@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
@@ -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
 
@@ -1,10 +1,6 @@
1
- import {
2
- type DensityFinding,
3
- type DensityKind,
4
- densityFindings,
5
- } from '@/markdown/density'
6
- import { type BodyLine, bodyLines, visibleText } from '@/markdown/scan'
7
- import { CHECKPOINTS, LIST_ITEM } from '@/markdown/structure'
1
+ import { type DensityKind, densityFindings } from '@/markdown/density'
2
+ import { type BodyLine, bodyLines } from '@/markdown/scan'
3
+ import { LIST_ITEM } from '@/markdown/structure'
8
4
 
9
5
  /**
10
6
  * The four counted limits a pull request section carries. The heading limits
@@ -119,7 +115,6 @@ export interface PostDensityFinding extends Omit<BodyDensityFinding, 'kind'> {
119
115
  }
120
116
 
121
117
  const LENGTHS: readonly DensityKind[] = ['paragraph-length', 'bullet-length']
122
- const NESTED_ITEM = /^(\s+)(?:[-*+]|\d+\.)\s+(.*)$/
123
118
  const CHECKBOX = /^\s*[-*+]\s+\[[ xX]\]/
124
119
  const FOOTER = /^🤖/
125
120
 
@@ -134,8 +129,8 @@ const FOOTER = /^🤖/
134
129
  * paragraph run, and a checklist box under `What to look at` takes no length
135
130
  * limit, since a driver needs its route, action, and result.
136
131
  *
137
- * A nested item is measured as a bullet of its own, which `densityFindings()`
138
- * leaves out of its parent, so a finding's subject and its evidence never sum.
132
+ * A nested item is measured as a bullet of its own by `densityFindings()`, which
133
+ * leaves it out of its parent, so a finding's subject and its evidence never sum.
139
134
  */
140
135
  export function postDensity(body: string): PostDensityFinding[] {
141
136
  const lines = bodyLines(body).map((line) =>
@@ -153,7 +148,6 @@ export function postDensity(body: string): PostDensityFinding[] {
153
148
  (chunk) => densityFindings(chunk),
154
149
  ),
155
150
  ...densityFindings(inside).filter(({ kind }) => LENGTHS.includes(kind)),
156
- ...nestedLengths(section.lines),
157
151
  ]
158
152
 
159
153
  for (const finding of measured) {
@@ -214,41 +208,6 @@ function foldedApart(lines: readonly BodyLine[]): {
214
208
  return { outside, inside }
215
209
  }
216
210
 
217
- /**
218
- * A `bullet-length` finding for each nested item past the bullet limit, its
219
- * length taken over the item and the lines indented under it that open no
220
- * item of their own.
221
- */
222
- function nestedLengths(lines: readonly BodyLine[]): DensityFinding[] {
223
- const found: DensityFinding[] = []
224
-
225
- for (const [index, line] of lines.entries()) {
226
- const match = line.fenced ? null : NESTED_ITEM.exec(line.text)
227
- if (!match) continue
228
-
229
- const indent = match[1].length
230
- const parts = [match[2].trim()]
231
- for (const next of lines.slice(index + 1)) {
232
- const lead = /^\s*/.exec(next.text)?.[0].length ?? 0
233
- if (next.fenced || next.text.trim() === '' || lead <= indent) break
234
- if (LIST_ITEM.test(next.text)) break
235
- parts.push(next.text.trim())
236
- }
237
-
238
- const length = visibleText(parts.join(' ')).length
239
- if (length > CHECKPOINTS.bullet) {
240
- found.push({
241
- kind: 'bullet-length',
242
- line: line.number,
243
- value: length,
244
- limit: CHECKPOINTS.bullet,
245
- })
246
- }
247
- }
248
-
249
- return found
250
- }
251
-
252
211
  function sectionsOf(lines: readonly BodyLine[]): Section[] {
253
212
  const sections: Section[] = []
254
213
  let current: { name: string | null; lines: BodyLine[] } = {
@@ -22,6 +22,8 @@ export type DensityKind =
22
22
  | 'bullet-run'
23
23
  | 'heading-depth'
24
24
  | 'h3-opening'
25
+ | 'table-cell'
26
+ | 'table-then-list'
25
27
 
26
28
  export interface DensityFinding {
27
29
  readonly kind: DensityKind
@@ -52,14 +54,16 @@ type Block =
52
54
  * here it belongs to the item: it counts toward the bullet's length and never
53
55
  * toward a paragraph run, since a reader sees it inside the list.
54
56
  *
55
- * A nested item is left out of its parent, as `heavyBullets` leaves it, and so
56
- * is everything indented under the nested item. An indented fence inside an
57
- * item stays with the item, and a column-zero one ends it.
57
+ * A nested item is a block of its own, taking the lines indented past its
58
+ * marker, and its parent leaves it out along with everything indented under it.
59
+ * An indented fence inside an item joins neither and ends the nested item, and
60
+ * a column-zero one ends both.
58
61
  */
59
62
  function blocksOf(lines: readonly BodyLine[]): Block[] {
60
63
  const blocks: Block[] = []
61
64
  let paragraph: { line: number; parts: string[] } | undefined
62
65
  let item: { line: number; parts: string[]; nested: boolean } | undefined
66
+ let child: { line: number; parts: string[]; indent: number } | undefined
63
67
  let inFence = false
64
68
  let afterBlank = false
65
69
 
@@ -74,7 +78,19 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
74
78
  paragraph = undefined
75
79
  }
76
80
 
81
+ const closeChild = (): void => {
82
+ if (child) {
83
+ blocks.push({
84
+ kind: 'item',
85
+ line: child.line,
86
+ text: child.parts.join(' '),
87
+ })
88
+ }
89
+ child = undefined
90
+ }
91
+
77
92
  const closeItem = (): void => {
93
+ closeChild()
78
94
  if (item) {
79
95
  blocks.push({ kind: 'item', line: item.line, text: item.parts.join(' ') })
80
96
  }
@@ -95,7 +111,8 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
95
111
  // The opening delimiter decides who owns the block. `bodyLines` marks a
96
112
  // delimiter and the body alike, so the opener is the first fenced line.
97
113
  if (!inFence) {
98
- if (!(item && indented)) {
114
+ if (item && indented) closeChild()
115
+ else {
99
116
  closeAll()
100
117
  blocks.push({ kind: 'other', line: line.number, table: false })
101
118
  }
@@ -108,13 +125,27 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
108
125
 
109
126
  if (text === '') {
110
127
  closeParagraph()
128
+ closeChild()
111
129
  afterBlank = true
112
130
  continue
113
131
  }
114
132
 
115
133
  if (indented && item) {
116
- if (LIST_ITEM.test(raw)) item.nested = true
117
- else if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
134
+ const lead = leadOf(raw)
135
+ if (LIST_ITEM.test(raw)) {
136
+ closeChild()
137
+ item.nested = true
138
+ child = {
139
+ line: line.number,
140
+ parts: [text.replace(LIST_ITEM, '')],
141
+ indent: lead,
142
+ }
143
+ } else if (child && lead > child.indent) {
144
+ if (!TABLE_ROW.test(raw)) child.parts.push(text)
145
+ } else {
146
+ closeChild()
147
+ if (!item.nested && !TABLE_ROW.test(raw)) item.parts.push(text)
148
+ }
118
149
  afterBlank = false
119
150
  continue
120
151
  }
@@ -163,6 +194,10 @@ function blocksOf(lines: readonly BodyLine[]): Block[] {
163
194
  return blocks
164
195
  }
165
196
 
197
+ function leadOf(raw: string): number {
198
+ return raw.length - raw.trimStart().length
199
+ }
200
+
166
201
  /**
167
202
  * Every breach of the six density limits, in line order.
168
203
  *
@@ -4,6 +4,7 @@ import {
4
4
  densityFindings,
5
5
  } from '@/markdown/density'
6
6
  import type { BodyLine } from '@/markdown/scan'
7
+ import { tableDensityFindings } from '@/markdown/tables'
7
8
 
8
9
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
9
10
  const BULLET_RUN_SCOPE = [
@@ -68,12 +69,16 @@ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
68
69
  * The density findings a path takes, being every finding on a density-scope
69
70
  * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
71
  * and `canon claude sync` all read this, so the three agree on what counts.
72
+ * The table cell and trailing-list findings join on a density-scope path only,
73
+ * since `densityFindings` stays what its direct callers grade.
71
74
  */
72
75
  export function scopedDensityFindings(
73
76
  rel: string,
74
77
  lines: readonly BodyLine[],
75
78
  ): DensityFinding[] {
76
- if (inDensityScope(rel)) return densityFindings(lines)
79
+ if (inDensityScope(rel)) {
80
+ return [...densityFindings(lines), ...tableDensityFindings(lines)]
81
+ }
77
82
  if (!KEY_PATHS_SCOPE.test(rel)) return []
78
83
  return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
84
  KEY_PATHS_KINDS.includes(finding.kind),
@@ -415,10 +415,10 @@ export function longestRun(
415
415
  }
416
416
 
417
417
  /**
418
- * Finds the top-level bullets carrying more than a decision.
418
+ * Finds the bullets carrying more than a decision, nested ones included.
419
419
  *
420
- * A nested item is left out rather than folded into its parent, since the
421
- * checkpoint asks what one bullet carries and a child carries its own. Lines
420
+ * A nested item is measured on its own and never folded into its parent, since
421
+ * the checkpoint asks what one bullet carries and a child carries its own. Lines
422
422
  * continuing a bullet do fold in, so a heavy bullet cannot fall under the
423
423
  * checkpoint by being wrapped across two source lines. A fenced line closes the
424
424
  * open bullet, which keeps a bullet from absorbing the example below it.
@@ -436,6 +436,8 @@ export function heavyBullets(
436
436
  interface FoldedBullet extends BulletFinding {
437
437
  /** The bullet's source lines joined by the space a wrap would have carried. */
438
438
  readonly text: string
439
+ /** The width of the indent before the marker, which a continuation must pass. */
440
+ readonly indent: number
439
441
  }
440
442
 
441
443
  /** The walk `heavyBullets` reports from. */
@@ -459,18 +461,18 @@ function foldBullets(
459
461
 
460
462
  const item = line.text.match(LIST_ITEM)
461
463
  const text = line.text.trim()
464
+ const lead = line.text.length - line.text.trimStart().length
462
465
 
463
466
  // Structure is read off the raw line and only the weight is masked. A line
464
467
  // carrying nothing but an autolink has no visible text at all, and reading
465
468
  // its masked form as blank would close the bullet it continues.
466
469
  if (item) {
467
470
  close()
468
- if (item[1].length === 0) {
469
- open = {
470
- line: line.number,
471
- characters: visibleText(text).length,
472
- text,
473
- }
471
+ open = {
472
+ line: line.number,
473
+ characters: visibleText(text).length,
474
+ text,
475
+ indent: item[1].length,
474
476
  }
475
477
  continue
476
478
  }
@@ -480,6 +482,13 @@ function foldBullets(
480
482
  continue
481
483
  }
482
484
 
485
+ // An indented line that does not pass the marker's indent belongs to a
486
+ // parent or to nothing, so it ends a nested item rather than extending it.
487
+ if (open && lead > 0 && lead <= open.indent) {
488
+ close()
489
+ continue
490
+ }
491
+
483
492
  // The joining space a wrapped line would have carried, so folding two
484
493
  // source lines measures what one unwrapped line would have.
485
494
  if (open)
@@ -1,3 +1,4 @@
1
+ import type { DensityFinding } from '@/markdown/density'
1
2
  import { type BodyLine, visibleText } from '@/markdown/scan'
2
3
  import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
4
 
@@ -10,19 +11,14 @@ export const TABLE_ROWS = 8
10
11
  /** The longest unbroken run a code span in a table cell may carry. */
11
12
  export const TABLE_TOKEN = 30
12
13
 
13
- export type TableKind =
14
- | 'table-cell'
15
- | 'table-rows'
16
- | 'table-then-list'
17
- | 'table-token'
14
+ export type TableKind = 'table-rows' | 'table-token'
18
15
 
19
16
  export interface TableFinding {
20
17
  readonly kind: TableKind
21
- /** The row holding the cell or span, the header for a row count, or the list's first item. */
18
+ /** The span's row, or the header for a row count. */
22
19
  readonly line: number
23
- /** What was measured, or null for a trailing list, which is a shape rather than a count. */
24
- readonly value: number | null
25
- readonly limit: number | null
20
+ readonly value: number
21
+ readonly limit: number
26
22
  }
27
23
 
28
24
  /**
@@ -55,16 +51,22 @@ function cellsOf(row: string): string[] {
55
51
  }
56
52
 
57
53
  /**
58
- * Every breach of the four table limits, in line order.
54
+ * Every breach of the four table limits, in line order, split by whether it
55
+ * gates.
59
56
  *
60
57
  * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
58
  * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
59
  * fenced line is an example, so neither is measured. The limits are a
63
60
  * 120-character cell, 8 body rows, a list as the next block, and a code span
64
- * holding a run past 30 characters. Every finding reports and none gates.
61
+ * holding a run past 30 characters. The cell and the trailing list are density
62
+ * findings, and the row count and the wide token report and never gate.
65
63
  */
66
- export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
- const found: TableFinding[] = []
64
+ function measureTables(lines: readonly BodyLine[]): {
65
+ reported: TableFinding[]
66
+ density: DensityFinding[]
67
+ } {
68
+ const reported: TableFinding[] = []
69
+ const density: DensityFinding[] = []
68
70
  let index = 0
69
71
 
70
72
  while (index < lines.length) {
@@ -96,7 +98,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
96
98
  for (const cell of cellsOf(row.text)) {
97
99
  const length = visibleText(cell).length
98
100
  if (length > TABLE_CELL) {
99
- found.push({
101
+ density.push({
100
102
  kind: 'table-cell',
101
103
  line: row.number,
102
104
  value: length,
@@ -106,7 +108,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
106
108
  for (const span of codeSpans(cell)) {
107
109
  const run = unbrokenRun(span)
108
110
  if (run > TABLE_TOKEN) {
109
- found.push({
111
+ reported.push({
110
112
  kind: 'table-token',
111
113
  line: row.number,
112
114
  value: run,
@@ -119,7 +121,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
119
121
 
120
122
  const bodyRows = tableRows.length - 1
121
123
  if (bodyRows > TABLE_ROWS) {
122
- found.push({
124
+ reported.push({
123
125
  kind: 'table-rows',
124
126
  line: header.number,
125
127
  value: bodyRows,
@@ -135,7 +137,7 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
135
137
  !following.fenced &&
136
138
  LIST_ITEM.test(following.text)
137
139
  ) {
138
- found.push({
140
+ density.push({
139
141
  kind: 'table-then-list',
140
142
  line: following.number,
141
143
  value: null,
@@ -146,5 +148,17 @@ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
146
148
  index = end
147
149
  }
148
150
 
149
- return found.sort((a, b) => a.line - b.line)
151
+ return { reported, density }
152
+ }
153
+
154
+ /** The row count and wide token findings, which report and never gate. */
155
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
156
+ return measureTables(lines).reported.sort((a, b) => a.line - b.line)
157
+ }
158
+
159
+ /** The cell and trailing-list findings, which gate as density kinds do. */
160
+ export function tableDensityFindings(
161
+ lines: readonly BodyLine[],
162
+ ): DensityFinding[] {
163
+ return measureTables(lines).density.sort((a, b) => a.line - b.line)
150
164
  }
@@ -9,6 +9,13 @@ const CLI = join(PROJECT_ROOT, 'src/cli.ts')
9
9
  /** No case has ever needed longer, and a blocked verb should fail fast. */
10
10
  const DEFAULT_TIMEOUT_MS = 10_000
11
11
 
12
+ /**
13
+ * A spawn that reaches `bun add` fails at once against this unreachable
14
+ * address, so an unseeded install is a loud defect rather than a test that
15
+ * passes or fails with the network. `options.env` still wins.
16
+ */
17
+ const DEAD_REGISTRY = 'http://127.0.0.1:9'
18
+
12
19
  export interface ProcessRun {
13
20
  readonly status: number | null
14
21
  readonly stdout: string
@@ -90,8 +97,7 @@ export function snapshotStateDir(): string {
90
97
  *
91
98
  * A git hook exports `GIT_DIR`, which would resolve a fixture's git-aware
92
99
  * 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.
100
+ * builds, so every spawn drops the `GIT_` prefix.
95
101
  *
96
102
  * `CANON_STATE_DIR` and `CANON_SANDBOX_DIR` get the same treatment as `GIT_DIR`,
97
103
  * each pointed at a folder under the case's own `cwd` rather than dropped,
@@ -99,7 +105,8 @@ export function snapshotStateDir(): string {
99
105
  * `HOME` to this machine's real `~/.local/state/canon`. `stateDir()` and
100
106
  * `sandboxTree()` resolve the same three ways and share that parent, so both
101
107
  * overrides move together. A case explicitly passing its own value through
102
- * `options.env` still wins, matching `CANON_NON_INTERACTIVE` below.
108
+ * `options.env` still wins. The child's stdin is a pipe rather than a
109
+ * terminal, so every case runs headless.
103
110
  *
104
111
  * The `stateDir()` snapshot before and after the spawn is what actually
105
112
  * catches an escape past that redirection, since a default can be wrong in a
@@ -125,9 +132,9 @@ export function runCli(
125
132
  timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
126
133
  env: {
127
134
  ...inherited,
128
- CANON_NON_INTERACTIVE: '1',
129
135
  CANON_STATE_DIR: join(options.cwd, '.canon-state'),
130
136
  CANON_SANDBOX_DIR: join(options.cwd, '.canon-state', 'sandbox'),
137
+ BUN_CONFIG_REGISTRY: DEAD_REGISTRY,
131
138
  ...options.env,
132
139
  },
133
140
  })
@@ -0,0 +1,21 @@
1
+ import { existsSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { PROJECT_ROOT } from '@/roots/project'
4
+ import { resolveChain } from '@/tooling/manifest'
5
+ import { collectDeps, parseSpec } from '@/tooling/package'
6
+
7
+ // A write run into a folder with no package.json would run `bun add -D` against
8
+ // the registry. A package.json already naming the stack's packages leaves
9
+ // nothing to install, so a test not about the manifest stays offline.
10
+ export const seedPackage = (dir: string, stack: string): void => {
11
+ const path = join(dir, 'package.json')
12
+ if (existsSync(path)) return
13
+
14
+ const devDependencies = Object.fromEntries(
15
+ collectDeps(resolveChain(PROJECT_ROOT, stack), {}).map((dep) => [
16
+ dep.name,
17
+ parseSpec(dep.spec).pin || '*',
18
+ ]),
19
+ )
20
+ writeFileSync(path, JSON.stringify({ devDependencies }))
21
+ }
@@ -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),
@@ -27,7 +27,7 @@ import {
27
27
  logStep,
28
28
  logWarn,
29
29
  outro,
30
- palette,
30
+ outroSuccess,
31
31
  select,
32
32
  } from '@/cli/ui'
33
33
 
@@ -99,9 +99,7 @@ function readGit(cwd: string, args: string[]): string | undefined {
99
99
  }
100
100
 
101
101
  function finish(message: string): void {
102
- const { GREEN, NC } = palette(process.stderr)
103
- outro()
104
- process.stderr.write(`\n${GREEN}${message}${NC}\n`)
102
+ outroSuccess(message)
105
103
  }
106
104
 
107
105
  function stageAnchorTree(
@@ -210,7 +208,7 @@ function install(
210
208
  env: NodeJS.ProcessEnv,
211
209
  ): void {
212
210
  const result = spawnSync('bun', [join(root, 'src', 'cli.ts'), ...args], {
213
- env: { ...env, CANON_NON_INTERACTIVE: '1' },
211
+ env,
214
212
  encoding: 'utf8',
215
213
  stdio: ['ignore', 'ignore', 'pipe'],
216
214
  })
@@ -432,10 +430,7 @@ async function provisionScenario(
432
430
  baseEnv: NodeJS.ProcessEnv,
433
431
  ): Promise<void> {
434
432
  const env: NodeJS.ProcessEnv = { ...baseEnv }
435
- if (scenario.arm !== undefined) {
436
- env.SANDBOX_SCENARIO = scenario.arm
437
- env.CANON_NON_INTERACTIVE = '1'
438
- }
433
+ if (scenario.arm !== undefined) env.SANDBOX_SCENARIO = scenario.arm
439
434
 
440
435
  const sandboxDir = join(root, 'sandbox')
441
436
  const declared = declareScenario(root, scenario, env)
@@ -475,14 +470,14 @@ async function provisionScenario(
475
470
  must(commitScenarioChanges(sandbox, isSkipAutoCommit, env))
476
471
  tagBaseline(sandbox, env)
477
472
 
478
- finish('✓ Sandbox Ready')
473
+ finish('Sandbox Ready')
479
474
  }
480
475
 
481
476
  function cleanSandbox(sandbox: string): void {
482
477
  logStep('Removing sandbox')
483
478
  rmSync(sandbox, { recursive: true, force: true })
484
479
  logRemove(sandbox)
485
- finish('✓ Sandbox clean')
480
+ finish('Sandbox clean')
486
481
  }
487
482
 
488
483
  function isAtBaseline(sandbox: string): boolean {
@@ -520,7 +515,7 @@ async function resetSandbox(sandbox: string): Promise<void> {
520
515
 
521
516
  if (isAtBaseline(sandbox)) {
522
517
  logInfo('At baseline')
523
- finish('✓ Sandbox at baseline')
518
+ finish('Sandbox at baseline')
524
519
  return
525
520
  }
526
521
 
@@ -556,7 +551,7 @@ async function resetSandbox(sandbox: string): Promise<void> {
556
551
  must(run('git', ['checkout-index', '-a', '-f'], quiet))
557
552
  }
558
553
  logInfo('Sandbox reset to baseline')
559
- finish('✓ Sandbox reset complete')
554
+ finish('Sandbox reset complete')
560
555
  }
561
556
 
562
557
  /**
@@ -13,6 +13,7 @@ import {
13
13
  import { createRequire } from 'node:module'
14
14
  import { dirname, join, relative } from 'node:path'
15
15
  import {
16
+ isNonInteractive,
16
17
  logError,
17
18
  logInfo,
18
19
  logStep,
@@ -580,8 +581,9 @@ export function createStageContext(options: ContextOptions): StageContext {
580
581
  }
581
582
 
582
583
  /**
583
- * Picks the arm: a routed name first,
584
- * echoed on the frame, then the first arm for a headless caller, then a prompt.
584
+ * Picks the arm: a routed name first, echoed on the frame, then a prompt. A
585
+ * headless caller naming no arm is refused rather than handed the first one,
586
+ * since a verdict on an arm nobody chose reads as a verdict on the scenario.
585
587
  */
586
588
  async function routeArm(
587
589
  names: readonly string[],
@@ -600,17 +602,9 @@ async function routeArm(
600
602
  const routed = env.SANDBOX_SCENARIO ?? ''
601
603
  if (routed !== '') return echo(routed)
602
604
 
603
- // Read off the scenario's environment rather than this process's, as
604
- // `is_non_interactive` read the shell it ran in.
605
- const isHeadless =
606
- env.CANON_NON_INTERACTIVE === '1' ||
607
- // canon-keep-retired
608
- env.AITK_NON_INTERACTIVE === '1'
609
- if (isHeadless) return echo(names[0] ?? DEFAULT_ARM_NAME)
610
-
611
- if (!process.stdin.isTTY) {
605
+ if (isNonInteractive()) {
612
606
  logError(
613
- `${prompt} requires a TTY. Pass an argument or set CANON_NON_INTERACTIVE=1.`,
607
+ `${prompt} requires a TTY. Pass the arm as the scenario argument, one of: ${names.join(', ')}.`,
614
608
  )
615
609
  throw new ScenarioStop(1, 'exited')
616
610
  }