@erclx/canon 5.54.0 → 5.56.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +8 -10
  4. package/claude/skills/auto-ship/SKILL.md +4 -5
  5. package/claude/skills/auto-ship/references/failure-recovery.md +37 -17
  6. package/claude/skills/auto-ship/references/tier-failures.md +9 -7
  7. package/claude/skills/auto-ship/references/worktree-name.md +8 -5
  8. package/claude/skills/backlog-triage/SKILL.md +10 -10
  9. package/claude/skills/backlog-triage/references/apply.md +6 -6
  10. package/claude/skills/context-fold/references/classify.md +17 -4
  11. package/claude/skills/create-rule/references/rule.md +4 -0
  12. package/claude/skills/create-skill/SKILL.md +3 -3
  13. package/claude/skills/deploy-app/SKILL.md +2 -0
  14. package/claude/skills/deprecation-migration/SKILL.md +7 -9
  15. package/claude/skills/document-health/SKILL.md +6 -6
  16. package/claude/skills/document-health/references/axes.md +1 -1
  17. package/claude/skills/document-repair/SKILL.md +5 -5
  18. package/claude/skills/git-followup/SKILL.md +16 -5
  19. package/claude/skills/git-issue/references/issue.md +9 -11
  20. package/claude/skills/git-pr/references/publish.md +4 -5
  21. package/claude/skills/git-split/SKILL.md +11 -4
  22. package/claude/skills/markdown-craft/SKILL.md +1 -1
  23. package/claude/skills/markdown-craft/references/markdown.md +2 -2
  24. package/claude/skills/markdown-craft/references/tables.md +9 -7
  25. package/claude/skills/plan-feature/SKILL.md +2 -0
  26. package/claude/skills/plan-feature/references/plan.md +2 -0
  27. package/claude/skills/review-address/SKILL.md +9 -7
  28. package/claude/skills/review-craft/SKILL.md +7 -9
  29. package/claude/skills/review-craft/references/adopted.md +10 -8
  30. package/claude/skills/review-pr/SKILL.md +2 -0
  31. package/claude/skills/review-pr/references/key-changes.md +8 -6
  32. package/claude/skills/review-ui/references/driving.md +10 -8
  33. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +6 -4
  34. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +9 -3
  35. package/claude/skills/role-reviewer/SKILL.md +5 -5
  36. package/claude/skills/search-craft/SKILL.md +5 -7
  37. package/claude/skills/session-worktree/SKILL.md +2 -0
  38. package/claude/skills/session-worktree/references/main-root-writes.md +2 -0
  39. package/claude/skills/systematic-debugging/SKILL.md +9 -9
  40. package/claude/skills/systematic-debugging/references/adopted.md +1 -1
  41. package/claude/skills/target-check/SKILL.md +6 -8
  42. package/claude/skills/target-check/references/domains.md +1 -4
  43. package/claude/skills/target-setup/SKILL.md +8 -8
  44. package/claude/skills/target-update/SKILL.md +5 -5
  45. package/claude/skills/task-board/SKILL.md +2 -4
  46. package/claude/skills/task-board/references/tasks.md +5 -5
  47. package/claude/skills/task-complete/references/archive.md +5 -2
  48. package/claude/skills/test-craft/SKILL.md +2 -0
  49. package/claude/skills/test-craft/references/adopted.md +2 -0
  50. package/claude/skills/ui-checklist/SKILL.md +8 -9
  51. package/claude/skills/write-doc/SKILL.md +8 -8
  52. package/claude/skills/write-doc/references/readme.md +2 -0
  53. package/claude/skills/write-doc/references/wiki-kind.md +11 -4
  54. package/docs/agents/context-audit.md +16 -14
  55. package/docs/agents/install-and-sync.md +2 -2
  56. package/docs/agents/markdown-audit.md +11 -4
  57. package/docs/agents/migrate.md +15 -13
  58. package/docs/agents/output-shape.md +13 -10
  59. package/docs/target/migrations.md +3 -3
  60. package/docs/target/sync.md +1 -1
  61. package/docs/workflow/operating-model.md +2 -0
  62. package/package.json +1 -1
  63. package/scripts/core/install-check.sh +1 -1
  64. package/scripts/lib/ui.sh +12 -65
  65. package/src/audits/run.ts +1 -1
  66. package/src/claude/routing.ts +1 -1
  67. package/src/cli/run.ts +5 -6
  68. package/src/cli/ui.ts +87 -28
  69. package/src/cli.ts +6 -0
  70. package/src/commands/claude.ts +2 -3
  71. package/src/commands/design.ts +8 -13
  72. package/src/commands/gate.ts +5 -3
  73. package/src/commands/gov.ts +11 -21
  74. package/src/commands/labels.ts +1 -1
  75. package/src/commands/markdown.ts +25 -29
  76. package/src/commands/pr.ts +1 -1
  77. package/src/commands/sync.ts +1 -1
  78. package/src/commands/tooling.ts +5 -8
  79. package/src/commands/transcripts.ts +6 -11
  80. package/src/gate/sequencer.ts +1 -1
  81. package/src/gate/stages.ts +0 -10
  82. package/src/hooks/post-merge.ts +2 -5
  83. package/src/labels/density.ts +5 -46
  84. package/src/markdown/density.ts +41 -6
  85. package/src/markdown/scope.ts +6 -1
  86. package/src/markdown/structure.ts +18 -9
  87. package/src/markdown/tables.ts +32 -18
  88. package/src/process/harness.ts +11 -4
  89. package/src/process/seeded-package.ts +21 -0
  90. package/src/sandbox/equivalence.ts +0 -1
  91. package/src/sandbox/provision.ts +8 -13
  92. package/src/sandbox/scenario.ts +6 -12
  93. package/src/serve/static.ts +2 -60
  94. package/src/sync/engine.ts +3 -8
  95. package/src/sync/workflow.ts +3 -5
  96. package/src/tooling/verify-stack.ts +1 -1
  97. package/tooling/base/configs/.husky/post-merge +6 -10
  98. package/tooling/base/reference.md +7 -6
  99. package/tooling/claude/reference.md +7 -5
  100. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +7 -6
  101. package/scripts/core/check-color-source.sh +0 -51
  102. package/src/serve/live.ts +0 -113
package/src/cli/ui.ts CHANGED
@@ -53,7 +53,38 @@ export function palette(stream: { isTTY?: boolean }): Palette {
53
53
  return supportsColor(stream) ? COLOR : PLAIN
54
54
  }
55
55
 
56
+ let quiet = false
57
+
58
+ /**
59
+ * Silences the progress frame for a caller that asked for a record, since an
60
+ * agent reads both streams and the frame outweighed the data. Warnings and
61
+ * errors still write, since diagnostics reach stderr in every mode.
62
+ */
63
+ export function setQuiet(on: boolean): void {
64
+ quiet = on
65
+ }
66
+
67
+ /**
68
+ * The frame is drawn only for a terminal. A pipe or a file gets each line's
69
+ * mark alone, since a reader there gains nothing from `┌ │ ├ └` and an agent
70
+ * pays for every one of them.
71
+ */
72
+ function framed(): boolean {
73
+ return process.stderr.isTTY === true
74
+ }
75
+
76
+ function markLine(color: keyof Palette, mark: string, message: string): string {
77
+ if (!framed()) return `${mark} ${message}\n`
78
+ const { GREY, NC, [color]: tint } = palette(process.stderr)
79
+ return `${GREY}│${NC} ${tint}${mark}${NC} ${message}\n`
80
+ }
81
+
56
82
  export function intro(title: string): void {
83
+ if (quiet) return
84
+ if (!framed()) {
85
+ process.stderr.write(`${title}\n`)
86
+ return
87
+ }
57
88
  const { GREY, NC, WHITE } = palette(process.stderr)
58
89
  process.stderr.write(`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}${title}${NC}\n`)
59
90
  }
@@ -64,23 +95,22 @@ export function intro(title: string): void {
64
95
  * leaving stdout clean for JSON and lists.
65
96
  */
66
97
  export function logInfo(message: string): void {
67
- const { GREEN, GREY, NC } = palette(process.stderr)
68
- process.stderr.write(`${GREY}│${NC} ${GREEN}✓${NC} ${message}\n`)
98
+ if (quiet) return
99
+ process.stderr.write(markLine('GREEN', '✓', message))
69
100
  }
70
101
 
71
102
  export function logWarn(message: string): void {
72
- const { GREY, NC, YELLOW } = palette(process.stderr)
73
- process.stderr.write(`${GREY}│${NC} ${YELLOW}!${NC} ${message}\n`)
103
+ process.stderr.write(markLine('YELLOW', '!', message))
74
104
  }
75
105
 
76
106
  export function logAdd(message: string): void {
77
- const { GREEN, GREY, NC } = palette(process.stderr)
78
- process.stderr.write(`${GREY}│${NC} ${GREEN}+${NC} ${message}\n`)
107
+ if (quiet) return
108
+ process.stderr.write(markLine('GREEN', '+', message))
79
109
  }
80
110
 
81
111
  export function logRemove(message: string): void {
82
- const { GREY, NC, RED } = palette(process.stderr)
83
- process.stderr.write(`${GREY}│${NC} ${RED}-${NC} ${message}\n`)
112
+ if (quiet) return
113
+ process.stderr.write(markLine('RED', '-', message))
84
114
  }
85
115
 
86
116
  /**
@@ -89,31 +119,48 @@ export function logRemove(message: string): void {
89
119
  * exit code rather than terminating mid-write.
90
120
  */
91
121
  export function logError(message: string): void {
92
- const { GREY, NC, RED } = palette(process.stderr)
93
- process.stderr.write(`${GREY}│${NC} ${RED}✗${NC} ${message}\n`)
122
+ process.stderr.write(markLine('RED', '✗', message))
94
123
  }
95
124
 
96
125
  export function logStep(message: string): void {
126
+ if (quiet) return
127
+ if (!framed()) {
128
+ process.stderr.write(`${message}\n`)
129
+ return
130
+ }
97
131
  const { GREY, NC, WHITE } = palette(process.stderr)
98
132
  process.stderr.write(`${GREY}│${NC}\n${GREY}├${NC} ${WHITE}${message}${NC}\n`)
99
133
  }
100
134
 
101
135
  export function outro(): void {
136
+ if (quiet || !framed()) return
102
137
  const { GREY, NC } = palette(process.stderr)
103
138
  process.stderr.write(`${GREY}└${NC}\n`)
104
139
  }
105
140
 
141
+ /**
142
+ * Closes the frame on the green line a command ends a success on. The line
143
+ * sits below the frame rather than inside it, and a quiet run writes neither.
144
+ */
145
+ export function outroSuccess(message: string, detail?: string): void {
146
+ if (quiet) return
147
+ outro()
148
+ const { GREEN, GREY, NC } = palette(process.stderr)
149
+ const suffix = detail === undefined ? '' : ` ${GREY}${detail}${NC}`
150
+ process.stderr.write(`${GREEN}✓ ${message}${NC}${suffix}\n`)
151
+ }
152
+
106
153
  /**
107
154
  * Indents borrowed output inside an open frame, matching `pipe_output` in
108
155
  * `scripts/lib/ui.sh`. Used for a document the command did not compose itself,
109
156
  * such as a pull request body or the output of a git mutation.
110
157
  */
111
158
  export function pipeOutput(text: string): void {
159
+ if (quiet) return
112
160
  const { GREY, NC } = palette(process.stderr)
161
+ const bar = framed() ? `${GREY}│${NC}` : ''
113
162
  const lines = text.replace(/\n$/, '').split('\n')
114
- process.stderr.write(
115
- `${lines.map((line) => `${GREY}│${NC} ${line}`).join('\n')}\n`,
116
- )
163
+ process.stderr.write(`${lines.map((line) => `${bar} ${line}`).join('\n')}\n`)
117
164
  }
118
165
 
119
166
  /** Counts a noun for a report line, where the plural is the bare `s` form. */
@@ -122,6 +169,10 @@ export function plural(count: number, noun: string): string {
122
169
  }
123
170
 
124
171
  export function frameError(message: string): void {
172
+ if (!framed()) {
173
+ process.stderr.write(`✗ ${message}\n`)
174
+ return
175
+ }
125
176
  const { GREY, NC, RED } = palette(process.stderr)
126
177
  process.stderr.write(
127
178
  `${GREY}┌${NC}\n${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
@@ -129,6 +180,11 @@ export function frameError(message: string): void {
129
180
  }
130
181
 
131
182
  export function frameSuccess(command: string, target: string): void {
183
+ if (quiet) return
184
+ if (!framed()) {
185
+ process.stderr.write(`${command}\n✓ ${target}\n`)
186
+ return
187
+ }
132
188
  const { GREEN, GREY, NC, WHITE } = palette(process.stderr)
133
189
  process.stderr.write(
134
190
  `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}${command}${NC}\n${GREY}│${NC}\n${GREY}│${NC} ${GREEN}✓${NC} ${target}\n${GREY}└${NC}\n`,
@@ -136,17 +192,17 @@ export function frameSuccess(command: string, target: string): void {
136
192
  }
137
193
 
138
194
  /**
139
- * The retired spelling is still honored, and permanently.
195
+ * A caller with no terminal on stdin is headless, since nobody can answer a
196
+ * prompt there.
140
197
  *
141
- * This variable is the documented way to drive any command headlessly, so
142
- * every skill body, CI job, and operator script already written sets the old
143
- * name. Those callers live in other repositories and in plugin caches this
144
- * release cannot reach, and the failure mode of dropping the fallback is a
145
- * command that quietly waits for a TTY nobody is watching rather than one that
146
- * refuses and says why.
198
+ * Both variables are retired overrides that force headless on a terminal and
199
+ * stay readable permanently. Skill bodies, CI jobs, and operator scripts in
200
+ * other repositories and plugin caches this release cannot reach still set
201
+ * them, and dropping the read turns their call into a prompt nobody watches.
147
202
  */
148
203
  export function isNonInteractive(): boolean {
149
204
  return (
205
+ !process.stdin.isTTY ||
150
206
  process.env.CANON_NON_INTERACTIVE === '1' ||
151
207
  // canon-keep-retired
152
208
  process.env.AITK_NON_INTERACTIVE === '1'
@@ -155,8 +211,8 @@ export function isNonInteractive(): boolean {
155
211
 
156
212
  /**
157
213
  * `nonInteractiveDefault` opts a prompt into resolving to its first option
158
- * under `CANON_NON_INTERACTIVE=1` instead of failing. Callers that would rather fail loudly than pick
159
- * for the user leave it off.
214
+ * when the caller is headless instead of failing. Callers that would rather
215
+ * fail loudly than pick for the user leave it off.
160
216
  */
161
217
  export async function select<Value>(opts: {
162
218
  message: string
@@ -171,9 +227,11 @@ export async function select<Value>(opts: {
171
227
  let cursor = 0
172
228
 
173
229
  if (opts.nonInteractiveDefault && isNonInteractive()) {
174
- process.stderr.write(
175
- `${GREY}│${NC}\n${GREY}◇${NC} ${message} ${WHITE}${options[0].label}${NC}\n`,
176
- )
230
+ if (!quiet) {
231
+ process.stderr.write(
232
+ markLine('GREY', '◇', `${message} ${options[0].label}`),
233
+ )
234
+ }
177
235
  return options[0].value
178
236
  }
179
237
 
@@ -196,10 +254,11 @@ export async function select<Value>(opts: {
196
254
  }
197
255
 
198
256
  return new Promise<Value>((resolve) => {
199
- if (!process.stdin.isTTY) {
200
- process.stderr.write(
201
- `${GREY}│${NC} ${RED}✗${NC} ${message} requires a TTY. ${opts.ttyHint ?? 'Pass an argument'} or set CANON_NON_INTERACTIVE=1.\n${GREY}└${NC}\n`,
257
+ if (isNonInteractive()) {
258
+ logError(
259
+ `${message} requires a TTY. ${opts.ttyHint ?? 'Pass an argument'}.`,
202
260
  )
261
+ outro()
203
262
  process.exit(1)
204
263
  }
205
264
 
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 { setQuiet } from '@/cli/ui'
43
44
 
44
45
  function showHelp(): void {
45
46
  console.log(renderHelp(process.stdout))
@@ -57,6 +58,11 @@ program.on('option:help', () => {
57
58
  process.exit(0)
58
59
  })
59
60
  program.option('-h, --help', 'Show help')
61
+ // A caller asking for a record reads it from stdout, so the frame on stderr is
62
+ // noise it pays for. Warnings and errors still write.
63
+ program.hook('preAction', (_program, action) => {
64
+ if (action.opts().json === true) setQuiet(true)
65
+ })
60
66
 
61
67
  init(program)
62
68
  sandbox(program)
@@ -91,6 +91,7 @@ import {
91
91
  logStep,
92
92
  logWarn,
93
93
  outro,
94
+ outroSuccess,
94
95
  palette,
95
96
  pipeOutput,
96
97
  plural,
@@ -604,9 +605,7 @@ export function register(program: Command): void {
604
605
  }
605
606
 
606
607
  function succeed(message: string): number {
607
- const { GREEN, NC } = palette(process.stderr)
608
- outro()
609
- process.stderr.write(`${GREEN}✓ ${message}${NC}\n`)
608
+ outroSuccess(message)
610
609
  return 0
611
610
  }
612
611
 
@@ -5,7 +5,7 @@ import { buildDesignCss } from '@/design/css'
5
5
  import { HAND_DRAWN_FONT_FACES } from '@/design/fonts'
6
6
  import { DESIGN_DOCUMENT, regenDesign } from '@/design/regen'
7
7
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
8
- import { logWarn, palette } from '@/cli/ui'
8
+ import { intro, logError, logInfo, logWarn, outro } from '@/cli/ui'
9
9
 
10
10
  export function register(program: Command): void {
11
11
  const design = program
@@ -29,8 +29,6 @@ export function register(program: Command): void {
29
29
  ].join('\n'),
30
30
  )
31
31
  .action(() => {
32
- const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
33
-
34
32
  // The guard below catches a `PROJECT_ROOT` with no record at all, which
35
33
  // is an installed package. It cannot catch a second real checkout that
36
34
  // has one, so both paths carry the warning. It is a frame-interior line,
@@ -42,24 +40,21 @@ export function register(program: Command): void {
42
40
  // The record is already committed here and ships with no package, so
43
41
  // its absence is what separates the two.
44
42
  if (!existsSync(join(PROJECT_ROOT, DESIGN_DOCUMENT))) {
45
- process.stderr.write(`${GREY}┌${NC}\n`)
43
+ intro('Regenerate design source')
46
44
  if (mismatch !== undefined) logWarn(mismatch)
47
- process.stderr.write(
48
- `${GREY}│${NC} ${RED}✗${NC} No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.\n${GREY}└${NC}\n`,
45
+ logError(
46
+ `No ${DESIGN_DOCUMENT} at ${PROJECT_ROOT}. Regen runs in the toolkit checkout, not against a target.`,
49
47
  )
48
+ outro()
50
49
  process.exitCode = 1
51
50
  return
52
51
  }
53
52
 
54
- process.stderr.write(
55
- `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}Regenerate design source${NC}\n`,
56
- )
53
+ intro('Regenerate design source')
57
54
  if (mismatch !== undefined) logWarn(mismatch)
58
55
  const result = regenDesign(PROJECT_ROOT)
59
- process.stderr.write(
60
- `${GREY}│${NC} ${GREEN}✓${NC} ${relative(PROJECT_ROOT, result.documentPath)}\n`,
61
- )
62
- process.stderr.write(`${GREY}└${NC}\n`)
56
+ logInfo(relative(PROJECT_ROOT, result.documentPath))
57
+ outro()
63
58
  })
64
59
 
65
60
  design
@@ -19,6 +19,7 @@ import {
19
19
  logStep,
20
20
  logWarn,
21
21
  outro,
22
+ outroSuccess,
22
23
  palette,
23
24
  pipeOutput,
24
25
  plural,
@@ -216,20 +217,21 @@ function report(result: StageResult): void {
216
217
  * against.
217
218
  */
218
219
  function close(summary: Summary, code: number): void {
219
- const { GREEN, NC, RED, YELLOW } = palette(process.stderr)
220
- outro()
220
+ const { NC, RED, YELLOW } = palette(process.stderr)
221
221
 
222
222
  if (code !== 0) {
223
+ outro()
223
224
  process.stderr.write(`${RED}✗ Verification failed${NC}\n\n`)
224
225
  return
225
226
  }
226
227
 
227
228
  if (summary.unmeasured > 0) {
229
+ outro()
228
230
  process.stderr.write(
229
231
  `${YELLOW}! Verification passed, ${plural(summary.unmeasured, 'stage')} measured nothing${NC}\n\n`,
230
232
  )
231
233
  return
232
234
  }
233
235
 
234
- process.stderr.write(`${GREEN}✓ Verification passed${NC}\n\n`)
236
+ outroSuccess('Verification passed')
235
237
  }
@@ -75,7 +75,7 @@ import {
75
75
  logStep,
76
76
  logWarn,
77
77
  outro,
78
- palette,
78
+ outroSuccess,
79
79
  select,
80
80
  } from '@/cli/ui'
81
81
 
@@ -184,9 +184,9 @@ export function register(program: Command): void {
184
184
  'Copies each rule into .claude/rules/canon/, replacing a copy already',
185
185
  'there. Rules under .claude/rules/project/ are never read.',
186
186
  '',
187
- 'The install confirm needs a TTY. --yes answers it, so a headless call',
188
- 'installs without setting CANON_NON_INTERACTIVE=1. It does not pick a',
189
- 'stack, so a headless call still names one.',
187
+ 'The install confirm needs a TTY. --yes answers it, and a call with no',
188
+ 'terminal on stdin takes Yes. It does not pick a stack, so a headless',
189
+ 'call still names one.',
190
190
  '',
191
191
  'Exit codes:',
192
192
  ' 0 installed, the stack held no rules, or the prompt was cancelled',
@@ -1160,7 +1160,7 @@ function runTestOrder(opts: TestOrderOptions): number {
1160
1160
  const report = readTestOrder(root, { base: opts.base })
1161
1161
  const emitJson = opts.json ?? false
1162
1162
 
1163
- // The frame renders on stderr in both modes and the record goes to stdout
1163
+ // The refusal reaches stderr in both modes and the record goes to stdout
1164
1164
  // alone, which is the split `docs/agents/output-shape.md` fixes. A consumer
1165
1165
  // reading stdout sees pure data either way, and an operator reading the
1166
1166
  // terminal sees the refusal rather than a command that appeared to do nothing.
@@ -1455,8 +1455,8 @@ function displayPath(target: string, rel: string): string {
1455
1455
  }
1456
1456
 
1457
1457
  /**
1458
- * Refuses rather than picking. `select_option` returned `options[0]` under
1459
- * `CANON_NON_INTERACTIVE=1`, so a headless `canon gov install` with no stack
1458
+ * Refuses rather than picking. `select_option` returned `options[0]` with no
1459
+ * terminal to answer it, so a headless `canon gov install` with no stack
1460
1460
  * installed whichever stack sorted first, measured as 26 astro rules into an
1461
1461
  * empty directory. Every documented agent path already passes the argument.
1462
1462
  */
@@ -1558,16 +1558,13 @@ async function runInstall(
1558
1558
  now,
1559
1559
  )
1560
1560
 
1561
- const { GREEN, NC } = palette(process.stderr)
1562
- outro()
1563
- process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
1561
+ outroSuccess('Rules installed')
1564
1562
  return 0
1565
1563
  }
1566
1564
 
1567
1565
  /**
1568
1566
  * The install confirm. `--yes` answers it without a prompt, so the line it
1569
- * would have printed is skipped rather than duplicated under
1570
- * `CANON_NON_INTERACTIVE=1`.
1567
+ * would have printed is skipped rather than duplicated on a headless call.
1571
1568
  */
1572
1569
  async function confirmInstall(
1573
1570
  count: number,
@@ -1582,7 +1579,6 @@ async function confirmInstall(
1582
1579
  { value: false, label: 'No' },
1583
1580
  ],
1584
1581
  nonInteractiveDefault: true,
1585
- ttyHint: 'Pass --yes',
1586
1582
  })
1587
1583
  }
1588
1584
 
@@ -1638,9 +1634,7 @@ async function installExtras(
1638
1634
 
1639
1635
  await recordStamp(createGovAdapter(PROJECT_ROOT), resolved, new Date())
1640
1636
 
1641
- const { GREEN, NC } = palette(process.stderr)
1642
- outro()
1643
- process.stderr.write(`${GREEN}✓ Rules installed${NC}\n`)
1637
+ outroSuccess('Rules installed')
1644
1638
  return 0
1645
1639
  }
1646
1640
 
@@ -1689,10 +1683,6 @@ async function runBuild(target: string): Promise<number> {
1689
1683
  await writeFile(output, buildRulesPayload(files))
1690
1684
  logAdd(payload)
1691
1685
 
1692
- const { GREEN, NC } = palette(process.stderr)
1693
- outro()
1694
- process.stderr.write(
1695
- `${GREEN}✓ Rules built (${files.length} rules → ${payload})${NC}\n`,
1696
- )
1686
+ outroSuccess(`Rules built (${files.length} rules → ${payload})`)
1697
1687
  return 0
1698
1688
  }
@@ -311,7 +311,7 @@ async function runAudit(paths: string[], opts: AuditOptions): Promise<number> {
311
311
 
312
312
  intro('canon labels audit')
313
313
 
314
- // The frame renders on stderr in both modes and the record goes to stdout
314
+ // The refusal reaches stderr in both modes and the record goes to stdout
315
315
  // alone, so an operator reading the terminal sees the refusal rather than a
316
316
  // command that appeared to do nothing.
317
317
  if (report.kind === 'refused') {
@@ -141,9 +141,10 @@ export function register(program: Command): void {
141
141
  'Depth weight is a judgment a reader settles, so it reports and never',
142
142
  'fails a run.',
143
143
  '',
144
- 'Density holds six limits: a 350-character paragraph, 2 paragraphs in',
145
- 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
146
- 'paragraph or table opening every H3. Only context, docs, skills, tooling',
144
+ 'Density holds eight limits: a 350-character paragraph, 2 paragraphs in',
145
+ 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, a',
146
+ 'paragraph or table opening every H3, a 120-character table cell, and no',
147
+ 'list as the block after a table. Only context, docs, skills, tooling',
147
148
  'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
148
149
  'finding. CLAUDE.md and its seed take only the bullet run and the H3',
149
150
  'opening, inside Key paths. In a canon checkout one gates under a',
@@ -154,10 +155,9 @@ export function register(program: Command): void {
154
155
  'and none gates.',
155
156
  'A config that does not parse refuses with its path named.',
156
157
  '',
157
- 'Tables take four limits on the same paths, CLAUDE.md aside: a',
158
- '120-character cell, 8 body rows, a list as the next block, and a code',
159
- 'span with a run past 30 characters. A table finding reports and never',
160
- 'fails a run.',
158
+ 'Tables take two more limits on the same paths, CLAUDE.md aside: 8 body',
159
+ 'rows, and a code span with a run past 30 characters. Each reports and',
160
+ 'never fails a run.',
161
161
  '',
162
162
  'A page whose frontmatter carries kind: reference takes the reference',
163
163
  'shape: no H2 beyond Output, Refusals, and See also, each at most once',
@@ -637,15 +637,19 @@ const DENSITY_LABELS: Record<DensityKind, string> = {
637
637
  'bullet-run': 'bullets in a row',
638
638
  'heading-depth': 'heading depth',
639
639
  'h3-opening': 'H3 opening',
640
+ 'table-cell': 'cell length',
641
+ 'table-then-list': 'list after table',
640
642
  }
641
643
 
642
644
  function describeDensity(finding: GatedDensityFinding): string {
643
645
  const measured =
644
646
  finding.kind === 'h3-opening'
645
647
  ? 'opens with neither a paragraph nor a table'
646
- : finding.kind === 'heading-depth'
647
- ? `H${finding.value} past H${finding.limit}`
648
- : `${finding.value} past ${finding.limit}`
648
+ : finding.kind === 'table-then-list'
649
+ ? 'follows the table with no lead sentence'
650
+ : finding.kind === 'heading-depth'
651
+ ? `H${finding.value} past H${finding.limit}`
652
+ : `${finding.value} past ${finding.limit}`
649
653
  const gate = finding.gating ? ' gating' : ''
650
654
  return ` :${finding.line} ${DENSITY_LABELS[finding.kind]} ${measured}${gate}`
651
655
  }
@@ -662,10 +666,10 @@ function reportDensity(
662
666
  ): void {
663
667
  logStep('Density')
664
668
  logInfo(
665
- `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, and an H3 opening with neither a paragraph nor a table.`,
669
+ `Paragraphs past ${CHECKPOINTS.paragraph} characters, more than 2 paragraphs in a row, bullets past ${CHECKPOINTS.bullet} characters, more than 5 plain bullets in a row, a heading past H3, an H3 opening with neither a paragraph nor a table, a table cell past ${TABLE_CELL} characters, and a list directly after a table.`,
666
670
  )
667
671
  logInfo(
668
- 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone. Every other path takes no density finding.',
672
+ 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone, with no table finding. Every other path takes no density finding.',
669
673
  )
670
674
  logInfo(densityGatingLine(gated, source))
671
675
 
@@ -689,7 +693,7 @@ function reportDensity(
689
693
  )
690
694
  if (gating > 0) logWarn('A gating finding fails the run.')
691
695
  logInfo(
692
- 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, and open each H3 with a sentence or a table.',
696
+ 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, open each H3 with a sentence or a table, move a long cell into a bullet under the lead, and give a trailing list its own lead sentence.',
693
697
  )
694
698
  pipeOutput(
695
699
  carrying
@@ -718,29 +722,23 @@ function densityGatingLine(
718
722
  }
719
723
 
720
724
  const TABLE_LABELS: Record<TableKind, string> = {
721
- 'table-cell': 'cell length',
722
725
  'table-rows': 'rows',
723
- 'table-then-list': 'list after table',
724
726
  'table-token': 'wide token',
725
727
  }
726
728
 
727
729
  function describeTable(finding: TableFinding): string {
728
- const measured =
729
- finding.kind === 'table-then-list'
730
- ? 'follows the table with no lead sentence'
731
- : `${finding.value} past ${finding.limit}`
732
- return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${measured}`
730
+ return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${finding.value} past ${finding.limit}`
733
731
  }
734
732
 
735
733
  /**
736
- * Lists every table finding on the density scope's paths. None gates yet,
737
- * since a gate is decided per path rather than per kind, and a new density
738
- * kind would fail every gated folder carrying a table hit at once.
734
+ * Lists the table findings that only report, on the density scope's paths. The
735
+ * cell and the trailing list gate as density kinds, so `reportDensity` lists
736
+ * them.
739
737
  */
740
738
  function reportTables(reports: readonly FileReport[]): void {
741
739
  logStep('Tables')
742
740
  logInfo(
743
- `Cells past ${TABLE_CELL} characters, tables past ${TABLE_ROWS} body rows, a list directly after a table, and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
741
+ `Tables past ${TABLE_ROWS} body rows and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
744
742
  )
745
743
  logInfo(
746
744
  'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
@@ -760,7 +758,7 @@ function reportTables(reports: readonly FileReport[]): void {
760
758
  `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
761
759
  )
762
760
  logInfo(
763
- 'Move a long cell into a bullet under the lead, split a long table into groups each under a lead sentence, give a trailing list its own lead, and move a wide token out of the table.',
761
+ 'Split a long table into groups each under a lead sentence, and move a wide token out of the table.',
764
762
  )
765
763
  pipeOutput(
766
764
  carrying
@@ -917,11 +915,9 @@ function reportBullets(
917
915
  ): void {
918
916
  logStep('Bullets')
919
917
  logInfo(
920
- 'Top-level bullets measure characters, folding in continuation lines.',
921
- )
922
- logInfo(
923
- 'Nested items and fenced blocks are excluded. Weight is a judgment, never a defect.',
918
+ 'Bullets measure characters, folding in continuation lines, and a nested bullet measures on its own.',
924
919
  )
920
+ logInfo('Fenced blocks are excluded. Weight is a judgment, never a defect.')
925
921
 
926
922
  const carrying = reports
927
923
  .filter((report) => report.structure.heavyBullets.length > 0)
@@ -3839,7 +3839,7 @@ async function readEvidenceGate(
3839
3839
  cwd: root,
3840
3840
  reject: false,
3841
3841
  timeout: GH_TIMEOUT_MS * 2,
3842
- env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1' },
3842
+ env: gitEnv(),
3843
3843
  extendEnv: false,
3844
3844
  },
3845
3845
  )
@@ -423,7 +423,7 @@ async function runSync(target: string): Promise<number> {
423
423
  /**
424
424
  * Each domain sync runs as its own process. They open their own frames and
425
425
  * prompt for their own changes, so stdin stays inherited. Claude is the one
426
- * exception: it runs headless because the combined pull request preview is the
426
+ * exception: its stdin is closed because the combined pull request preview is the
427
427
  * single confirmation gate for the whole sync, and `canon claude sync` writes
428
428
  * only `.gitignore`.
429
429
  */
@@ -36,7 +36,7 @@ import {
36
36
  logStep,
37
37
  logWarn,
38
38
  outro,
39
- palette,
39
+ outroSuccess,
40
40
  select,
41
41
  } from '@/cli/ui'
42
42
 
@@ -101,7 +101,7 @@ export function register(program: Command): void {
101
101
  'Examples:',
102
102
  ' canon tooling sync base',
103
103
  ' canon tooling sync base --check',
104
- ' CANON_NON_INTERACTIVE=1 canon tooling sync base --write',
104
+ ' canon tooling sync base --write',
105
105
  ' canon tooling sync vite-react web --skip base',
106
106
  '',
107
107
  'A target below its git root syncs as a subfolder. It withholds',
@@ -412,7 +412,6 @@ async function runSync(
412
412
  }
413
413
 
414
414
  const mode = resolveWriteMode(opts)
415
- const { GREEN, NC } = palette(process.stderr)
416
415
 
417
416
  if (result.totalChanges === 0) {
418
417
  // The stamp is a write like any other, so a run with no authority to write
@@ -421,8 +420,7 @@ async function runSync(
421
420
  if (mode === 'apply' || mode === 'prompt') {
422
421
  await stampChain(prepared.chain, prepared.target)
423
422
  }
424
- outro()
425
- process.stderr.write(`${GREEN}✓ Everything up to date${NC}\n`)
423
+ outroSuccess('Everything up to date')
426
424
  return 0
427
425
  }
428
426
 
@@ -450,8 +448,7 @@ async function runSync(
450
448
 
451
449
  await stampChain(prepared.chain, prepared.target)
452
450
 
453
- outro()
454
- process.stderr.write(`${GREEN}✓ Tooling sync complete${NC}\n`)
451
+ outroSuccess('Tooling sync complete')
455
452
  return 0
456
453
  }
457
454
 
@@ -607,7 +604,7 @@ async function promptForStack(): Promise<string | undefined> {
607
604
  return select({
608
605
  message: 'Select tooling stack:',
609
606
  options: stacks.map((name) => ({ value: name, label: name })),
610
- nonInteractiveDefault: true,
607
+ ttyHint: `Pass a stack, one of: ${stacks.join(', ')}`,
611
608
  })
612
609
  }
613
610