@erclx/canon 5.49.0 → 5.51.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 (86) hide show
  1. package/CHANGELOG.md +4024 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  6. package/claude/skills/canvas-board/SKILL.md +8 -9
  7. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  8. package/claude/skills/ci-workflow/SKILL.md +26 -1
  9. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  10. package/claude/skills/context-fold/references/context.md +4 -3
  11. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  12. package/claude/skills/design-record/SKILL.md +84 -0
  13. package/claude/skills/design-record/references/folder.md +69 -0
  14. package/claude/skills/design-taste/SKILL.md +3 -2
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/references/pr.md +1 -1
  17. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  18. package/claude/skills/markdown-craft/SKILL.md +6 -1
  19. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  20. package/claude/skills/markdown-craft/references/tables.md +30 -0
  21. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  22. package/claude/skills/plan-feature/SKILL.md +13 -1
  23. package/claude/skills/plan-feature/references/batches.md +1 -1
  24. package/claude/skills/session-worktree/SKILL.md +1 -1
  25. package/claude/skills/target-check/references/domains.md +8 -6
  26. package/docs/agents/index.md +2 -2
  27. package/docs/agents/markdown-audit.md +5 -2
  28. package/docs/agents/migrate.md +2 -0
  29. package/docs/agents/targets.md +8 -1
  30. package/docs/target/migrations.md +8 -1
  31. package/docs/target/projects.md +1 -1
  32. package/docs/workflow/parallel-features.md +1 -1
  33. package/docs/workflow/skill-map.md +2 -1
  34. package/docs/workflow/visual-design-workflow.md +2 -1
  35. package/governance/detect.toml +1 -1
  36. package/governance/ledger/tooling/ci-workflow.md +6 -0
  37. package/governance/ledger/ui/design-reference.md +8 -12
  38. package/governance/rules/tooling/ci-workflow.md +4 -0
  39. package/governance/rules/ui/design-reference.md +4 -6
  40. package/package.json +2 -1
  41. package/scripts/ci/check-browsers.sh +13 -0
  42. package/scripts/ci/delete-previews.sh +34 -0
  43. package/scripts/ci/publish-check.sh +29 -0
  44. package/scripts/ci/refresh-frames-pr.sh +63 -0
  45. package/scripts/ci/report-baseline.sh +23 -0
  46. package/scripts/ci/stack-issues.sh +24 -0
  47. package/scripts/ci/verify-npm-token.sh +14 -0
  48. package/src/claude/cases/misc.ts +5 -0
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli.ts +2 -0
  51. package/src/commands/changelog.ts +146 -0
  52. package/src/commands/claude.ts +26 -1
  53. package/src/commands/gate.ts +57 -16
  54. package/src/commands/hooks.ts +7 -5
  55. package/src/commands/markdown.ts +98 -19
  56. package/src/commands/migrate.ts +77 -0
  57. package/src/commands/worktrees.ts +273 -0
  58. package/src/context/audit.ts +6 -5
  59. package/src/design/budget.ts +1 -1
  60. package/src/design/components.ts +1 -1
  61. package/src/design/document.ts +1 -1
  62. package/src/design/regen.ts +1 -1
  63. package/src/design/render.ts +1 -1
  64. package/src/gate/hand-suite.ts +52 -0
  65. package/src/gate/measures.ts +9 -2
  66. package/src/gate/sequencer.ts +17 -8
  67. package/src/gov/detect.ts +28 -10
  68. package/src/hooks/post-merge.ts +45 -6
  69. package/src/markdown/gated.ts +5 -7
  70. package/src/markdown/scope.ts +60 -10
  71. package/src/markdown/structure.ts +1 -1
  72. package/src/markdown/tables.ts +150 -0
  73. package/src/migrate/status.ts +128 -0
  74. package/src/tasks/validate.ts +15 -12
  75. package/src/version/changelog.ts +156 -0
  76. package/src/worktrees/members.ts +540 -0
  77. package/tooling/base/configs/.husky/post-merge +7 -6
  78. package/tooling/claude/reference.md +1 -0
  79. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  80. package/tooling/claude/seeds/CLAUDE.md +9 -0
  81. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  82. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  83. package/tooling/cloudflare/reference.md +7 -4
  84. package/tooling/web/reference.md +5 -0
  85. package/tooling/web/seeds/canon/design/index.md +23 -0
  86. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -39,6 +39,7 @@ export interface PostMergeInput {
39
39
  export const STEP_VERBS = {
40
40
  archive: ['tasks', 'archive'],
41
41
  push: ['records', 'push'],
42
+ members: ['worktrees', 'retire-members'],
42
43
  reclaim: ['worktrees', 'reclaim'],
43
44
  upgrade: ['upgrade'],
44
45
  pluginUpdate: ['claude', 'plugin-update'],
@@ -225,6 +226,33 @@ function reclaimLines(fields: Fields): string | undefined {
225
226
  return lines.length > 0 ? lines : undefined
226
227
  }
227
228
 
229
+ /**
230
+ * Keyed on the fields, like the reclaim, since a run that retires one member
231
+ * and fails on another exits 1 with a branch already deleted. A body naming no
232
+ * members is every merge but an integrator's, so it stays quiet.
233
+ */
234
+ function memberLines(number: number, fields: Fields): string | undefined {
235
+ let lines = ''
236
+ const retired = count(fields, 'retired')
237
+ if (retired > 0) {
238
+ lines += `\n🌿 Retired ${retired} fan-in member(s) of #${number}.\n\n`
239
+ }
240
+
241
+ const hint = `Run canon worktrees retire-members --pull-request ${number} --dry-run to read why.\n\n`
242
+ const reason = text(fields, 'reason')
243
+ const unfinished = count(fields, 'kept') + count(fields, 'failed')
244
+ if (
245
+ reason !== undefined &&
246
+ reason !== 'no-members' &&
247
+ reason !== 'gh-missing'
248
+ ) {
249
+ lines += `\n🌿 Fan-in members of #${number} not retired: ${reason}\n${hint}`
250
+ } else if (unfinished > 0) {
251
+ lines += `\n🌿 ${unfinished} fan-in member(s) of #${number} kept or not fully deleted.\n${hint}`
252
+ }
253
+ return lines.length > 0 ? lines : undefined
254
+ }
255
+
228
256
  /**
229
257
  * Shared by the binary reinstall and the plugin update. `current` is the
230
258
  * ordinary outcome on most merges and stays quiet, and `quiet` names the
@@ -271,8 +299,10 @@ async function step(
271
299
  }
272
300
 
273
301
  /**
274
- * Runs the five post-merge steps in order: archive, records push, reclaim,
275
- * upgrade, plugin update. The reclaim sits before the upgrade, since the
302
+ * Runs the six post-merge steps in order: archive, records push, member
303
+ * retirement, reclaim, upgrade, plugin update. The member retirement sits
304
+ * before the reclaim, so the reclaim reads a retired member's worktree as
305
+ * gone rather than racing it. Both sit before the upgrade, since the
276
306
  * upgrade reinstalls the binary every later child runs under, and the two
277
307
  * network-bound moves sit last so a slow registry delays nothing else.
278
308
  *
@@ -309,11 +339,20 @@ export async function runPostMerge(input: PostMergeInput): Promise<void> {
309
339
  pushLines,
310
340
  )
311
341
 
312
- // No `--root` here, and that is load-bearing. The verb reads its own working
313
- // directory to refuse the worktree it stands in, so a root would turn the
314
- // running worktree into an ordinary candidate and let a pull inside a
315
- // linked worktree delete the ground under itself.
342
+ // No `--root` on either worktree step, and that is load-bearing. Each verb
343
+ // reads its own working directory to refuse the worktree it stands in, so a
344
+ // root would turn the running worktree into an ordinary candidate and let a
345
+ // pull inside a linked worktree delete the ground under itself. One switch
346
+ // covers both, since both remove worktrees.
316
347
  if (!isSkipped('CANON_SKIP_RECLAIM')) {
348
+ for (const number of pullRequestNumbers(input.subjects)) {
349
+ await step(
350
+ input,
351
+ at([...STEP_VERBS.members, '--pull-request', String(number), '--json']),
352
+ (_, fields) => memberLines(number, fields),
353
+ )
354
+ }
355
+
317
356
  await step(input, at([...STEP_VERBS.reclaim, '--json']), (_, fields) =>
318
357
  reclaimLines(fields),
319
358
  )
@@ -9,7 +9,7 @@ import {
9
9
  import { readFile } from 'node:fs/promises'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { listRepositoryFiles } from '@/git/files'
12
- import { densityFindings, type DensityKind } from '@/markdown/density'
12
+ import type { DensityKind } from '@/markdown/density'
13
13
  import { referenceShapeGates } from '@/markdown/gate'
14
14
  import {
15
15
  type ReferenceShapeKind,
@@ -20,7 +20,7 @@ import {
20
20
  densityGates,
21
21
  GATED_PATHS,
22
22
  GATED_SCOPE_ROOTS,
23
- inDensityScope,
23
+ scopedDensityFindings,
24
24
  } from '@/markdown/scope'
25
25
  import { isOwnCheckout } from '@/roots/project'
26
26
 
@@ -198,11 +198,9 @@ export async function findingsUnder(
198
198
  const found = await Promise.all(
199
199
  under.map(async (rel) => {
200
200
  const source = await readFile(join(root, rel), 'utf8')
201
- const density = inDensityScope(rel)
202
- ? densityFindings(bodyLines(source)).filter((finding) =>
203
- densityGates(rel, finding.kind, [prefix], false),
204
- )
205
- : []
201
+ const density = scopedDensityFindings(rel, bodyLines(source)).filter(
202
+ (finding) => densityGates(rel, finding.kind, [prefix], false),
203
+ )
206
204
  const shape = referenceShapeGates(rel, [prefix])
207
205
  ? referenceShapeFindings(source)
208
206
  : []
@@ -1,4 +1,9 @@
1
- import type { DensityKind } from '@/markdown/density'
1
+ import {
2
+ type DensityFinding,
3
+ type DensityKind,
4
+ densityFindings,
5
+ } from '@/markdown/density'
6
+ import type { BodyLine } from '@/markdown/scan'
2
7
 
3
8
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
4
9
  const BULLET_RUN_SCOPE = [
@@ -21,11 +26,12 @@ const REQUIREMENT_SCOPE =
21
26
  * pages, the skill bodies, references, and requirements of both skill trees,
22
27
  * the tooling seeds and stack references, the wiki, and the two root guides.
23
28
  *
24
- * Rules stay out, since a rule is one imperative per bullet. So do
25
- * `CLAUDE.md`, the project requirement and design records, a seed carrying one
26
- * of those three names, the changelog, the examples, and every `.canon/`
27
- * record, each with a shape and cap of its own. Named here and nowhere else, so
28
- * the audit, the gate stage reading its exit, and their tests read one scope.
29
+ * Rules stay out, since a rule is one imperative per bullet, and so does every
30
+ * `CLAUDE.md` section but Key paths, which `KEY_PATHS_SCOPE` holds. So do the
31
+ * project requirement and design records, a seed carrying one of those three
32
+ * names, the changelog, the examples, and every `.canon/` record, each with a
33
+ * shape and cap of its own. Named here and nowhere else, so the audit, the gate
34
+ * stage reading its exit, and their tests read one scope.
29
35
  */
30
36
  const DENSITY_SCOPE = [
31
37
  ...BULLET_RUN_SCOPE,
@@ -36,10 +42,49 @@ const DENSITY_SCOPE = [
36
42
  /^(?:README|CONTRIBUTING)\.md$/,
37
43
  ]
38
44
 
45
+ /**
46
+ * The root `CLAUDE.md` and its seed, measured inside `## Key paths` alone. That
47
+ * section is a catalog rather than rules, so it takes the run limit and the H3
48
+ * opening a grouped catalog needs, and nothing else a rule would breach.
49
+ */
50
+ const KEY_PATHS_SCOPE = /^(?:tooling\/claude\/seeds\/)?CLAUDE\.md$/
51
+ const KEY_PATHS_HEADING = /^## Key paths\s*$/
52
+ const KEY_PATHS_KINDS: readonly DensityKind[] = ['bullet-run', 'h3-opening']
53
+
54
+ /** The lines under `## Key paths` up to the next H1 or H2, keeping their numbers. */
55
+ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
56
+ const start = lines.findIndex(
57
+ (line) => !line.fenced && KEY_PATHS_HEADING.test(line.text),
58
+ )
59
+ if (start === -1) return []
60
+ const rest = lines.slice(start + 1)
61
+ const end = rest.findIndex(
62
+ (line) => !line.fenced && /^#{1,2}\s/.test(line.text),
63
+ )
64
+ return end === -1 ? rest : rest.slice(0, end)
65
+ }
66
+
67
+ /**
68
+ * The density findings a path takes, being every finding on a density-scope
69
+ * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
+ * and `canon claude sync` all read this, so the three agree on what counts.
71
+ */
72
+ export function scopedDensityFindings(
73
+ rel: string,
74
+ lines: readonly BodyLine[],
75
+ ): DensityFinding[] {
76
+ if (inDensityScope(rel)) return densityFindings(lines)
77
+ if (!KEY_PATHS_SCOPE.test(rel)) return []
78
+ return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
+ KEY_PATHS_KINDS.includes(finding.kind),
80
+ )
81
+ }
82
+
39
83
  /**
40
84
  * The roots `canon init` gates in a new project when each passes at init time:
41
- * every density surface a target authors itself, leaving out the toolkit's own
42
- * `claude/skills/` and `tooling/` trees.
85
+ * every density surface a target authors itself and the Key paths of its
86
+ * `CLAUDE.md`, leaving out the toolkit's own `claude/skills/` and `tooling/`
87
+ * trees.
43
88
  */
44
89
  export const GATED_SCOPE_ROOTS: readonly string[] = [
45
90
  'canon/context/',
@@ -48,6 +93,7 @@ export const GATED_SCOPE_ROOTS: readonly string[] = [
48
93
  'wiki/',
49
94
  'README.md',
50
95
  'CONTRIBUTING.md',
96
+ 'CLAUDE.md',
51
97
  ]
52
98
 
53
99
  /**
@@ -93,7 +139,6 @@ export const GATED_PATHS: readonly string[] = [
93
139
  'claude/skills/canvas-board/REQUIREMENT.md',
94
140
  'claude/skills/canvas-board/SKILL.md',
95
141
  'claude/skills/canvas-board/references/canvas.md',
96
- 'claude/skills/canvas-board/references/design.md',
97
142
  'claude/skills/ci-workflow/REQUIREMENT.md',
98
143
  'claude/skills/ci-workflow/SKILL.md',
99
144
  'claude/skills/ci-workflow/references/workflows.md',
@@ -133,6 +178,10 @@ export const GATED_PATHS: readonly string[] = [
133
178
  'claude/skills/deprecation-migration/REQUIREMENT.md',
134
179
  'claude/skills/deprecation-migration/SKILL.md',
135
180
  'claude/skills/deprecation-migration/references/adopted.md',
181
+ 'claude/skills/design-record/REQUIREMENT.md',
182
+ 'claude/skills/design-record/SKILL.md',
183
+ 'claude/skills/design-record/references/design.md',
184
+ 'claude/skills/design-record/references/folder.md',
136
185
  'claude/skills/design-taste/REQUIREMENT.md',
137
186
  'claude/skills/design-taste/SKILL.md',
138
187
  'claude/skills/design-taste/references/adopted.md',
@@ -394,6 +443,7 @@ export const GATED_PATHS: readonly string[] = [
394
443
  'wiki/',
395
444
  'README.md',
396
445
  'CONTRIBUTING.md',
446
+ 'CLAUDE.md',
397
447
  ]
398
448
 
399
449
  /**
@@ -425,7 +475,7 @@ export function densityGates(
425
475
  gated: readonly string[] = GATED_PATHS,
426
476
  alwaysGated = true,
427
477
  ): boolean {
428
- if (!inDensityScope(rel)) return false
478
+ if (!inDensityScope(rel) && !KEY_PATHS_SCOPE.test(rel)) return false
429
479
  if (REQUIREMENT_SCOPE.test(rel)) return gated.includes(rel)
430
480
  if (
431
481
  alwaysGated &&
@@ -67,7 +67,7 @@ const MARKER_WIDTH = 20
67
67
 
68
68
  export const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
69
69
  export const TABLE_ROW = /^\s*\|/
70
- const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
70
+ export const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
71
71
  export const BLOCKQUOTE = /^\s*>/
72
72
 
73
73
  /**
@@ -0,0 +1,150 @@
1
+ import { type BodyLine, visibleText } from '@/markdown/scan'
2
+ import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
+
4
+ /** The most visible characters one table cell may hold. */
5
+ export const TABLE_CELL = 120
6
+
7
+ /** The most body rows a table may carry before it splits into groups. */
8
+ export const TABLE_ROWS = 8
9
+
10
+ /** The longest unbroken run a code span in a table cell may carry. */
11
+ export const TABLE_TOKEN = 30
12
+
13
+ export type TableKind =
14
+ | 'table-cell'
15
+ | 'table-rows'
16
+ | 'table-then-list'
17
+ | 'table-token'
18
+
19
+ export interface TableFinding {
20
+ readonly kind: TableKind
21
+ /** The row holding the cell or span, the header for a row count, or the list's first item. */
22
+ 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
26
+ }
27
+
28
+ /**
29
+ * The body of every code span in a cell, read after link destinations are
30
+ * dropped, since only a link's label renders.
31
+ */
32
+ export function codeSpans(cell: string): string[] {
33
+ return (cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []).map(
34
+ (span) => span.slice(1, -1),
35
+ )
36
+ }
37
+
38
+ /**
39
+ * The longest whitespace-free run in a code span. A preview also breaks a span
40
+ * after a hyphen or a slash, but a run this long still splits mid-token or
41
+ * widens its column, so whitespace is the break this counts.
42
+ */
43
+ export function unbrokenRun(span: string): number {
44
+ return Math.max(...span.split(/\s+/).map((part) => part.length))
45
+ }
46
+
47
+ /** Splits a row on its unescaped pipes, dropping the outer two. */
48
+ function cellsOf(row: string): string[] {
49
+ return row
50
+ .trim()
51
+ .replace(/^\|/, '')
52
+ .replace(/(?<!\\)\|$/, '')
53
+ .split(/(?<!\\)\|/)
54
+ .map((cell) => cell.trim().replaceAll('\\|', '|'))
55
+ }
56
+
57
+ /**
58
+ * Every breach of the four table limits, in line order.
59
+ *
60
+ * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
+ * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
+ * fenced line is an example, so neither is measured. The limits are a
63
+ * 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.
65
+ */
66
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
+ const found: TableFinding[] = []
68
+ let index = 0
69
+
70
+ while (index < lines.length) {
71
+ const header = lines[index]
72
+ const delimiter = lines[index + 1]
73
+ if (
74
+ header.fenced ||
75
+ !TABLE_ROW.test(header.text) ||
76
+ delimiter === undefined ||
77
+ delimiter.fenced ||
78
+ !TABLE_SEPARATOR.test(delimiter.text)
79
+ ) {
80
+ index++
81
+ continue
82
+ }
83
+
84
+ const tableRows = [header]
85
+ let end = index + 2
86
+ while (
87
+ end < lines.length &&
88
+ !lines[end].fenced &&
89
+ TABLE_ROW.test(lines[end].text)
90
+ ) {
91
+ tableRows.push(lines[end])
92
+ end++
93
+ }
94
+
95
+ for (const row of tableRows) {
96
+ for (const cell of cellsOf(row.text)) {
97
+ const length = visibleText(cell).length
98
+ if (length > TABLE_CELL) {
99
+ found.push({
100
+ kind: 'table-cell',
101
+ line: row.number,
102
+ value: length,
103
+ limit: TABLE_CELL,
104
+ })
105
+ }
106
+ for (const span of codeSpans(cell)) {
107
+ const run = unbrokenRun(span)
108
+ if (run > TABLE_TOKEN) {
109
+ found.push({
110
+ kind: 'table-token',
111
+ line: row.number,
112
+ value: run,
113
+ limit: TABLE_TOKEN,
114
+ })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ const bodyRows = tableRows.length - 1
121
+ if (bodyRows > TABLE_ROWS) {
122
+ found.push({
123
+ kind: 'table-rows',
124
+ line: header.number,
125
+ value: bodyRows,
126
+ limit: TABLE_ROWS,
127
+ })
128
+ }
129
+
130
+ let next = end
131
+ while (next < lines.length && lines[next].text.trim() === '') next++
132
+ const following = lines[next]
133
+ if (
134
+ following !== undefined &&
135
+ !following.fenced &&
136
+ LIST_ITEM.test(following.text)
137
+ ) {
138
+ found.push({
139
+ kind: 'table-then-list',
140
+ line: following.number,
141
+ value: null,
142
+ limit: null,
143
+ })
144
+ }
145
+
146
+ index = end
147
+ }
148
+
149
+ return found.sort((a, b) => a.line - b.line)
150
+ }
@@ -0,0 +1,128 @@
1
+ import type { RunCommand } from '@/gate/measures'
2
+
3
+ interface Migration {
4
+ readonly name: string
5
+ readonly flags: readonly string[]
6
+ }
7
+
8
+ /**
9
+ * Every `canon migrate` subcommand in the order a project runs them, records
10
+ * first. This is the source for that order, and `docs/target/migrations.md`
11
+ * cites the verb rather than keeping a second list. A new migration joins by
12
+ * one line here.
13
+ */
14
+ export const MIGRATIONS: readonly Migration[] = [
15
+ { name: 'records', flags: [] },
16
+ { name: 'record-tree', flags: [] },
17
+ { name: 'scratch-evidence', flags: [] },
18
+ { name: 'record-layout', flags: [] },
19
+ { name: 'rule-layout', flags: [] },
20
+ { name: 'rename', flags: ['--scope', 'target'] },
21
+ { name: 'skill-names', flags: [] },
22
+ { name: 'surface-roots', flags: [] },
23
+ { name: 'session-defaults', flags: [] },
24
+ ]
25
+
26
+ export type MigrationState = 'pending' | 'clean' | 'refused'
27
+
28
+ export interface MigrationStatus {
29
+ readonly name: string
30
+ readonly verb: string
31
+ readonly state: MigrationState
32
+ /** The subcommand's own `--json` record, carried through unread. */
33
+ readonly record?: unknown
34
+ readonly reason?: string
35
+ }
36
+
37
+ export interface StatusReport {
38
+ readonly migrations: readonly MigrationStatus[]
39
+ readonly pending: number
40
+ readonly refused: number
41
+ }
42
+
43
+ function parseRecord(stdout: string): unknown {
44
+ try {
45
+ return JSON.parse(stdout)
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ const FRAME = /^[\s│├┌└]+/
52
+
53
+ /**
54
+ * A refusing subcommand writes its stderr inside the CLI's frame, so the raw
55
+ * text would carry the glyphs into a record a skill reads as data. The error
56
+ * lines are the reason, and a stderr without a marker keeps every line.
57
+ */
58
+ function refusalReason(stderr: string): string {
59
+ const lines = stderr.split('\n').map((line) => line.replace(FRAME, '').trim())
60
+ const errors = lines
61
+ .filter((line) => line.startsWith('✗'))
62
+ .map((line) => line.replace(/^✗\s*/, ''))
63
+ const kept = errors.length > 0 ? errors : lines.filter(Boolean)
64
+ return kept.map((line) => line.replace(/\.+$/, '')).join('. ')
65
+ }
66
+
67
+ async function readOne(
68
+ run: RunCommand,
69
+ migration: Migration,
70
+ ): Promise<MigrationStatus> {
71
+ const verb = `canon migrate ${migration.name}`
72
+ const result = await run([
73
+ 'migrate',
74
+ migration.name,
75
+ ...migration.flags,
76
+ '--json',
77
+ ])
78
+
79
+ if (result.exitCode === 0) {
80
+ return { name: migration.name, verb, state: 'clean' }
81
+ }
82
+
83
+ if (result.exitCode === 2) {
84
+ return {
85
+ name: migration.name,
86
+ verb,
87
+ state: 'pending',
88
+ record: parseRecord(result.stdout),
89
+ }
90
+ }
91
+
92
+ const reason =
93
+ result.spawnError ??
94
+ (refusalReason(result.stderr) ||
95
+ `exited ${result.exitCode} with no message`)
96
+ return {
97
+ name: migration.name,
98
+ verb,
99
+ state: 'refused',
100
+ record: parseRecord(result.stdout),
101
+ reason,
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Runs each migration's own dry run and sorts it by the exit contract every
107
+ * subcommand shares: 0 with nothing to move, 2 with a plan and no `--write`,
108
+ * anything else a refusal. It never passes `--write`, and it reads no count,
109
+ * since the records share no field to count from.
110
+ */
111
+ export async function readMigrationStatus(
112
+ run: RunCommand,
113
+ ): Promise<StatusReport> {
114
+ const migrations = await Promise.all(
115
+ MIGRATIONS.map((migration) => readOne(run, migration)),
116
+ )
117
+ return {
118
+ migrations,
119
+ pending: migrations.filter((m) => m.state === 'pending').length,
120
+ refused: migrations.filter((m) => m.state === 'refused').length,
121
+ }
122
+ }
123
+
124
+ /** A refusal outranks a pending plan, since a refused read measured nothing. */
125
+ export function statusExitCode(report: StatusReport): 0 | 1 | 2 {
126
+ if (report.refused > 0) return 1
127
+ return report.pending > 0 ? 2 : 0
128
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { readdir, readFile } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
+ import { codeSpans, unbrokenRun } from '@/markdown/tables'
4
5
  import {
5
6
  archiveDir,
6
7
  declinedDir,
@@ -1084,28 +1085,30 @@ function checkFolderClaims(
1084
1085
  /**
1085
1086
  * The widest the longest unbroken run in a code span may be before a table
1086
1087
  * scrolls in the preview pane. A 57-character span fit and a 78-character one
1087
- * did not, so the limit sits at the nearest round number above the first.
1088
+ * did not, so the limit sits at the nearest round number above the first. It
1089
+ * is still too high: a 36-character path with no hyphen widened a board column
1090
+ * until the next one ran off the pane, which is why a table cell reads the same
1091
+ * measure at 30. The board keeps 60 until its own report is revisited.
1088
1092
  */
1089
1093
  const WIDE_LIMIT = 60
1090
1094
 
1091
1095
  /**
1092
- * Reports a code span a table cell cannot wrap. A span breaks only at
1093
- * whitespace, so the longest whitespace-free run is what is measured, and a
1094
- * link target is stripped first because only its label renders. Every group is
1095
- * scanned, since the overflow this exists for happened under `## Up next`,
1096
- * where the folder-claim scan would not have looked. Prose breaks at spaces
1097
- * and bare words at hyphens, so only a span is read.
1096
+ * Reports a code span a table cell cannot wrap, measured by `unbrokenRun`,
1097
+ * which `canon markdown audit` reads for a table too. A rendered preview breaks
1098
+ * a span at whitespace and after a hyphen or a slash, so a long run either
1099
+ * widens its column or splits mid-token. Every group is scanned, since the
1100
+ * overflow this exists for happened under `## Up next`, where the folder-claim
1101
+ * scan would not have looked. Prose breaks at spaces and bare words at hyphens,
1102
+ * so only a span is read.
1098
1103
  */
1099
1104
  function checkWideTokens(rows: readonly BoardRow[]): WideToken[] {
1100
1105
  const wide: WideToken[] = []
1101
1106
 
1102
1107
  for (const row of rows) {
1103
1108
  for (const cell of row.cells) {
1104
- const spans = cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []
1105
-
1106
- for (const span of spans) {
1107
- const text = span.slice(1, -1)
1108
- const run = Math.max(...text.split(/\s+/).map((part) => part.length))
1109
+ for (const text of codeSpans(cell)) {
1110
+ const span = `\`${text}\``
1111
+ const run = unbrokenRun(text)
1109
1112
 
1110
1113
  if (/\{[^}]*,[^}]*\}/.test(text)) {
1111
1114
  wide.push({