From 7800a2d87818803da5640f3c3de933ab70f887e7 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 10 May 2026 19:57:24 -0700 Subject: [PATCH 1/2] agents|feat: Restructure voice and format rules to enforce inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice rules — the per-sentence outcome test and identifier ban — and a concrete audit step are now inlined at every voice-authoring step (commit body, change-summary `## What`, merge-commit body), via a shared partial. The rules sit in the agent's working context immediately before the action that produces voice content; previously they were referenced from one click away and skipped reliably enough to keep producing per-PR violations. The voice SSOT is renamed `release-notes-voice.md` → `lede-voice.md` to reflect its broader scope (it now governs commit titles, commit bodies, change summaries, and merge bodies). It absorbs the title-content rules and body-content rules previously in `commit-format.md`, plus a new Bad/Good worked example for the format/glyph adoption trap. Title-template rendering (cross-cutting across commits, PRs, ticket titles, and merge titles) moves to a new `title-templates.md`. Commit-specific mechanics — line length, body formatting, scope rules — fold into `commit/SKILL.md`. `commit-format.md` is removed. --- .../content/_partials/voice-checklist.md | 20 +++++++ .../{release-notes-voice.md => lede-voice.md} | 35 ++++++++++- .../{commit-format.md => title-templates.md} | 59 ++++--------------- .../agents/content/skills/commit/SKILL.md | 37 ++++++------ .../content/skills/condense-branch/SKILL.md | 2 +- .../agents/content/skills/create-pr/SKILL.md | 2 +- .../agents/content/skills/merge-pr/SKILL.md | 4 +- .../content/skills/summarize-change/SKILL.md | 4 +- .../content/subagents/orchestrated-coder.md | 2 +- .../src/commands/__tests__/install.test.ts | 10 ++-- 10 files changed, 94 insertions(+), 81 deletions(-) create mode 100644 packages/agents/content/_partials/voice-checklist.md rename packages/agents/content/skills/_data/{release-notes-voice.md => lede-voice.md} (79%) rename packages/agents/content/skills/_data/{commit-format.md => title-templates.md} (64%) diff --git a/packages/agents/content/_partials/voice-checklist.md b/packages/agents/content/_partials/voice-checklist.md new file mode 100644 index 00000000..45949704 --- /dev/null +++ b/packages/agents/content/_partials/voice-checklist.md @@ -0,0 +1,20 @@ +Before saving, audit your draft against these two rules. (See [`lede-voice.md`](../_data/lede-voice.md) for full doctrine and worked examples.) + +**Rule 1 — Each sentence must be one of:** + +- **Outcome** — what the reader will experience, see, or be able to do. +- **Stated invariant** — explicit assurance worth confirming ("Behavior is unchanged."; "No migration required."). +- **Migration info** — names of user-facing surface added, removed, or renamed; steps the reader must take. + +Cut any sentence describing mechanism (internal data, code paths, refactor mechanics, output-format details, internal counts). Indirect outcomes (reliability, performance) are permitted only if specific. + +**Rule 2 — Identifiers permitted only for top-level user-configurable surface:** + +- Allowed: package names, CLI commands and flags, top-level config-file paths, public-API endpoints/methods. +- Banned: schema field names, default values for configurable names, internal file paths, function/type/class/module names, internal subsystem names, internal version markers, output-format details (JSON keys, marker glyphs, header strings). +- Exception: user-facing surface that has been added, removed, or renamed may be named for migration (both removed identifiers and new defaults). + +**Audit before save:** + +1. For each sentence, classify it: Outcome / Invariant / Migration. Rewrite anything that fits none. +2. For each identifier, confirm it's in the allowed list. Drop or rephrase anything that isn't. diff --git a/packages/agents/content/skills/_data/release-notes-voice.md b/packages/agents/content/skills/_data/lede-voice.md similarity index 79% rename from packages/agents/content/skills/_data/release-notes-voice.md rename to packages/agents/content/skills/_data/lede-voice.md index 663ba24a..4de5fb58 100644 --- a/packages/agents/content/skills/_data/release-notes-voice.md +++ b/packages/agents/content/skills/_data/lede-voice.md @@ -1,9 +1,10 @@ -# Release-notes voice +# Lede voice -This file defines the voice for changelog and release-notes entries. The same voice may be adopted by any artifact whose first paragraph is the entry point for a glancing reader. Currently used by: +This file defines the voice for changelog and release-notes entries — and any other artifact whose first paragraph is the entry point for a glancing reader. The metaphor is journalism: the entry is the lede; the PR is the article. Currently used by: - `summarize-change/SKILL.md` for the `## What` section -- `commit/SKILL.md` for the commit body +- `commit/SKILL.md` for the commit title and body +- `merge-pr/SKILL.md` for the merge commit body ## The reader @@ -68,6 +69,22 @@ Banned: When in doubt, leave the name out and describe the behavior. The one exception: User-configurable surface needed for migration may be named — both _removed_ identifiers (so the user recognizes what's gone) and _new_ defaults (so the user knows where to find or move them). Examples: "The `--fix-low` flag is replaced by `--approval-threshold`."; "The default config file is now `.config/v11y-check.config.json`." +## Title application + +A title is a single-sentence lede. Both rules apply, distilled: + +- **Outcome, not mechanism.** The title goes to changelogs and release notes — readers often see only the title. Ask "what does this change deliver?", not "what did I edit?". Bad: "Upgrade hono from v1 to v2." Good: "Upgrade hono to patch authentication vulnerability." +- **The code change, not what prompted it.** Ask "what does the diff do?", not "why did I open the editor?". Bad: "Address review findings"; "Apply feedback"; "Incorporate suggestions". Good: "Add error logging to `handleStateUpdate`"; "Remove dead rejection handler". +- **No ephemeral references.** If it would not make sense to a reader who has only `git log`, leave it out. +- **Only what's in the diff.** External actions (updating a ticket, posting a comment, sending a notification) are not part of the change and do not belong in its title. + +## Body content discipline + +Body text — commit bodies, `## What`, merge-commit bodies — has two specific exclusions on top of the two rules: + +- **Never reference automated tests or CI.** Formatting, linting, typechecking, and unit tests run automatically. Mentioning them in the body is process noise, not user content. +- **Never use review finding IDs.** Identifiers like `F1`, `W2`, `T3` belong only in review documents — they are meaningless in `git log` and to any future reader. + ## Length The entry is as long as needed to convey outcomes, invariants, and migration info — and not one word longer. @@ -141,3 +158,15 @@ Cut: the mechanism clause ("Each CLI now reads its version directly from its `pa > Below-threshold vulnerabilities are now surfaced in `check` output instead of silently hidden, so users can see what their configured threshold is filtering out. Exit code behavior is unchanged. Cut: marker glyph, "ignored" annotation, JSON field name, scope-header format string, branch in the "no vulnerabilities" message. Survives: outcome (visibility) plus invariant (exit codes unchanged). + +### Bad → Good — format/glyph adoption + +**Bad** (output-format details under Rule 2): + +> Adds work-type emojis to PR descriptions: `## Details` subsections now render as `### 🎉 Features`, `### 🐛 Bug fixes`, `### ♻️ Refactoring`, `### 🧪 Tests`, and `### 📦 Dependencies`. Breaking changes in any subsection get a `🚨 **Breaking:**` prefix on the entry's first line. + +**Good:** + +> Adds work-type emojis to PR-description section headings for at-a-glance scanning, and an inline marker on entries that introduce breaking changes. + +Cut: every specific emoji, every header string, every marker glyph — all output-format details. Survives: the outcome (scanability) and the fact that breaking changes carry a marker, without naming the marker. The trap with this pattern is the urge to enumerate the new visuals because they feel like the user-facing change; they aren't — the user-facing change is "things are easier to scan." diff --git a/packages/agents/content/skills/_data/commit-format.md b/packages/agents/content/skills/_data/title-templates.md similarity index 64% rename from packages/agents/content/skills/_data/commit-format.md rename to packages/agents/content/skills/_data/title-templates.md index 21f4a207..9d457de9 100644 --- a/packages/agents/content/skills/_data/commit-format.md +++ b/packages/agents/content/skills/_data/title-templates.md @@ -1,10 +1,8 @@ -# Git commit format - -## Commit title format +# Title templates Commit titles, ticket titles, PR titles, and squash-merge titles are produced from declarative templates. Each surface has its own template, configured per repository and per user, and rendered by `describe-change.sh` from a small set of named tokens. -### Rendering the title +## Rendering the title Run the `describe-change.sh` script with every input that is available; templates control which tokens are required: @@ -34,7 +32,7 @@ Use `commit_title` for commit titles, `ticket_title` for issue titles, `pr_title If the script is not found, fall back to the bare `--title` value. -### Supported tokens +## Supported tokens | Token | Resolves to | | -------------- | ------------------------------------------------------------------------------------- | @@ -46,13 +44,13 @@ If the script is not found, fall back to the bare `--title` value. A template that omits `{title}` produces a title without the bare title text — `describe-change.sh` does not insert it implicitly. Unknown tokens (e.g., a typo like `{titel}`) are left as-is so the mistake is visible in the rendered output. -### Optional groups via `[...]` +## Optional groups via `[...]` A `[...]` group renders verbatim if every token reference inside resolves non-empty. If any inner token is empty, the entire group — literals included — drops. Groups are processed left-to-right and may not be nested. After substitution, a final whitespace pass collapses runs of multiple spaces into a single space and trims leading and trailing whitespace. -#### Worked example +### Worked example Template: `[{ticket_ref} ][{scope}|{type}: ]{title}[ (#{pr_number})]` @@ -66,7 +64,7 @@ Template: `[{ticket_ref} ][{scope}|{type}: ]{title}[ (#{pr_number})]` The whitespace-collapse pass turns ` Add foo ` into `Add foo` and prevents extra spaces from showing up next to dropped groups (e.g., `[{ticket_ref}] {title} [{pr_number}]` with only `{title}` populated renders as `Add foo`, not ` Add foo `). -### Common templates +## Common templates ```yaml commit: @@ -90,47 +88,12 @@ Quote `title_format` values in YAML (single or double quotes are both fine). Quo | `'[{ticket_ref} ]{title}'` | `#466 Add script installer` | | `'{title} ({ticket_ref})'` | `Add script installer (#466)` | -### Scope +## Scope values -The scope identifies the part of the codebase affected by the commit: +The `{scope}` token expects a value that identifies the part of the codebase affected. Surface-defined values: - In a monorepo, the scope is typically the workspace name or abbreviation. -- Use `root` if the commit touches only files in the monorepo root. -- Use `*` if the commit spans multiple workspaces, or root and one or more workspaces. -- If a root change is tightly associated with only one workspace, don't count it as a root change. - -Common example: if a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root. - -## Title constraints - -- **72 characters max** (hard limit). -- **Describes the code change, not what prompted it.** Ask: "what does the diff do?" Bad: "Address review findings". Good: "Add error logging to `handleStateUpdate`". -- **Describes the outcome, not the mechanism.** The title feeds the changelog and, for release-notes-contributing work types, the release notes — a reader scanning those sees only the title. Ask: "what does this change deliver?" — not "what did I edit?" Bad: "Upgrade hono from v1 to v2". Good: "Upgrade hono to patch authentication vulnerability". -- **No ephemeral references.** If it won't make sense to a reader who has only `git log`, leave it out. -- **Only document what's in the diff.** External actions (e.g., updating a ticket) don't belong. - -Add `!` after the work type to indicate breaking changes: `agents|feat!: Remove deprecated API` - -## Ticket ID - -Do not include the ticket ID in the commit title. The branch name already carries it. - -Include the ticket ID at the end of the commit body only if the branch covers more than one ticket (rare). - -## Line length - -- **Title**: 72 characters max (hard limit). -- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width. - -## Body formatting - -- **Release-notes voice.** The body is extracted into the changelog and (for release-notes-contributing work types) into release notes. Apply the [release-notes voice](release-notes-voice.md). -- **Punctuate list items.** Each bulleted item ends with a period, comma, or semicolon. -- **Use backticks for code identifiers.** Variable names, function names, class names, and file paths must be wrapped in backticks — e.g., `handleStateUpdate`, `AgentActor`, `src/lib/manifest.ts`. -- **Never reference automated tests or CI.** Do not mention formatting, linting, unit tests, or typechecking as part of what the commit does. -- **Never use review finding IDs.** Identifiers like F1, W2, T3 belong only in review documents — they are meaningless in `git log`. -- **Break up large paragraphs.** Use a blank line between paragraphs. Prefer short, focused paragraphs over walls of text. - -## Branch naming +- Use `root` when the change touches only files at the monorepo root. +- Use `*` when the change spans multiple workspaces, or root and one or more workspaces. -See `branch-format.md` for branch naming conventions. Branch format: `{ticket}/{description}`. +Per-surface guidance on when to apply each value (e.g., what to count as `root` for a commit) lives with the consuming skill — see `commit/SKILL.md` for the commit-side rules. diff --git a/packages/agents/content/skills/commit/SKILL.md b/packages/agents/content/skills/commit/SKILL.md index 504c4ad2..4383d29e 100644 --- a/packages/agents/content/skills/commit/SKILL.md +++ b/packages/agents/content/skills/commit/SKILL.md @@ -8,38 +8,35 @@ user-invocable: true Commit titles and bodies are extracted into the changelog and, for release-notes-contributing work types, into release notes. Commit bodies also feed the PR's `## What` section via the `summarize-change` skill. Write with those downstream surfaces in mind. -## Commit message format - -See `../_data/commit-format.md` for the full specification, including how to render the commit title using `describe-change.sh`. - ## Commit metadata -- `WORK_TYPE` describes the category of work (see `../_data/work-types.json`) +- `WORK_TYPE` describes the category of work (see `../_data/work-types.json`). -## Ticket ID +## Commit title -Do not include the ticket ID in the commit title. The branch name carries it. Include it at the end of the commit body only if the branch covers more than one ticket (rare). +Render via `describe-change.sh` — see `../_data/title-templates.md` for the full template syntax, supported tokens, and rendering pipeline. -## Line length +- **72 characters max** (hard limit). +- **Imperative, task-oriented voice.** "Add…", "Fix…", "Prevent…", "Enable…" — describing what the coder did. The title appears next to the PR number in release notes; it reads as the task. Distinct from the body voice, which is declarative. +- For content discipline (outcome-not-mechanism, code-change-not-prompt, no ephemeral references, only what's in the diff), see the "Title application" section in [`lede-voice.md`](../_data/lede-voice.md). +- Mark breaking changes by appending `!` to the work type: `agents|feat!: Remove deprecated API`. See [Breaking changes](#breaking-changes) below for which types are eligible. -- **Title**: 72 characters max (hard limit). -- **Body**: No hard wrapping. Write naturally — do not insert newlines to wrap at a column width. +## Ticket ID -## Title guidelines +Do not include the ticket ID in the commit title. The branch name carries it. Include it at the end of the commit body only if the branch covers more than one ticket (rare). -- **Imperative, task-oriented voice.** "Add…", "Fix…", "Prevent…", "Enable…" — describing what the coder did. The title appears next to the PR number in release notes; it reads as the task. This voice is distinct from the body voice, which is declarative (see below). -- **Describes the code change, not what prompted it.** Ask: "what does the diff do?" — not "why did I open the editor?" Bad: "Address review findings", "Apply feedback", "Incorporate suggestions". Good: "Add error logging to handleStateUpdate", "Remove dead rejection handler". -- **Describes the outcome, not the mechanism.** The title feeds the changelog and, for release-notes-contributing work types, the release notes — a reader scanning those sees only the title. Ask: "what does this change deliver?" — not "what did I edit?" Bad: "Upgrade hono from v1 to v2". Good: "Upgrade hono to patch authentication vulnerability". -- **No ephemeral references.** If it won't make sense to a reader who has only `git log`, leave it out. -- **Only document what's in the diff.** External actions (e.g., updating a ticket) are not part of the commit and don't belong in its message. +## Commit body -## Body guidelines +**Body voice.** The commit body feeds the changelog, release notes (for release-notes-contributing types), and the PR's `## What` section. The first paragraph of the body must stand alone as the entry; subsequent paragraphs may elaborate for the engaged reader who has clicked through. -**Voice: release-notes voice.** The commit body feeds the changelog, release notes (for release-notes-contributing types), and the PR's `## What` section. Apply the [release-notes voice](../_data/release-notes-voice.md): The per-sentence outcome test and the identifier ban. The first paragraph of the body must stand alone as the changelog/release-notes entry; subsequent paragraphs may elaborate for the engaged reader who has clicked through. + -**No hard line breaks.** Write each paragraph or list item as a single long line. Do not insert newlines to wrap at a column width. Every tool that renders commit messages handles wrapping; manual breaks produce ragged text. +### Body mechanics -See `../_data/commit-format.md` for mechanical body formatting rules (punctuation, backtick formatting, paragraph structure, and what to omit). +- **No hard line breaks.** Write each paragraph or list item as a single long line. Do not insert newlines to wrap at a column width. Every tool that renders commit messages handles wrapping; manual breaks produce ragged text. +- **Punctuate list items.** Each bulleted item ends with a period, comma, or semicolon. +- **Use backticks for code identifiers.** Variable names, function names, class names, and file paths must be wrapped in backticks — e.g., `handleStateUpdate`, `AgentActor`, `src/lib/manifest.ts`. +- **Break up large paragraphs.** Use a blank line between paragraphs. Prefer short, focused paragraphs over walls of text. ## Changes touching multiple scopes diff --git a/packages/agents/content/skills/condense-branch/SKILL.md b/packages/agents/content/skills/condense-branch/SKILL.md index 36a26c93..0563cc94 100644 --- a/packages/agents/content/skills/condense-branch/SKILL.md +++ b/packages/agents/content/skills/condense-branch/SKILL.md @@ -44,7 +44,7 @@ Use `summarize-change` to compose a good commit message. Save the description pe ## Commit format -Follow [commit-format.md](../_data/commit-format.md). Use `describe-change.sh` to render the full commit title: +Follow the [commit conventions](../commit/SKILL.md). Use `describe-change.sh` to render the full commit title (see [title-templates.md](../_data/title-templates.md) for syntax): ```bash json=$({platform_home_dir}/scripts/describe-change.sh \ diff --git a/packages/agents/content/skills/create-pr/SKILL.md b/packages/agents/content/skills/create-pr/SKILL.md index efe4dad0..156684c6 100644 --- a/packages/agents/content/skills/create-pr/SKILL.md +++ b/packages/agents/content/skills/create-pr/SKILL.md @@ -66,7 +66,7 @@ Use `pr_title` directly as the final PR title. Do not concatenate with `title` s If the script is not found, fall back to the bare `title` from the change summary. -See [commit-format.md](../_data/commit-format.md) for the title-format model and supported tokens. +See [title-templates.md](../_data/title-templates.md) for the title-format model and supported tokens. ### 7. Resolve labels diff --git a/packages/agents/content/skills/merge-pr/SKILL.md b/packages/agents/content/skills/merge-pr/SKILL.md index af6345b1..e38878f2 100644 --- a/packages/agents/content/skills/merge-pr/SKILL.md +++ b/packages/agents/content/skills/merge-pr/SKILL.md @@ -119,7 +119,9 @@ git log {default_branch}..HEAD --format=%B git diff {default_branch}...HEAD --stat ``` -Write the composed body in **release-notes voice** — apply the [release-notes voice](../_data/release-notes-voice.md). Describe the accomplishment from the reader's standpoint, not the edits. One short paragraph is usually enough; add a follow-up paragraph only when the change is substantial. +Describe the accomplishment from the reader's standpoint. One short paragraph is usually enough; add a follow-up paragraph only when the change is substantial. + + ### 7. Approval gate diff --git a/packages/agents/content/skills/summarize-change/SKILL.md b/packages/agents/content/skills/summarize-change/SKILL.md index e5a73639..8b50454d 100644 --- a/packages/agents/content/skills/summarize-change/SKILL.md +++ b/packages/agents/content/skills/summarize-change/SKILL.md @@ -50,7 +50,9 @@ Generated by: {Agent name} (model: {model}) ## What -{This section becomes a changelog entry — and, for public-tier work (per [work-types.json](../_data/work-types.json)), a release note. Apply the [release-notes voice](../_data/release-notes-voice.md): The per-sentence outcome test and the identifier ban. Mechanism, internal naming, and refactor mechanics belong in `## Details`.} +{This section becomes a changelog entry — and, for public-tier work (per [work-types.json](../_data/work-types.json)), a release note. Mechanism, internal naming, and refactor mechanics belong in `## Details`.} + + ## Why diff --git a/packages/agents/content/subagents/orchestrated-coder.md b/packages/agents/content/subagents/orchestrated-coder.md index 4ac9a926..4652b08f 100644 --- a/packages/agents/content/subagents/orchestrated-coder.md +++ b/packages/agents/content/subagents/orchestrated-coder.md @@ -167,7 +167,7 @@ If the project does not have a particular quality gate configured, note "N/A" fo Every commit message MUST satisfy all five rules. Violations are treated as quality gate failures. -1. **Render the commit title** using `describe-change.sh` (see `commit-format.md` in the commit skill's `_data/` directory). Pass `--title`, `--scope`, and `--type` and read `commit_title` from the JSON output. If the script is not found, fall back to the bare title. +1. **Render the commit title** using `describe-change.sh` (see `title-templates.md` in the commit skill's `_data/` directory). Pass `--title`, `--scope`, and `--type` and read `commit_title` from the JSON output. If the script is not found, fall back to the bare title. 2. **Title describes the code change, not the process.** Ask "what does the diff do?" — never "why did I open the editor?" Forbidden: "Address review findings," "Apply feedback," "Fix issues from review," "Incorporate suggestions." Required: describe the actual change — "Fix null check in layout resolver," "Remove unused layout fields." 3. **Title is 72 characters max.** Count characters before committing. If it's too long, shorten it. 4. **No hard line breaks in the body.** Write naturally as continuous text. Do not insert newlines to wrap at a fixed column width. diff --git a/packages/agents/src/commands/__tests__/install.test.ts b/packages/agents/src/commands/__tests__/install.test.ts index 3b108f3e..07b35a23 100644 --- a/packages/agents/src/commands/__tests__/install.test.ts +++ b/packages/agents/src/commands/__tests__/install.test.ts @@ -270,7 +270,7 @@ describe('installCommand', () => { const dataContents = await readdir(path.join(claudeHome, 'skills', '_data')); expect(dataContents).toContain('artifact-conventions.md'); expect(dataContents).toContain('next-steps-after-plan.md'); - expect(dataContents).toContain('commit-format.md'); + expect(dataContents).toContain('title-templates.md'); }); it('should generate prompts.yml for rovodev with valid YAML structure', async () => { @@ -653,12 +653,12 @@ describe('installCommand', () => { await installCommand(makeOptions(), tempDir); // _data files have no frontmatter; marker should be HTML comment - const dataPath = path.join(claudeHome, 'skills', '_data', 'commit-format.md'); + const dataPath = path.join(claudeHome, 'skills', '_data', 'title-templates.md'); const content = await readFile(dataPath, 'utf8'); expect(content.startsWith(HTML_MARKER_LINE_1)).toBe(true); expect(content).toContain( - '', + '', ); }); @@ -746,13 +746,13 @@ describe('installCommand', () => { await installCommand(makeOptions(), tempDir); const firstSkill = await readFile(path.join(claudeHome, 'skills', 'commit', 'SKILL.md'), 'utf8'); - const firstData = await readFile(path.join(claudeHome, 'skills', '_data', 'commit-format.md'), 'utf8'); + const firstData = await readFile(path.join(claudeHome, 'skills', '_data', 'title-templates.md'), 'utf8'); const firstSubagent = await readFile(path.join(claudeHome, 'agents', 'orchestrated-coder.md'), 'utf8'); const firstShared = await readFile(path.join(tempDir, '.agents', 'AGENTS.md'), 'utf8'); await installCommand(makeOptions(), tempDir); const secondSkill = await readFile(path.join(claudeHome, 'skills', 'commit', 'SKILL.md'), 'utf8'); - const secondData = await readFile(path.join(claudeHome, 'skills', '_data', 'commit-format.md'), 'utf8'); + const secondData = await readFile(path.join(claudeHome, 'skills', '_data', 'title-templates.md'), 'utf8'); const secondSubagent = await readFile(path.join(claudeHome, 'agents', 'orchestrated-coder.md'), 'utf8'); const secondShared = await readFile(path.join(tempDir, '.agents', 'AGENTS.md'), 'utf8'); From 44636074d72fbccd9c15ff6e6b2c2c2d0c13fd9b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 10 May 2026 20:32:56 -0700 Subject: [PATCH 2/2] agents|refactor: Centralize scope-value enumeration in title-templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope-value enumeration (`root` / `*` / workspace name) now lives only in `title-templates.md` "Scope values" — `commit/SKILL.md` "Changes touching multiple scopes" delegates to that section and retains only the commit-side disciplinary rule (with its common example). Polish: The `voice-checklist.md` preamble is rephrased from "audit your draft against these two rules" to "audit your draft against the two rules below" to disambiguate from the numbered audit steps further down. The imperative-voice bullet in `commit/SKILL.md` "Commit title" gains explicit examples of declarative voice ("Adds…", "Fixes…") to make the contrast with the lede voice unambiguous. Stale `commit-format.md` references in `path-rewriter.test.ts` fixtures are updated to `title-templates.md`, reflecting a real file. --- .../content/_partials/voice-checklist.md | 2 +- .../agents/content/skills/commit/SKILL.md | 8 +++---- .../src/lib/__tests__/path-rewriter.test.ts | 22 +++++++++---------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/agents/content/_partials/voice-checklist.md b/packages/agents/content/_partials/voice-checklist.md index 45949704..9f5ebe11 100644 --- a/packages/agents/content/_partials/voice-checklist.md +++ b/packages/agents/content/_partials/voice-checklist.md @@ -1,4 +1,4 @@ -Before saving, audit your draft against these two rules. (See [`lede-voice.md`](../_data/lede-voice.md) for full doctrine and worked examples.) +Before saving, audit your draft against the two rules below. (See [`lede-voice.md`](../_data/lede-voice.md) for full doctrine and worked examples.) **Rule 1 — Each sentence must be one of:** diff --git a/packages/agents/content/skills/commit/SKILL.md b/packages/agents/content/skills/commit/SKILL.md index 4383d29e..92df5d15 100644 --- a/packages/agents/content/skills/commit/SKILL.md +++ b/packages/agents/content/skills/commit/SKILL.md @@ -17,7 +17,7 @@ Commit titles and bodies are extracted into the changelog and, for release-notes Render via `describe-change.sh` — see `../_data/title-templates.md` for the full template syntax, supported tokens, and rendering pipeline. - **72 characters max** (hard limit). -- **Imperative, task-oriented voice.** "Add…", "Fix…", "Prevent…", "Enable…" — describing what the coder did. The title appears next to the PR number in release notes; it reads as the task. Distinct from the body voice, which is declarative. +- **Imperative, task-oriented voice.** "Add…", "Fix…", "Prevent…", "Enable…" — describing what the coder did. The title appears next to the PR number in release notes; it reads as the task. Distinct from the lede voice, which is declarative ("Adds…", "Fixes…"). - For content discipline (outcome-not-mechanism, code-change-not-prompt, no ephemeral references, only what's in the diff), see the "Title application" section in [`lede-voice.md`](../_data/lede-voice.md). - Mark breaking changes by appending `!` to the work type: `agents|feat!: Remove deprecated API`. See [Breaking changes](#breaking-changes) below for which types are eligible. @@ -40,11 +40,9 @@ Do not include the ticket ID in the commit title. The branch name carries it. In ## Changes touching multiple scopes -- Use `root` if commit touches only files in monorepo root -- Use `*` if commit comprises changes to multiple scopes, or root and one or more scopes -- If a root change is tightly associated with only one scope, don't count it as a root change +For the structural scope values (`root`, `*`, workspace name), see "Scope values" in [`../_data/title-templates.md`](../_data/title-templates.md). -Common example: If a package is added to `packages/workspace-a`, that updates the package lock file in root. Don't treat that as a change to root. +Commit-side application: when more than one scope-value would technically apply, use the closest fit. If a root change is tightly associated with only one workspace, count it as a workspace change rather than a root change. Common example: if a package is added to `packages/workspace-a`, that updates the package lock file in root — still treat the commit as a workspace change. ## Branch naming diff --git a/packages/agents/src/lib/__tests__/path-rewriter.test.ts b/packages/agents/src/lib/__tests__/path-rewriter.test.ts index f9657747..9849373d 100644 --- a/packages/agents/src/lib/__tests__/path-rewriter.test.ts +++ b/packages/agents/src/lib/__tests__/path-rewriter.test.ts @@ -13,8 +13,8 @@ describe(rewriteMarkdownPaths, () => { { name: 'rewrites a relative ../_data/ link', fileRelPath: 'commit/SKILL.md', - content: 'See [commit-format.md](../_data/commit-format.md) for details.', - expected: 'See [commit-format.md](~/.claude/skills/_data/commit-format.md) for details.', + content: 'See [title-templates.md](../_data/title-templates.md) for details.', + expected: 'See [title-templates.md](~/.claude/skills/_data/title-templates.md) for details.', }, { name: 'preserves anchor fragments', @@ -45,8 +45,8 @@ describe(rewriteMarkdownPaths, () => { { name: 'leaves tilde-prefixed paths untouched', fileRelPath: 'commit/SKILL.md', - content: 'See [file](~/.claude/skills/_data/commit-format.md) for info.', - expected: 'See [file](~/.claude/skills/_data/commit-format.md) for info.', + content: 'See [file](~/.claude/skills/_data/title-templates.md) for info.', + expected: 'See [file](~/.claude/skills/_data/title-templates.md) for info.', }, { name: 'handles nested file paths correctly', @@ -75,9 +75,9 @@ describe(rewriteMarkdownPaths, () => { { name: 'rewrites multiple links in the same content', fileRelPath: 'commit/SKILL.md', - content: 'See [format](../_data/commit-format.md) and [types](../_data/work-types.md).', + content: 'See [format](../_data/title-templates.md) and [types](../_data/work-types.md).', expected: - 'See [format](~/.claude/skills/_data/commit-format.md) and [types](~/.claude/skills/_data/work-types.md).', + 'See [format](~/.claude/skills/_data/title-templates.md) and [types](~/.claude/skills/_data/work-types.md).', }, ])('$name', ({ fileRelPath, content, expected }) => { expect(rewriteMarkdownPaths(content, fileRelPath, skillsPrefix)).toBe(expected); @@ -141,12 +141,12 @@ describe(rewritePathsInDirectory, () => { it('rewrites relative links in .md files within a directory', async () => { const skillDir = path.join(skillsDestDir, 'commit'); await mkdir(skillDir, { recursive: true }); - await writeFile(path.join(skillDir, 'SKILL.md'), 'See [format](../_data/commit-format.md) for spec.', 'utf8'); + await writeFile(path.join(skillDir, 'SKILL.md'), 'See [format](../_data/title-templates.md) for spec.', 'utf8'); await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); - expect(result).toBe('See [format](~/.claude/skills/_data/commit-format.md) for spec.'); + expect(result).toBe('See [format](~/.claude/skills/_data/title-templates.md) for spec.'); }); it('recursively processes nested directories', async () => { @@ -167,7 +167,7 @@ describe(rewritePathsInDirectory, () => { it('skips non-.md files', async () => { const skillDir = path.join(skillsDestDir, 'test-skill'); await mkdir(skillDir, { recursive: true }); - const originalContent = 'See [format](../_data/commit-format.md) for spec.'; + const originalContent = 'See [format](../_data/title-templates.md) for spec.'; await writeFile(path.join(skillDir, 'notes.txt'), originalContent, 'utf8'); await rewritePathsInDirectory(skillDir, skillsDestDir, '.claude/skills', '.claude'); @@ -205,7 +205,7 @@ describe(rewritePathsInDirectory, () => { await mkdir(skillDir, { recursive: true }); await writeFile( path.join(skillDir, 'SKILL.md'), - 'See [format](../_data/commit-format.md). Run {platform_home_dir}/scripts/describe-change.sh.', + 'See [format](../_data/title-templates.md). Run {platform_home_dir}/scripts/describe-change.sh.', 'utf8', ); @@ -213,7 +213,7 @@ describe(rewritePathsInDirectory, () => { const result = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8'); expect(result).toBe( - 'See [format](~/.claude/skills/_data/commit-format.md). Run ~/.claude/scripts/describe-change.sh.', + 'See [format](~/.claude/skills/_data/title-templates.md). Run ~/.claude/scripts/describe-change.sh.', ); });