@erclx/canon 5.58.0 → 5.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.59.0](https://github.com/erclx/canon/compare/v5.58.0...v5.59.0) (2026-10-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **cli:** name the release that retired a verb and find its callers ([#2662](https://github.com/erclx/canon/issues/2662)) ([32fddb5](https://github.com/erclx/canon/commit/32fddb5f8c363441fc18ffb4243c08a65a863a67))
9
+ * **gov:** report rule coverage after gov sync ([#2660](https://github.com/erclx/canon/issues/2660)) ([c087965](https://github.com/erclx/canon/commit/c087965acd64249f8dfbe9ca1d98a7857621d380))
10
+ * **pr:** compose bot pull request merge messages ([#2659](https://github.com/erclx/canon/issues/2659)) ([698991c](https://github.com/erclx/canon/commit/698991c71b6423279756f023c86cd2537ae46017))
11
+ * **sandbox:** add a canon-feedback-triage anchor scenario ([#2656](https://github.com/erclx/canon/issues/2656)) ([ae45573](https://github.com/erclx/canon/commit/ae45573e2b385b97cdfbf300b4d1f9bb0f3db839))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **sync:** write the stamp in the formatter shape ([#2655](https://github.com/erclx/canon/issues/2655)) ([a34c5fb](https://github.com/erclx/canon/commit/a34c5fb2d18314ec31d83640be387057b68638a7))
17
+
3
18
  ## [5.58.0](https://github.com/erclx/canon/compare/v5.57.0...v5.58.0) (2026-10-24)
4
19
 
5
20
 
package/README.md CHANGED
@@ -140,7 +140,7 @@ Bun, Git, and a shell are required, and the GitHub CLI is optional.
140
140
  - [Bun](https://bun.sh) for the CLI runtime and scripts
141
141
  - [Git](https://git-scm.com) with worktree support
142
142
  - [GitHub CLI](https://cli.github.com) (optional) for ship flows
143
- - Shell: `zsh` or bash 4+ (`brew install bash` on macOS).
143
+ - Shell: `zsh`, since bootstrap appends its aliases to `~/.zshrc`.
144
144
 
145
145
  Clone the repo, then run the bootstrap script. It installs dependencies, links the CLI globally, and appends a marked block of Claude Code shell aliases to your `~/.zshrc`.
146
146
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.58.0",
4
+ "version": "5.59.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -64,6 +64,8 @@ Target update: `<path>`, canon `<installed>` to `<running>`.
64
64
 
65
65
  **Migrations, in run order:** <each pending or refused entry from migrate status, with its verb>
66
66
 
67
+ **Retired calls:** <each `retiredCallers` entry from migrate status, with its file, line, the release that retired the verb, and where the work went, or omitted when the list is empty>
68
+
67
69
  **Drift:** <each non-empty cause from sync --check, with its count and paths>
68
70
 
69
71
  **Where it lands:** <the linked worktree this run is already in, or the worktree `.claude/worktrees/<name>/` it will enter, or the branch it will create in place when the operator declines a worktree>
@@ -73,11 +75,13 @@ Target update: `<path>`, canon `<installed>` to `<running>`.
73
75
  **Commands, in order:** <the exact commands Steps 4 to 6 will run>
74
76
  ```
75
77
 
78
+ A retired call is no migration, so no step here fixes it. Name it in the preview and leave the edit to the operator, who owns the script.
79
+
76
80
  Summarize the changelog for this project, not for the toolkit. Drop an entry that touches nothing the target holds, and lead with anything a migration or a sync will change.
77
81
 
78
82
  ### Nothing pending
79
83
 
80
- When the changelog range is empty, `migrate status` lists nothing pending or refused, and the drift lines hold nothing a step here would take, write nothing. Invoke `canon:target-check` and report the target current with its table.
84
+ When the changelog range is empty, `migrate status` lists nothing pending or refused and no `retiredCallers`, and the drift lines hold nothing a step here would take, write nothing. Invoke `canon:target-check` and report the target current with its table.
81
85
 
82
86
  ### Take a branch after the go-ahead
83
87
 
@@ -24,7 +24,7 @@ A flat installed-rule layout makes `gov sync` report "No governance surfaces fou
24
24
 
25
25
  ## Governance sync
26
26
 
27
- `canon gov sync [target]` updates what `.claude/rules/canon/` holds and adds nothing. `.claude/rules/project/` is never read, so put project rules there.
27
+ `canon gov sync [target]` updates what `.claude/rules/canon/` holds and adds nothing. `.claude/rules/project/` is never read, so put project rules there. After an apply it lists each copied rule with its globs and matching file count, and `--json` puts `{ "coverage": [...] }` on stdout.
28
28
 
29
29
  - A rule the toolkit no longer ships is deleted, edited or not
30
30
  - A rule renamed in `governance/renames.toml` moves, and its local edits are dropped
@@ -38,6 +38,12 @@ kind: reference
38
38
 
39
39
  `status` runs each verb as a dry run and exits 2 while any is pending, with a refusal outranking that. Each entry's `verb` carries the flags its dry run used, so append `--write` to it. A clean entry can turn pending once an earlier one applies, so run it again after each `--write`.
40
40
 
41
+ `status --json` also carries `retiredCallers`, the project calls to a verb a release retired.
42
+
43
+ - Each entry has `file`, `line`, `verb`, `release`, and `movedTo`
44
+ - The scan reads the workflow folder, the `package.json` scripts, `scripts/`, and the files those name, matching a command line or an argument list and never prose
45
+ - A caller changes no exit code, since no `--write` fixes one, so edit the script by hand
46
+
41
47
  Three markers keep an old spelling on a line. Each also covers the nearest non-blank line, below it for `canon-keep-record-root` and above it for `canon-keep-surface-root`.
42
48
 
43
49
  | Marker | Keeps |
@@ -43,10 +43,12 @@ Add `--json` for a machine-readable report and `--exit-code` to fail CI when a t
43
43
  ## Targeted
44
44
 
45
45
  - Seed docs such as `CLAUDE.md` and `canon/REQUIREMENTS.md`: merge by hand, keeping the project's customizations
46
- - Installed governance rules: `canon gov sync <path>`, which adds no new rules
46
+ - Installed governance rules: `canon gov sync <path>`, which adds no new rules and reports coverage afterward
47
47
  - Tooling configs and seeds: read differences with `canon tooling diff <stack> <path>`, then `canon tooling sync <stack> <path> --write`, which overwrites golden configs and copies each seed once
48
48
  - Index regeneration: `canon indexes regen`
49
49
 
50
+ After `canon gov sync` applies, it lists each rule it copied with its globs and how many project files match. A `ui/` rule adds a pointer to `/canon:ux-audit`. `--json` writes `{ "coverage": [{ "rule", "paths", "files", "ui" }] }` to stdout, empty when nothing was copied, with `files` null outside git. The report never changes the exit code.
51
+
50
52
  Try a scenario from [sandbox](../../canon/context/development/sandbox.md) before syncing a real project.
51
53
 
52
54
  ## Running sync from an agent session
@@ -36,7 +36,7 @@ Each role keeps to its own writes, and five rules cover how the roles meet.
36
36
  7. A pass posts `## Review` while anything is open and `## Review closed` when nothing is. The reviewer then runs `canon pr lift`.
37
37
  8. The human merges with `canon pr merge <number>`. The orchestrator tells any trailing worker whose files the merge touched to run `review-address`, which rebases.
38
38
 
39
- Each worker halts at the PR, and the merge stays a human gate. `canon pr merge` squashes with the body's Summary, Key changes, and Technical context as the message, and the seeded deny rule covers the forms an agent writes by default. The merge button stays for a bot's pull request, such as a dependency bump or a release.
39
+ Each worker halts at the PR, and the merge stays a human gate. `canon pr merge` squashes with the body's Summary, Key changes, and Technical context as the message, and the seeded deny rule covers the forms an agent writes by default. A release-please or Dependabot pull request merges the same way, from the bot's own body.
40
40
 
41
41
  ## The review layer
42
42
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.58.0",
4
+ "version": "5.59.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -30,14 +30,14 @@
30
30
  "check:shell": "find scripts tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
31
31
  "check:lint": "oxlint --disable-nested-config --max-warnings 0",
32
32
  "check:types": "tsc --noEmit",
33
- "check:install": "./scripts/core/install-check.sh",
33
+ "check:install": "bun scripts/core/install-check.ts",
34
34
  "test": "bun --bun vitest run",
35
35
  "prepare": "husky",
36
36
  "check": "bun src/cli.ts gate run",
37
37
  "check:ci": "bun src/cli.ts gate run --all",
38
38
  "update": "bun update --interactive && bun run check",
39
39
  "clean": "rm -rf node_modules && bun pm cache rm && bun install",
40
- "bootstrap": "./scripts/core/bootstrap.sh",
40
+ "bootstrap": "bun scripts/core/bootstrap.ts",
41
41
  "canon:sandbox": "canon sandbox",
42
42
  "canon:sandbox:reset": "canon sandbox reset",
43
43
  "web:tokens": "bun src/cli.ts design css --no-components > web/src/styles/tokens.css.new && (echo '/* Generated by `canon design css --no-components`. Regenerate with `bun run web:tokens`. Do not hand-edit. */'; cat web/src/styles/tokens.css.new) > web/src/styles/tokens.css && rm web/src/styles/tokens.css.new",
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Installs dependencies, links the canon CLI globally, and appends the marked
3
+ * alias block to the user's `.zshrc`, the only file this writes outside the
4
+ * repository. Runs on Bun alone, so a stock macOS bash 3.2 never enters it.
5
+ */
6
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ import {
12
+ intro,
13
+ logAdd,
14
+ logError,
15
+ logInfo,
16
+ logStep,
17
+ logWarn,
18
+ outro,
19
+ } from '../../src/cli/ui'
20
+
21
+ const root = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
22
+
23
+ const MARKER_OPEN = '# >>> canon aliases >>>'
24
+ const MARKER_CLOSE = '# <<< canon aliases <<<'
25
+
26
+ function zshrcPath(): string {
27
+ const dir = process.env.ZDOTDIR
28
+ return join(dir !== undefined && dir !== '' ? dir : homedir(), '.zshrc')
29
+ }
30
+
31
+ /** `$TOOLKIT` stays a literal in the block, so the shell expands it on load. */
32
+ function aliasBlock(): string {
33
+ return `${MARKER_OPEN}
34
+ TOOLKIT="${root}"
35
+ export CLAUDE_CODE_ENABLE_TODO_TOOLS=1
36
+
37
+ alias cl='claude'
38
+ alias clr='cl -r'
39
+ alias clc='cl -c'
40
+ alias clw='cl -w'
41
+ alias cls='cl --model sonnet'
42
+
43
+ alias clp='claude --plugin-dir $TOOLKIT/claude'
44
+ alias clpc='clp -c'
45
+ alias clps='clp --model sonnet'
46
+ alias clpa='clp agents'
47
+ alias clpac='clpa --cwd .'
48
+ ${MARKER_CLOSE}`
49
+ }
50
+
51
+ function run(step: string, cmd: string[]): void {
52
+ const result = Bun.spawnSync(cmd, {
53
+ cwd: root,
54
+ stdout: 'ignore',
55
+ stderr: 'pipe',
56
+ })
57
+ if (result.exitCode !== 0) {
58
+ throw new Error(`${step} failed: ${result.stderr.toString().trim()}`)
59
+ }
60
+ }
61
+
62
+ function installCli(): void {
63
+ logStep('Installing the canon CLI')
64
+ run('bun install', ['bun', 'install', '--cwd', root])
65
+ run('bun link', ['bun', 'link', '--cwd', root])
66
+ logInfo('Dependencies installed and canon linked')
67
+ }
68
+
69
+ function installAliases(): void {
70
+ const zshrc = zshrcPath()
71
+ logStep('Installing Claude Code shell aliases')
72
+ logInfo(
73
+ `This step manages a marked alias block in ${zshrc}, the only file outside the repo this script writes.`,
74
+ )
75
+ logInfo(
76
+ 'Delete the block and its two marker comments to remove the aliases. Re-running never adds a second copy.',
77
+ )
78
+
79
+ const existing = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : ''
80
+ if (existing.includes(MARKER_OPEN)) {
81
+ logInfo(`Aliases already present in ${zshrc}, skipping`)
82
+ return
83
+ }
84
+ if (/^alias clp=/m.test(existing)) {
85
+ logWarn(
86
+ `An unmanaged clp alias is already in ${zshrc}. Leaving it untouched.`,
87
+ )
88
+ logWarn('Remove it and re-run to switch to the managed block.')
89
+ return
90
+ }
91
+
92
+ appendFileSync(zshrc, `\n${aliasBlock()}\n`)
93
+ logAdd(`Appended canon alias block to ${zshrc}`)
94
+ logInfo(`Run 'source ${zshrc}' or open a new shell to load them`)
95
+ }
96
+
97
+ function verifyCli(): void {
98
+ logStep('Verifying the install')
99
+ if (Bun.which('canon') !== null) {
100
+ logInfo('canon resolves on PATH')
101
+ } else {
102
+ logWarn(
103
+ 'canon not on PATH yet. Ensure $BUN_INSTALL/bin is exported, then re-open your shell.',
104
+ )
105
+ }
106
+ }
107
+
108
+ intro('canon bootstrap')
109
+ let failed = false
110
+ try {
111
+ installCli()
112
+ installAliases()
113
+ verifyCli()
114
+ } catch (error) {
115
+ failed = true
116
+ logError(error instanceof Error ? error.message : String(error))
117
+ } finally {
118
+ outro()
119
+ }
120
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Verifies the published install path end to end: packs this repo, installs
3
+ * the tarball for production, and runs `canon init` from it into a fresh
4
+ * project. Runs on Bun alone.
5
+ */
6
+ import { existsSync, mkdirSync, rmSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ import {
11
+ intro,
12
+ logError,
13
+ logInfo,
14
+ logStep,
15
+ outro,
16
+ pipeOutput,
17
+ } from '../../src/cli/ui'
18
+
19
+ const root = fileURLToPath(new URL('../..', import.meta.url)).replace(/\/$/, '')
20
+ const tmpRoot = join(root, '.canon/tmp/install-check')
21
+ const packDir = join(tmpRoot, 'pack')
22
+ const extractDir = join(tmpRoot, 'extract')
23
+ const targetDir = join(tmpRoot, 'target')
24
+
25
+ const HELP = `Usage: scripts/core/install-check.ts [--keep]
26
+
27
+ Verifies the published install path end to end:
28
+ 1. Packs this repo into a tarball at .canon/tmp/install-check/pack
29
+ 2. Extracts it and asserts sandbox is absent from the tree
30
+ 3. Runs bun install --production in the extracted tree
31
+ 4. Runs the CLI with --help from the extracted tree to confirm it resolves
32
+ 5. Scaffolds a fresh project in .canon/tmp/install-check/target
33
+ 6. Runs canon init from the extracted package and asserts a scaffold landed
34
+
35
+ Flags:
36
+ --keep Keep the tmp tree on exit for inspection
37
+ `
38
+
39
+ /**
40
+ * Every domain canon init installs needs at least one path here. A domain with
41
+ * no assertion can truncate silently: init still exits 0 because run_domain
42
+ * catches a failed domain, and the gate stays green while the target is
43
+ * missing everything that domain provides.
44
+ *
45
+ * Authoring references name no path because they ship inside the plugin's
46
+ * skills rather than installing into a target. The two rule paths stand for
47
+ * the `claude` and `canon` groups `base` takes whole.
48
+ */
49
+ const SCAFFOLD_PATHS = [
50
+ 'CLAUDE.md',
51
+ '.claude',
52
+ 'canon/context/index.md',
53
+ '.oxfmtrc.json',
54
+ '.lintstagedrc',
55
+ '.husky/pre-commit',
56
+ '.github/workflows/verify.yml',
57
+ 'scripts/verify.ts',
58
+ '.claude/rules/canon/claude/output.md',
59
+ '.claude/rules/canon/canon/scratch.md',
60
+ ]
61
+
62
+ const args = process.argv.slice(2)
63
+ if (args.includes('--help') || args.includes('-h')) {
64
+ process.stdout.write(HELP)
65
+ process.exit(0)
66
+ }
67
+ const keep = args.includes('--keep')
68
+
69
+ /** Runs a command to completion, showing its output inside the frame. */
70
+ function run(
71
+ failure: string,
72
+ cmd: string[],
73
+ opts: { cwd?: string; show?: boolean } = {},
74
+ ): string {
75
+ const result = Bun.spawnSync(cmd, {
76
+ cwd: opts.cwd ?? root,
77
+ stdin: 'ignore',
78
+ stdout: 'pipe',
79
+ stderr: 'pipe',
80
+ })
81
+ const stdout = result.stdout.toString()
82
+ const output = `${stdout}${result.stderr.toString()}`.trim()
83
+ if (opts.show && output !== '') pipeOutput(output)
84
+ if (result.exitCode !== 0) {
85
+ if (!opts.show && output !== '') pipeOutput(output)
86
+ throw new Error(failure)
87
+ }
88
+ return stdout
89
+ }
90
+
91
+ function check(): void {
92
+ logStep('Assert excluded path exists in source')
93
+ // An absence check over a path the source tree no longer holds passes on
94
+ // nothing, so the excluded tree has to exist here before its absence below
95
+ // can mean the publish left it out.
96
+ if (!existsSync(join(root, 'sandbox/exempt.toml'))) {
97
+ throw new Error(
98
+ 'sandbox/exempt.toml is missing from the source tree, so the absence check below would pass on nothing',
99
+ )
100
+ }
101
+ logInfo('Present: sandbox/exempt.toml')
102
+
103
+ logStep('Pack')
104
+ const packed = run('bun pm pack failed', [
105
+ 'bun',
106
+ 'pm',
107
+ 'pack',
108
+ '--quiet',
109
+ '--ignore-scripts',
110
+ '--destination',
111
+ packDir,
112
+ ])
113
+ const tarball = packed.trim().split('\n').pop() ?? ''
114
+ logInfo(`Packed to ${tarball}`)
115
+
116
+ logStep('Extract')
117
+ run('tar extract failed', [
118
+ 'tar',
119
+ '-xzf',
120
+ tarball,
121
+ '-C',
122
+ extractDir,
123
+ '--strip-components=1',
124
+ ])
125
+ logInfo(`Extracted to ${extractDir}`)
126
+
127
+ logStep('Assert excluded path is absent')
128
+ if (existsSync(join(extractDir, 'sandbox'))) {
129
+ throw new Error(
130
+ 'sandbox shipped in the tarball, and the files field in package.json leaves it out',
131
+ )
132
+ }
133
+ logInfo('Absent: sandbox')
134
+
135
+ logStep('Install dependencies (production)')
136
+ run(
137
+ 'bun install --production failed',
138
+ ['bun', 'install', '--production', '--ignore-scripts', '--silent'],
139
+ { cwd: extractDir, show: true },
140
+ )
141
+ logInfo('Dependencies installed without devDependencies')
142
+
143
+ logStep('Confirm CLI runs')
144
+ run('canon --help failed', ['bun', 'run', 'src/cli.ts', '--help'], {
145
+ cwd: extractDir,
146
+ })
147
+ logInfo('canon --help ran clean from the extracted package')
148
+
149
+ logStep('Scaffold fresh project')
150
+ mkdirSync(targetDir, { recursive: true })
151
+ run('git init failed', ['git', 'init', '--quiet'], { cwd: targetDir })
152
+ logInfo(`Initialized git in ${targetDir}`)
153
+
154
+ logStep('Run canon init')
155
+ run(
156
+ 'canon init failed',
157
+ ['bun', 'run', join(extractDir, 'src/cli.ts'), 'init', '--stack', 'base'],
158
+ { cwd: targetDir, show: true },
159
+ )
160
+ logInfo('canon init completed')
161
+
162
+ logStep('Assert scaffold')
163
+ for (const path of SCAFFOLD_PATHS) {
164
+ if (!existsSync(join(targetDir, path))) {
165
+ throw new Error(`Missing after canon init: ${path}`)
166
+ }
167
+ logInfo(`Found: ${path}`)
168
+ }
169
+
170
+ logStep('Verification passed')
171
+ logInfo('Manual check still needed: bun link and global canon invocation')
172
+ }
173
+
174
+ rmSync(tmpRoot, { recursive: true, force: true })
175
+ mkdirSync(packDir, { recursive: true })
176
+ mkdirSync(extractDir, { recursive: true })
177
+
178
+ intro('Install verification')
179
+ let failed = false
180
+ try {
181
+ check()
182
+ } catch (error) {
183
+ failed = true
184
+ logError(error instanceof Error ? error.message : String(error))
185
+ } finally {
186
+ if (keep) {
187
+ logInfo(`Kept artifacts at ${tmpRoot}`)
188
+ } else {
189
+ rmSync(tmpRoot, { recursive: true, force: true })
190
+ }
191
+ outro()
192
+ }
193
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,63 @@
1
+ export interface RetiredVerb {
2
+ /** The words a caller types after `canon`, one for a group and two for a subcommand. */
3
+ readonly path: readonly string[]
4
+ /** The release that dropped the verb, from the changelog. */
5
+ readonly release: string
6
+ readonly movedTo: string
7
+ }
8
+
9
+ const DESIGN_MOVED =
10
+ 'the design values now live in the stylesheet your canon/DESIGN.md lead names'
11
+
12
+ /**
13
+ * Every verb a release dropped, with the release and where the work went. The
14
+ * router reads this ahead of the parser, and `canon migrate status` reads it to
15
+ * find project calls. A verb that returns leaves this table in the same change.
16
+ */
17
+ export const RETIRED_VERBS: readonly RetiredVerb[] = [
18
+ {
19
+ path: ['canvas'],
20
+ release: '5.54.0',
21
+ movedTo: 'Frameboard, which carries the design board in its own repository',
22
+ },
23
+ { path: ['design', 'render'], release: '5.54.0', movedTo: DESIGN_MOVED },
24
+ { path: ['design', 'install'], release: '5.54.0', movedTo: DESIGN_MOVED },
25
+ { path: ['design', 'sync'], release: '5.54.0', movedTo: DESIGN_MOVED },
26
+ { path: ['design', 'fonts'], release: '5.54.0', movedTo: DESIGN_MOVED },
27
+ { path: ['design', 'list'], release: '5.54.0', movedTo: DESIGN_MOVED },
28
+ { path: ['design', 'add'], release: '5.54.0', movedTo: DESIGN_MOVED },
29
+ {
30
+ path: ['teach'],
31
+ release: '5.53.0',
32
+ movedTo: 'the canon:teach-craft skill',
33
+ },
34
+ {
35
+ path: ['slides'],
36
+ release: '5.53.0',
37
+ movedTo: 'the canon:teach-craft skill',
38
+ },
39
+ {
40
+ path: ['demo'],
41
+ release: '5.53.0',
42
+ movedTo: 'a tool built for composed video',
43
+ },
44
+ ]
45
+
46
+ /**
47
+ * Matches the leading words of argv, stopping at the first option so a flag's
48
+ * value is never read as a verb name.
49
+ */
50
+ export function findRetired(args: readonly string[]): RetiredVerb | undefined {
51
+ const words: string[] = []
52
+ for (const arg of args) {
53
+ if (arg.startsWith('-')) break
54
+ words.push(arg)
55
+ }
56
+ return RETIRED_VERBS.find((verb) =>
57
+ verb.path.every((word, index) => words[index] === word),
58
+ )
59
+ }
60
+
61
+ export function retiredMessage(verb: RetiredVerb): string {
62
+ return `error: 'canon ${verb.path.join(' ')}' was retired in v${verb.release}. The work moved to ${verb.movedTo}.`
63
+ }
package/src/cli/ui.ts CHANGED
@@ -90,9 +90,8 @@ export function intro(title: string): void {
90
90
  }
91
91
 
92
92
  /**
93
- * Timeline log levels matching `scripts/lib/ui.sh`, so a migrated command
94
- * prints the same frame as the bash it replaced. All output is stderr,
95
- * leaving stdout clean for JSON and lists.
93
+ * Timeline log levels for the framed output every command and `scripts/core/`
94
+ * script prints. All output is stderr, leaving stdout clean for JSON and lists.
96
95
  */
97
96
  export function logInfo(message: string): void {
98
97
  if (quiet) return
@@ -151,8 +150,7 @@ export function outroSuccess(message: string, detail?: string): void {
151
150
  }
152
151
 
153
152
  /**
154
- * Indents borrowed output inside an open frame, matching `pipe_output` in
155
- * `scripts/lib/ui.sh`. Used for a document the command did not compose itself,
153
+ * Indents borrowed output inside an open frame. Used for a document the command did not compose itself,
156
154
  * such as a pull request body or the output of a git mutation.
157
155
  */
158
156
  export function pipeOutput(text: string): void {
package/src/cli.ts CHANGED
@@ -40,6 +40,7 @@ import { register as changelog } from '@/commands/changelog'
40
40
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
41
41
  import { renderHelp } from '@/cli/help'
42
42
  import { renderAll, routeHelp } from '@/cli/help-all'
43
+ import { findRetired, retiredMessage } from '@/cli/retired'
43
44
  import { setQuiet } from '@/cli/ui'
44
45
 
45
46
  function showHelp(): void {
@@ -101,8 +102,13 @@ gate(program)
101
102
  upgrade(program)
102
103
  changelog(program)
103
104
 
104
- const route = routeHelp(program, process.argv.slice(2))
105
- if (route.kind === 'top') showHelp()
105
+ const args = process.argv.slice(2)
106
+ const retired = findRetired(args)
107
+ const route = routeHelp(program, args)
108
+ if (retired !== undefined) {
109
+ console.error(retiredMessage(retired))
110
+ process.exitCode = 1
111
+ } else if (route.kind === 'top') showHelp()
106
112
  else if (route.kind === 'all') process.stdout.write(renderAll(route.command))
107
113
  else if (route.kind === 'unknown') {
108
114
  console.error(`error: unknown command '${route.name}'`)
@@ -210,6 +210,7 @@ export function register(program: Command): void {
210
210
  .description('Update rules already installed under .claude/rules/canon/')
211
211
  .argument('[target]', 'Target directory', '.')
212
212
  .helpOption('-h, --help', 'Show this help message')
213
+ .option('--json', 'Emit the coverage record as JSON on stdout')
213
214
  .addHelpText(
214
215
  'after',
215
216
  [
@@ -219,17 +220,22 @@ export function register(program: Command): void {
219
220
  'It adds no new rule, which is `canon gov install`, and never touches',
220
221
  '.claude/rules/project/.',
221
222
  '',
223
+ 'After an apply it lists each rule it copied with the globs it loads on',
224
+ 'and how many files in the target match them. --json writes the same',
225
+ 'list as { "coverage": [{ rule, paths, files, ui }] } on stdout, empty',
226
+ 'when nothing was copied. The report never changes the exit code.',
227
+ '',
222
228
  'Exit codes:',
223
229
  ' 0 synced, already up to date, or the prompt was cancelled',
224
230
  ' 1 the target is missing or is the toolkit root',
225
231
  '',
226
232
  ].join('\n'),
227
233
  )
228
- .action(async (target: string) => {
234
+ .action(async (target: string, opts: { json?: boolean }) => {
229
235
  process.exitCode = await runDomainSync(
230
236
  createGovAdapter(PROJECT_ROOT),
231
237
  target,
232
- { protectedRoot: PROJECT_ROOT },
238
+ { protectedRoot: PROJECT_ROOT, json: opts.json },
233
239
  )
234
240
  })
235
241
 
@@ -1056,7 +1056,7 @@ async function runStatus(opts: StatusOptions): Promise<number> {
1056
1056
  const mismatch = checkoutMismatchWarning(process.cwd())
1057
1057
  if (mismatch !== undefined) logWarn(mismatch)
1058
1058
 
1059
- const report = await readMigrationStatus(cliRunner(root))
1059
+ const report = await readMigrationStatus(cliRunner(root), root)
1060
1060
 
1061
1061
  if (opts.json) {
1062
1062
  process.stdout.write(
@@ -1071,6 +1071,12 @@ async function runStatus(opts: StatusOptions): Promise<number> {
1071
1071
  }
1072
1072
  }
1073
1073
 
1074
+ for (const caller of report.retiredCallers) {
1075
+ logWarn(
1076
+ `Retired: ${caller.verb} at ${caller.file}:${caller.line}, retired in v${caller.release}, moved to ${caller.movedTo}`,
1077
+ )
1078
+ }
1079
+
1074
1080
  if (report.pending === 0 && report.refused === 0) {
1075
1081
  logInfo('No migration is pending.')
1076
1082
  }
@@ -935,7 +935,7 @@ export function register(program: Command): void {
935
935
 
936
936
  pr.command('merge')
937
937
  .description(
938
- 'Squash-merge a ready pull request with a message carrying only its Summary, Key changes, and Technical context',
938
+ "Squash-merge a ready pull request with a message carrying only the change: its Summary, Key changes, and Technical context, or a bot pull request's own changelog or package table",
939
939
  )
940
940
  .argument('[number]', 'Pull request to merge, defaulting to this branch')
941
941
  .helpOption('-h, --help', 'Show this help message')
@@ -955,6 +955,13 @@ export function register(program: Command): void {
955
955
  'UI pass line dropped. Technical context is optional. Testing, the',
956
956
  'reviewer requests, and every other section stay out of the trunk.',
957
957
  '',
958
+ 'A release-please or Dependabot pull request is read off its head',
959
+ 'branch, plus the release body opening or a bot author, and needs no',
960
+ 'flag. A release composes to its changelog without the robot line, the',
961
+ 'dividers, and the footer. Dependabot composes to its lead and package',
962
+ 'table, cut at the first `Updates ` line or `<details>` block. A bot body with nothing left',
963
+ 'refuses missing-summary.',
964
+ '',
958
965
  'Refusals are read before the one write, which sends the head it read',
959
966
  'as `sha`, so a push after the read refuses rather than merging. The',
960
967
  'verb reads the draft flag and not the reviews, since a ready pull',
@@ -4124,6 +4131,8 @@ function mergeApi(root: string, number: string): MergeApi {
4124
4131
  title: row.title,
4125
4132
  body: row.body ?? '',
4126
4133
  head: row.head.sha,
4134
+ branch: row.head.ref ?? '',
4135
+ isBotAuthor: row.user?.type === 'Bot',
4127
4136
  }
4128
4137
  } catch {
4129
4138
  return undefined