@erclx/canon 5.54.0 → 5.55.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 (41) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -1
  4. package/claude/skills/document-health/SKILL.md +6 -6
  5. package/claude/skills/document-repair/SKILL.md +5 -5
  6. package/claude/skills/target-check/SKILL.md +6 -8
  7. package/claude/skills/target-check/references/domains.md +1 -4
  8. package/claude/skills/target-setup/SKILL.md +8 -8
  9. package/claude/skills/target-update/SKILL.md +5 -5
  10. package/docs/agents/install-and-sync.md +2 -2
  11. package/docs/agents/output-shape.md +11 -10
  12. package/docs/target/sync.md +1 -1
  13. package/package.json +1 -1
  14. package/scripts/core/install-check.sh +1 -1
  15. package/scripts/lib/ui.sh +12 -65
  16. package/src/audits/run.ts +1 -1
  17. package/src/claude/routing.ts +1 -1
  18. package/src/cli/run.ts +5 -6
  19. package/src/cli/ui.ts +87 -28
  20. package/src/cli.ts +6 -0
  21. package/src/commands/claude.ts +2 -3
  22. package/src/commands/design.ts +8 -13
  23. package/src/commands/gate.ts +5 -3
  24. package/src/commands/gov.ts +11 -21
  25. package/src/commands/labels.ts +1 -1
  26. package/src/commands/pr.ts +1 -1
  27. package/src/commands/sync.ts +1 -1
  28. package/src/commands/tooling.ts +5 -8
  29. package/src/commands/transcripts.ts +6 -11
  30. package/src/gate/sequencer.ts +1 -1
  31. package/src/gate/stages.ts +0 -10
  32. package/src/hooks/post-merge.ts +2 -5
  33. package/src/process/harness.ts +3 -4
  34. package/src/sandbox/equivalence.ts +0 -1
  35. package/src/sandbox/provision.ts +8 -13
  36. package/src/sandbox/scenario.ts +6 -12
  37. package/src/sync/engine.ts +3 -8
  38. package/src/sync/workflow.ts +3 -5
  39. package/src/tooling/verify-stack.ts +1 -1
  40. package/tooling/base/configs/.husky/post-merge +6 -10
  41. package/scripts/core/check-color-source.sh +0 -51
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') {
@@ -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
 
@@ -2,7 +2,7 @@ import { resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import { recordDir } from '@/roots/record'
4
4
  import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
5
- import { palette } from '@/cli/ui'
5
+ import { intro, logError, logInfo, outro } from '@/cli/ui'
6
6
  import { mainWorktreeRoot } from '@/git/worktree'
7
7
 
8
8
  interface TranscriptOptions {
@@ -37,10 +37,7 @@ export function register(program: Command): void {
37
37
  )
38
38
  .action(async (url: string, opts: TranscriptOptions) => {
39
39
  const outDir = await resolveOutDir(opts)
40
- const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
41
- process.stderr.write(
42
- `${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}canon transcripts${NC}\n`,
43
- )
40
+ intro('canon transcripts')
44
41
  try {
45
42
  ensureYtDlp()
46
43
  const target = await fetchOne(url, {
@@ -48,14 +45,12 @@ export function register(program: Command): void {
48
45
  keepTimestamps: opts.keepTimestamps ?? false,
49
46
  })
50
47
  process.stdout.write(`${target}\n`)
51
- process.stderr.write(
52
- `${GREY}│${NC} ${GREEN}✓${NC} ${target}\n${GREY}└${NC}\n`,
53
- )
48
+ logInfo(target)
49
+ outro()
54
50
  } catch (error) {
55
51
  const message = error instanceof Error ? error.message : String(error)
56
- process.stderr.write(
57
- `${GREY}│${NC} ${RED}✗${NC} ${message}\n${GREY}└${NC}\n`,
58
- )
52
+ logError(message)
53
+ outro()
59
54
  process.exitCode = 1
60
55
  }
61
56
  })
@@ -525,7 +525,7 @@ async function spawn(
525
525
  cwd,
526
526
  reject: false,
527
527
  all: true,
528
- env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1', ...env },
528
+ env: { ...gitEnv(), ...env },
529
529
  extendEnv: false,
530
530
  })
531
531
 
@@ -119,12 +119,6 @@ const TESTS_SCOPE = new RegExp(
119
119
  ].join('|'),
120
120
  )
121
121
 
122
- const script = (name: string, failure: string): Check => ({
123
- kind: 'command',
124
- argv: ['bash', `scripts/core/${name}`],
125
- failure,
126
- })
127
-
128
122
  /**
129
123
  * Every stage the gate runs, in the order it runs them.
130
124
  *
@@ -530,10 +524,6 @@ export const STAGES: readonly Stage[] = [
530
524
  argv: ['bun', 'run', 'check:shell'],
531
525
  failure: 'Shell check failed',
532
526
  },
533
- script(
534
- 'check-color-source.sh',
535
- 'A color escape is defined outside scripts/lib/ui.sh.',
536
- ),
537
527
  ],
538
528
  success: 'Shell check passed',
539
529
  },
@@ -359,11 +359,8 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
359
359
  }
360
360
 
361
361
  if (!isSkipped('CANON_SKIP_UPGRADE')) {
362
- await step(
363
- input,
364
- at([...STEP_VERBS.upgrade, '--json'], { CANON_NON_INTERACTIVE: '1' }),
365
- (result, fields) =>
366
- moveLines(result, fields, '⬆️ ', 'CLI not upgraded', []),
362
+ await step(input, at([...STEP_VERBS.upgrade, '--json']), (result, fields) =>
363
+ moveLines(result, fields, '⬆️ ', 'CLI not upgraded', []),
367
364
  )
368
365
  }
369
366
 
@@ -90,8 +90,7 @@ export function snapshotStateDir(): string {
90
90
  *
91
91
  * A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
92
92
  * reads against this checkout instead of the temporary directory a case
93
- * builds, so every spawn drops the `GIT_` prefix before adding the headless
94
- * flag every case needs to avoid a picker blocking on stdin.
93
+ * builds, so every spawn drops the `GIT_` prefix.
95
94
  *
96
95
  * `CANON_STATE_DIR` and `CANON_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
97
96
  * each pointed at a folder under the case's own `cwd` rather than dropped,
@@ -99,7 +98,8 @@ export function snapshotStateDir(): string {
99
98
  * `HOME` to this machine's real `~/.local/state/canon`. `stateDir()` and
100
99
  * `sandboxTree()` resolve the same three ways and share that parent, so both
101
100
  * overrides move together. A case explicitly passing its own value through
102
- * `options.env` still wins, matching `CANON_NON_INTERACTIVE` below.
101
+ * `options.env` still wins. The child's stdin is a pipe rather than a
102
+ * terminal, so every case runs headless.
103
103
  *
104
104
  * The `stateDir()` snapshot before and after the spawn is what actually
105
105
  * catches an escape past that redirection, since a default can be wrong in a
@@ -125,7 +125,6 @@ export function runCli(
125
125
  timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
126
126
  env: {
127
127
  ...inherited,
128
- CANON_NON_INTERACTIVE: '1',
129
128
  CANON_STATE_DIR: join(options.cwd, '.canon-state'),
130
129
  CANON_SANDBOX_DIR: join(options.cwd, '.canon-state', 'sandbox'),
131
130
  ...options.env,
@@ -517,7 +517,6 @@ function provisionEnv(
517
517
  ): NodeJS.ProcessEnv {
518
518
  const env: NodeJS.ProcessEnv = {
519
519
  ...process.env,
520
- CANON_NON_INTERACTIVE: '1',
521
520
  CANON_SANDBOX_DIR: dir,
522
521
  CANON_SANDBOX_RUN_ID: PINNED_RUN_ID,
523
522
  CLAUDE_CONFIG_DIR: configDirFor(dir),