@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,11 @@ export const MISC_CASES: readonly SkillCase[] = [
39
39
  'Draw the signup screen as a frame on the canvas, then restyle the element I just selected there.',
40
40
  expect: 'canvas-board',
41
41
  },
42
+ {
43
+ prompt:
44
+ 'The operator picked the new settings panel, so land it in canon/design with an index row for it.',
45
+ expect: 'design-record',
46
+ },
42
47
  {
43
48
  prompt:
44
49
  "We're about to hit the context limit, write the handoff before we lose state.",
package/src/cli/help.ts CHANGED
@@ -20,6 +20,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
20
20
  ['sync [path]', 'Sync all installed domains in a project'],
21
21
  ['upgrade', 'Reinstall the CLI with the manager that installed it'],
22
22
  ['migrate [cmd]', 'Move a project off a retired name or layout'],
23
+ ['changelog', 'Read the releases between a stamp and this binary'],
23
24
  ['targets [cmd]', 'Projects this toolkit installed into'],
24
25
  ],
25
26
  },
package/src/cli.ts CHANGED
@@ -40,6 +40,7 @@ import { register as census } from '@/commands/census'
40
40
  import { register as upstream } from '@/commands/upstream'
41
41
  import { register as targets } from '@/commands/targets'
42
42
  import { register as upgrade } from '@/commands/upgrade'
43
+ import { register as changelog } from '@/commands/changelog'
43
44
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
44
45
  import { renderHelp } from '@/cli/help'
45
46
  import { renderAll, routeHelp } from '@/cli/help-all'
@@ -100,6 +101,7 @@ upstream(program)
100
101
  audits(program)
101
102
  gate(program)
102
103
  upgrade(program)
104
+ changelog(program)
103
105
 
104
106
  const route = routeHelp(program, process.argv.slice(2))
105
107
  if (route.kind === 'top') showHelp()
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { PROJECT_ROOT } from '@/roots/project'
5
+ import { readStamp } from '@/sync/stamp'
6
+ import {
7
+ intro,
8
+ logInfo,
9
+ logStep,
10
+ logWarn,
11
+ outro,
12
+ pipeOutput,
13
+ plural,
14
+ } from '@/cli/ui'
15
+ import { type ChangelogReport, resolveChangelog } from '@/version/changelog'
16
+ import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
17
+
18
+ interface ChangelogOptions {
19
+ readonly since?: string
20
+ readonly root?: string
21
+ readonly json?: boolean
22
+ }
23
+
24
+ const REFUSALS = {
25
+ 'no-stamp':
26
+ 'the target carries no stamped version and --since named none, so there is no start to range from',
27
+ 'no-changelog':
28
+ 'the installed package carries no CHANGELOG.md with release entries to read',
29
+ 'unknown-version':
30
+ 'the changelog lists no such release, so the range would be a guess',
31
+ } as const
32
+
33
+ const HELP = [
34
+ '',
35
+ 'Prints the release entries after a version, up to the running one, from the',
36
+ 'CHANGELOG.md the installed package ships. It reads no registry and needs no',
37
+ '`gh`, so it answers offline.',
38
+ '',
39
+ 'The start defaults to the oldest `version` across the target stamp domains,',
40
+ 'since the oldest domain is the one furthest behind. --since overrides it.',
41
+ '',
42
+ 'Exit codes:',
43
+ ' 0 the range was read, empty when the target is already current',
44
+ ' 1 refused, with the reason on stderr',
45
+ '',
46
+ 'A refusal names a "reason" rather than printing an empty range. no-stamp',
47
+ 'means no start was recorded or given, no-changelog means the file or its',
48
+ 'release headings are missing, and unknown-version means the start or the',
49
+ 'running version is absent from the file, which covers a pre-release and a',
50
+ 'start newer than the binary.',
51
+ '',
52
+ 'The JSON carries "since", "running", and "entries", each an object with the',
53
+ 'release "version" and its raw "markdown". Read the record rather than the',
54
+ 'exit when a skill consumes this, since a shell function wrapping the binary',
55
+ 'may flatten the status.',
56
+ '',
57
+ 'Examples:',
58
+ ' canon changelog',
59
+ ' canon changelog --since 5.47.0 --json',
60
+ ' canon changelog --root ../frameboard',
61
+ '',
62
+ ].join('\n')
63
+
64
+ export function register(program: Command): void {
65
+ program
66
+ .command('changelog')
67
+ .description(
68
+ 'Print the release entries after a version up to the running one',
69
+ )
70
+ .helpOption('-h, --help', 'Show this help message')
71
+ .option(
72
+ '--since <version>',
73
+ 'Start after this version instead of the stamp',
74
+ )
75
+ .option('--root <path>', 'Target whose stamp sets the start', '.')
76
+ .option('--json', 'Add a machine-readable record on stdout')
77
+ .addHelpText('after', HELP)
78
+ .action((opts: ChangelogOptions) => {
79
+ process.exitCode = runChangelog(opts)
80
+ })
81
+ }
82
+
83
+ function readChangelogText(): string | undefined {
84
+ const path = join(PROJECT_ROOT, 'CHANGELOG.md')
85
+
86
+ return existsSync(path) ? readFileSync(path, 'utf8') : undefined
87
+ }
88
+
89
+ function stampedVersions(root: string): string[] {
90
+ const stamp = readStamp(root)
91
+ if (stamp === undefined) return []
92
+
93
+ return Object.values(stamp.domains)
94
+ .map((domain) => domain?.version)
95
+ .filter((version): version is string => version !== undefined)
96
+ }
97
+
98
+ function runChangelog(opts: ChangelogOptions): number {
99
+ intro('canon changelog')
100
+
101
+ const root = resolve(opts.root ?? '.')
102
+ const report = resolveChangelog({
103
+ text: readChangelogText(),
104
+ running: readInstalled().version ?? UNKNOWN_LABEL,
105
+ since: opts.since,
106
+ stamped: stampedVersions(root),
107
+ })
108
+
109
+ if (report.kind === 'range') {
110
+ reportRange(report)
111
+ } else {
112
+ reportRefusal(report)
113
+ }
114
+ outro()
115
+
116
+ if (opts.json) {
117
+ process.stdout.write(`${JSON.stringify(report)}\n`)
118
+ }
119
+
120
+ return report.kind === 'range' ? 0 : 1
121
+ }
122
+
123
+ function reportRange(
124
+ report: Extract<ChangelogReport, { kind: 'range' }>,
125
+ ): void {
126
+ logStep('Range')
127
+ logInfo(`${report.since} to ${report.running}`)
128
+
129
+ if (report.entries.length === 0) {
130
+ logInfo('No release after the start, so nothing is pending')
131
+ return
132
+ }
133
+
134
+ logStep(plural(report.entries.length, 'release'))
135
+ for (const entry of report.entries) {
136
+ pipeOutput(entry.markdown)
137
+ }
138
+ }
139
+
140
+ function reportRefusal(
141
+ report: Extract<ChangelogReport, { kind: 'refused' }>,
142
+ ): void {
143
+ logStep('Refused')
144
+ const version = report.version === undefined ? '' : ` (${report.version})`
145
+ logWarn(`${report.reason}${version}: ${REFUSALS[report.reason]}`)
146
+ }
@@ -71,6 +71,9 @@ import {
71
71
  serializeSettings,
72
72
  writeSettings,
73
73
  } from '@/claude/settings'
74
+ import { bodyLines } from '@/markdown/scan'
75
+ import { scopedDensityFindings } from '@/markdown/scope'
76
+ import { CAP } from '@/markdown/stacks'
74
77
  import { readSessionDefaults } from '@/migrate/session-defaults'
75
78
  import { copyPreservingMode } from '@/sync/copy'
76
79
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
@@ -176,6 +179,22 @@ export function seededDirPath(resolved: string, name: string): string {
176
179
  ? surfaceDir(resolved, name)
177
180
  : recordDir(resolved, name)
178
181
  }
182
+
183
+ /**
184
+ * Each bullet run past the limit in the target's `CLAUDE.md` Key paths, read
185
+ * through the scope the audit gates on. Sync warns rather than gates, since a
186
+ * target gates only the prefixes its own config lists.
187
+ */
188
+ export async function longKeyPathsRuns(
189
+ resolved: string,
190
+ ): Promise<{ line: number; count: number }[]> {
191
+ const path = join(resolved, 'CLAUDE.md')
192
+ if (!existsSync(path)) return []
193
+ const lines = bodyLines(await readFile(path, 'utf8'))
194
+ return scopedDensityFindings('CLAUDE.md', lines)
195
+ .filter((finding) => finding.kind === 'bullet-run')
196
+ .map((finding) => ({ line: finding.line, count: finding.value ?? 0 }))
197
+ }
179
198
  const USER_DIR = join('tooling', 'claude', 'user')
180
199
  const STATUSLINE = 'statusline-command.sh'
181
200
  const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
@@ -244,7 +263,8 @@ export function register(program: Command): void {
244
263
  'Writes .gitignore alone: adds the entries the claude manifest lists',
245
264
  'and drops ones under its headers that the manifest no longer lists.',
246
265
  'A seeded file is reported, never rewritten, and a missing one points',
247
- 'at `canon claude init`.',
266
+ 'at `canon claude init`. A CLAUDE.md Key paths running past 5 bullets',
267
+ 'is reported with the H3 grouping that fixes it.',
248
268
  '',
249
269
  'Exit codes:',
250
270
  ' 0 synced, already up to date, or the prompt was cancelled',
@@ -665,6 +685,11 @@ async function runSync(target: string): Promise<number> {
665
685
  if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
666
686
  else logWarn(`${name}/ missing. Run \`canon claude init\``)
667
687
  }
688
+ for (const run of await longKeyPathsRuns(resolved)) {
689
+ logWarn(
690
+ `CLAUDE.md:${run.line} runs ${run.count} Key paths bullets past ${CAP}. Group them under H3s, each opening on a lead sentence`,
691
+ )
692
+ }
668
693
 
669
694
  // Sync never rewrites an existing settings file, so a target seeded before
670
695
  // the session defaults learns of them only here.
@@ -130,22 +130,9 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
130
130
  process.stderr.write(`${failed.label}: ${failed.failure}\n`)
131
131
  }
132
132
  process.stdout.write(
133
- `${JSON.stringify({
134
- ok: code === 0,
135
- root,
136
- scoped: changed.scoped,
137
- changed: changed.files.length,
138
- summary,
139
- ms: results.reduce((total, result) => total + result.ms, 0),
140
- stages: results.map(({ id, label, status, failure, ms, queuedMs }) => ({
141
- id,
142
- label,
143
- status,
144
- failure,
145
- ms,
146
- queuedMs,
147
- })),
148
- })}\n`,
133
+ `${JSON.stringify(
134
+ gateRecord({ ok: code === 0, root, changed, summary, results }),
135
+ )}\n`,
149
136
  )
150
137
  return code
151
138
  }
@@ -154,6 +141,60 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
154
141
  return code
155
142
  }
156
143
 
144
+ /** Lines of a failed stage's output the record keeps, counted from the end. */
145
+ const OUTPUT_TAIL_LINES = 200
146
+
147
+ interface GateRecordInput {
148
+ readonly ok: boolean
149
+ readonly root: string
150
+ readonly changed: {
151
+ readonly scoped: boolean
152
+ readonly files: readonly string[]
153
+ }
154
+ readonly summary: Summary
155
+ readonly results: readonly StageResult[]
156
+ }
157
+
158
+ /**
159
+ * The `--json` record. A failed stage carries the tail of the output its
160
+ * checks printed, since a caller reading the record alone otherwise reruns
161
+ * the stage by hand to learn which test failed. The tail rather than a parse
162
+ * of the `FAIL` lines, so a generic stage stays free of one runner's format,
163
+ * and a dot-reporter run prints its failures and summary last.
164
+ */
165
+ export function gateRecord(input: GateRecordInput) {
166
+ const { ok, root, changed, summary, results } = input
167
+ return {
168
+ ok,
169
+ root,
170
+ scoped: changed.scoped,
171
+ changed: changed.files.length,
172
+ summary,
173
+ ms: results.reduce((total, result) => total + result.ms, 0),
174
+ stages: results.map(
175
+ ({ id, label, status, failure, ms, queuedMs, emissions }) => ({
176
+ id,
177
+ label,
178
+ status,
179
+ failure,
180
+ ms,
181
+ queuedMs,
182
+ output: status === 'failed' ? outputTail(emissions) : undefined,
183
+ }),
184
+ ),
185
+ }
186
+ }
187
+
188
+ function outputTail(emissions: StageResult['emissions']): string {
189
+ const lines = emissions
190
+ .filter((emission) => emission.kind === 'output')
191
+ .map((emission) => emission.text.trimEnd())
192
+ .filter((text) => text !== '')
193
+ .join('\n')
194
+ .split('\n')
195
+ return lines.slice(-OUTPUT_TAIL_LINES).join('\n')
196
+ }
197
+
157
198
  function report(result: StageResult): void {
158
199
  logStep(result.label)
159
200
  for (const emission of result.emissions) {
@@ -39,14 +39,16 @@ export function register(program: Command): void {
39
39
  'Runs these steps in order, each a child call reading its --json record:',
40
40
  ...Object.values(STEP_VERBS).map((verb) => ` canon ${verb.join(' ')}`),
41
41
  '',
42
- 'The archive runs once per pull request number the merge brought in.',
43
- 'Lines go to stderr, and an ordinary merge prints nothing.',
42
+ 'The archive and the member retirement run once per pull request',
43
+ 'number the merge brought in. Lines go to stderr, and an ordinary merge',
44
+ 'prints nothing.',
44
45
  '',
45
- 'The reclaim runs from the working directory with no root, so a pull',
46
- 'inside a linked worktree never removes the worktree it stands in.',
46
+ 'The member retirement and the reclaim run from the working directory',
47
+ 'with no root, so a pull inside a linked worktree never removes the',
48
+ 'worktree it stands in.',
47
49
  '',
48
50
  'Environment:',
49
- ' CANON_SKIP_RECLAIM=1 skip the worktree reclaim',
51
+ ' CANON_SKIP_RECLAIM=1 skip the member retirement and the reclaim',
50
52
  ' CANON_SKIP_UPGRADE=1 skip the CLI reinstall',
51
53
  ' CANON_SKIP_PLUGIN_UPDATE=1 skip the plugin update',
52
54
  '',
@@ -6,7 +6,7 @@ import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
6
6
  import { isChangelog, lengthExemption } from '@/markdown/ceiling'
7
7
  import { type ColonFinding, findColons } from '@/markdown/colons'
8
8
  import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
9
- import { densityFindings, type DensityKind } from '@/markdown/density'
9
+ import type { DensityKind } from '@/markdown/density'
10
10
  import {
11
11
  type GatedDensityFinding,
12
12
  type GatedReferenceShapeFinding,
@@ -38,7 +38,19 @@ import {
38
38
  readGatedConfig,
39
39
  resolveGatedList,
40
40
  } from '@/markdown/gated'
41
- import { densityGates, inDensityScope } from '@/markdown/scope'
41
+ import {
42
+ densityGates,
43
+ inDensityScope,
44
+ scopedDensityFindings,
45
+ } from '@/markdown/scope'
46
+ import {
47
+ TABLE_CELL,
48
+ TABLE_ROWS,
49
+ TABLE_TOKEN,
50
+ type TableFinding,
51
+ tableFindings,
52
+ type TableKind,
53
+ } from '@/markdown/tables'
42
54
  import {
43
55
  BASELINE,
44
56
  CHECKPOINTS,
@@ -86,6 +98,8 @@ interface FileReport {
86
98
  readonly headingCase: readonly HeadingCaseFinding[]
87
99
  /** Empty on a path outside the density scope, so a plan never carries one. */
88
100
  readonly density: readonly GatedDensityFinding[]
101
+ /** Empty outside the density scope, and never gating anywhere. */
102
+ readonly tables: readonly TableFinding[]
89
103
  /** Empty on a page whose frontmatter does not carry `kind: reference`. */
90
104
  readonly referenceShape: readonly GatedReferenceShapeFinding[]
91
105
  readonly structure: StructureReport
@@ -131,13 +145,20 @@ export function register(program: Command): void {
131
145
  'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
132
146
  'paragraph or table opening every H3. Only context, docs, skills, tooling',
133
147
  'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
134
- 'finding. In a canon checkout one gates under a prefix in GATED_PATHS in',
135
- 'src/markdown/scope.ts, and a bullet run gates in context, docs, and',
136
- 'skills from the start. Elsewhere one gates only under a prefix',
137
- 'canon/config/markdown.toml lists, which canon markdown gated add writes,',
138
- 'and with no such file every density finding reports and none gates.',
148
+ 'finding. CLAUDE.md and its seed take only the bullet run and the H3',
149
+ 'opening, inside Key paths. In a canon checkout one gates under a',
150
+ 'prefix in GATED_PATHS in src/markdown/scope.ts, and a bullet run gates',
151
+ 'in context, docs, and skills from the start. Elsewhere one gates only',
152
+ 'under a prefix canon/config/markdown.toml lists, which canon markdown',
153
+ 'gated add writes, and with no such file every density finding reports',
154
+ 'and none gates.',
139
155
  'A config that does not parse refuses with its path named.',
140
156
  '',
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.',
161
+ '',
141
162
  'A page whose frontmatter carries kind: reference takes the reference',
142
163
  'shape: no H2 beyond Output, Refusals, and See also, each at most once',
143
164
  'and in that order, no H3, and no table whose first header cell names',
@@ -361,17 +382,16 @@ async function runAudit(
361
382
  bans: scanBans(lines, bans),
362
383
  colons: advisory ? findColons(lines) : [],
363
384
  headingCase: advisory ? findHeadingCase(source) : [],
364
- density: inDensityScope(rel)
365
- ? densityFindings(lines).map((finding) => ({
366
- ...finding,
367
- gating: densityGates(
368
- rel,
369
- finding.kind,
370
- gated.prefixes,
371
- gated.alwaysGated,
372
- ),
373
- }))
374
- : [],
385
+ density: scopedDensityFindings(rel, lines).map((finding) => ({
386
+ ...finding,
387
+ gating: densityGates(
388
+ rel,
389
+ finding.kind,
390
+ gated.prefixes,
391
+ gated.alwaysGated,
392
+ ),
393
+ })),
394
+ tables: inDensityScope(rel) ? tableFindings(lines) : [],
375
395
  referenceShape: referenceShapeFindings(source).map((finding) => ({
376
396
  ...finding,
377
397
  gating: referenceShapeGates(rel, gated.prefixes),
@@ -393,6 +413,7 @@ async function runAudit(
393
413
  reportColons(reports)
394
414
  reportHeadingCase(reports)
395
415
  reportDensity(reports, gated, resolution.source)
416
+ reportTables(reports)
396
417
  reportReferenceShape(reports, gated)
397
418
  reportBullets(reports, checkpoints)
398
419
  reportParagraphs(reports, checkpoints)
@@ -430,6 +451,7 @@ async function runAudit(
430
451
  colons: report.colons,
431
452
  headingCase: report.headingCase,
432
453
  density: report.density,
454
+ tables: report.tables,
433
455
  referenceShape: report.referenceShape,
434
456
  longestRun: report.structure.longestRun,
435
457
  longestRunLine: report.structure.longestRunLine,
@@ -643,7 +665,7 @@ function reportDensity(
643
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.`,
644
666
  )
645
667
  logInfo(
646
- 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
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.',
647
669
  )
648
670
  logInfo(densityGatingLine(gated, source))
649
671
 
@@ -695,6 +717,63 @@ function densityGatingLine(
695
717
  : `A finding gates under a prefix ${GATED_CONFIG_REL} lists (${listed}). Every other finding reports.`
696
718
  }
697
719
 
720
+ const TABLE_LABELS: Record<TableKind, string> = {
721
+ 'table-cell': 'cell length',
722
+ 'table-rows': 'rows',
723
+ 'table-then-list': 'list after table',
724
+ 'table-token': 'wide token',
725
+ }
726
+
727
+ 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}`
733
+ }
734
+
735
+ /**
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.
739
+ */
740
+ function reportTables(reports: readonly FileReport[]): void {
741
+ logStep('Tables')
742
+ 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.`,
744
+ )
745
+ logInfo(
746
+ 'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
747
+ )
748
+
749
+ const carrying = reports
750
+ .filter((report) => report.tables.length > 0)
751
+ .sort((a, b) => b.tables.length - a.tables.length)
752
+
753
+ if (carrying.length === 0) {
754
+ logInfo('No table finding.')
755
+ return
756
+ }
757
+
758
+ const total = carrying.reduce((sum, report) => sum + report.tables.length, 0)
759
+ logWarn(
760
+ `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
761
+ )
762
+ 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.',
764
+ )
765
+ pipeOutput(
766
+ carrying
767
+ .map(
768
+ (report) =>
769
+ `${report.rel} ${plural(report.tables.length, 'finding')}\n${report.tables
770
+ .map(describeTable)
771
+ .join('\n')}`,
772
+ )
773
+ .join('\n'),
774
+ )
775
+ }
776
+
698
777
  const REFERENCE_SHAPE_LABELS: Record<ReferenceShapeKind, string> = {
699
778
  'extra-section': 'section outside the shape',
700
779
  'section-order': 'section out of order or repeated',
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { join, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { cliRunner } from '@/gate/sequencer'
4
5
  import { listRepositoryFiles } from '@/git/files'
5
6
  import { indexSourceRules } from '@/gov/adapter'
6
7
  import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
@@ -35,6 +36,7 @@ import {
35
36
  SKILL_NAME_MAP,
36
37
  SKILL_NAME_RULES,
37
38
  } from '@/migrate/skill-names'
39
+ import { readMigrationStatus, statusExitCode } from '@/migrate/status'
38
40
  import {
39
41
  planSurfaceRootsMove,
40
42
  type SurfaceRootsPlan,
@@ -1039,6 +1041,43 @@ function toSessionDefaultsRecord(
1039
1041
  }
1040
1042
  }
1041
1043
 
1044
+ interface StatusOptions {
1045
+ readonly json?: boolean
1046
+ readonly root?: string
1047
+ }
1048
+
1049
+ /**
1050
+ * Lists every migration a project still needs by running each subcommand's own
1051
+ * dry run, so it reads the contract they share and writes nothing.
1052
+ */
1053
+ async function runStatus(opts: StatusOptions): Promise<number> {
1054
+ const root = resolve(opts.root ?? process.cwd())
1055
+
1056
+ const mismatch = checkoutMismatchWarning(process.cwd())
1057
+ if (mismatch !== undefined) logWarn(mismatch)
1058
+
1059
+ const report = await readMigrationStatus(cliRunner(root))
1060
+
1061
+ if (opts.json) {
1062
+ process.stdout.write(
1063
+ `${JSON.stringify({ ok: report.refused === 0, ...report })}\n`,
1064
+ )
1065
+ }
1066
+
1067
+ for (const entry of report.migrations) {
1068
+ if (entry.state === 'pending') logInfo(`Pending: ${entry.verb}`)
1069
+ if (entry.state === 'refused') {
1070
+ logError(`Refused: ${entry.verb}: ${entry.reason ?? 'no reason given'}`)
1071
+ }
1072
+ }
1073
+
1074
+ if (report.pending === 0 && report.refused === 0) {
1075
+ logInfo('No migration is pending.')
1076
+ }
1077
+
1078
+ return statusExitCode(report)
1079
+ }
1080
+
1042
1081
  export function register(program: Command): void {
1043
1082
  const migrate = program
1044
1083
  .command('migrate')
@@ -1446,4 +1485,42 @@ export function register(program: Command): void {
1446
1485
  .action(async (opts: SessionDefaultsOptions) => {
1447
1486
  process.exitCode = await runSessionDefaults(opts)
1448
1487
  })
1488
+
1489
+ migrate
1490
+ .command('status')
1491
+ .description('List every migration a project still needs, in run order')
1492
+ .helpOption('-h, --help', 'Show this help message')
1493
+ .option('--json', 'Add a machine-readable record on stdout')
1494
+ .option(
1495
+ '--root <path>',
1496
+ 'Project root, defaulting to the working directory',
1497
+ )
1498
+ .addHelpText(
1499
+ 'after',
1500
+ [
1501
+ '',
1502
+ 'Runs each migrate subcommand as a dry run and sorts it by its exit',
1503
+ 'code, in the order a project runs them, records first. It passes',
1504
+ 'no --write and writes nothing. A migration is pending or not, with',
1505
+ "the subcommand's own record attached, since the records share no",
1506
+ 'count field.',
1507
+ '',
1508
+ "Each dry run reads today's tree, so a clean entry can turn pending",
1509
+ 'once an earlier one is applied. Run status again after each --write.',
1510
+ '',
1511
+ 'Exit codes:',
1512
+ ' 0 no migration is pending',
1513
+ ' 1 a subcommand refused, which outranks a pending plan',
1514
+ ' 2 at least one migration is pending',
1515
+ '',
1516
+ 'Examples:',
1517
+ ' canon migrate status',
1518
+ ' canon migrate status --json',
1519
+ ' canon migrate status --root ../project',
1520
+ '',
1521
+ ].join('\n'),
1522
+ )
1523
+ .action(async (opts: StatusOptions) => {
1524
+ process.exitCode = await runStatus(opts)
1525
+ })
1449
1526
  }