@erclx/canon 5.38.0 → 5.40.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 (246) hide show
  1. package/README.md +5 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +12 -9
  4. package/claude/skills/auto-ship/SKILL.md +43 -23
  5. package/claude/skills/backlog-triage/SKILL.md +62 -53
  6. package/claude/skills/backlog-triage/references/apply.md +69 -0
  7. package/claude/skills/build-in-slices/SKILL.md +8 -4
  8. package/claude/skills/canon-cli/SKILL.md +5 -1
  9. package/claude/skills/canon-feedback/SKILL.md +18 -8
  10. package/claude/skills/canon-feedback-triage/SKILL.md +10 -3
  11. package/claude/skills/canvas-board/SKILL.md +37 -18
  12. package/claude/skills/ci-workflow/SKILL.md +5 -4
  13. package/claude/skills/code-craft/SKILL.md +10 -6
  14. package/claude/skills/codebase-layout/SKILL.md +3 -5
  15. package/claude/skills/context-fold/REQUIREMENT.md +4 -4
  16. package/claude/skills/context-fold/SKILL.md +42 -23
  17. package/claude/skills/context-fold/references/context-refresh.md +4 -3
  18. package/claude/skills/context-fold/references/context.md +79 -64
  19. package/claude/skills/create-rule/SKILL.md +7 -3
  20. package/claude/skills/create-skill/SKILL.md +27 -7
  21. package/claude/skills/create-skill/references/skill-reference.md +41 -17
  22. package/claude/skills/decision-escalate/SKILL.md +8 -5
  23. package/claude/skills/deploy-app/SKILL.md +1 -1
  24. package/claude/skills/deprecation-migration/SKILL.md +12 -4
  25. package/claude/skills/design-taste/SKILL.md +17 -11
  26. package/claude/skills/docs-sync/REQUIREMENT.md +1 -0
  27. package/claude/skills/docs-sync/SKILL.md +17 -6
  28. package/claude/skills/docs-sync/references/docs.md +54 -21
  29. package/claude/skills/docs-sync/references/readme.md +32 -8
  30. package/claude/skills/document-health/SKILL.md +3 -4
  31. package/claude/skills/draft-and-pick/SKILL.md +73 -47
  32. package/claude/skills/draft-doc/SKILL.md +9 -4
  33. package/claude/skills/draft-doc/references/context.md +4 -4
  34. package/claude/skills/draft-doc/references/docs.md +1 -0
  35. package/claude/skills/draft-doc/references/wiki.md +3 -2
  36. package/claude/skills/draft-figure/SKILL.md +9 -7
  37. package/claude/skills/draft-identity/SKILL.md +24 -52
  38. package/claude/skills/draft-identity/references/card-route.md +46 -0
  39. package/claude/skills/draft-ready/SKILL.md +19 -7
  40. package/claude/skills/draft-screencast/SKILL.md +6 -112
  41. package/claude/skills/draft-screencast/references/beats-template.md +119 -0
  42. package/claude/skills/draft-slides/SKILL.md +9 -7
  43. package/claude/skills/draft-slides/references/deck.md +183 -0
  44. package/claude/skills/git-branch/SKILL.md +3 -3
  45. package/claude/skills/git-commit/SKILL.md +5 -3
  46. package/claude/skills/git-followup/SKILL.md +16 -9
  47. package/claude/skills/git-issue/SKILL.md +6 -5
  48. package/claude/skills/git-pr/SKILL.md +56 -44
  49. package/claude/skills/git-pr/references/evidence.md +2 -2
  50. package/claude/skills/git-pr/references/final-command.md +1 -1
  51. package/claude/skills/git-pr/references/labels.md +1 -1
  52. package/claude/skills/git-pr/references/publish.md +1 -1
  53. package/claude/skills/git-ship/SKILL.md +43 -34
  54. package/claude/skills/git-ship/references/ci-watch.md +31 -0
  55. package/claude/skills/git-split/SKILL.md +28 -100
  56. package/claude/skills/git-split/references/independent.md +40 -0
  57. package/claude/skills/git-split/references/stacked.md +73 -0
  58. package/claude/skills/git-stage/SKILL.md +9 -7
  59. package/claude/skills/git-worktree/SKILL.md +27 -49
  60. package/claude/skills/git-worktree/references/cleanup.md +55 -0
  61. package/claude/skills/markdown-craft/SKILL.md +2 -2
  62. package/claude/skills/markdown-craft/references/markdown.md +82 -53
  63. package/claude/skills/plan-feature/SKILL.md +52 -46
  64. package/claude/skills/plan-feature/references/batches.md +46 -0
  65. package/claude/skills/plan-groundwork/SKILL.md +60 -74
  66. package/claude/skills/plan-groundwork/references/output.md +48 -0
  67. package/claude/skills/plan-intake/SKILL.md +28 -21
  68. package/claude/skills/plan-intake-answer/SKILL.md +14 -13
  69. package/claude/skills/read-frames/SKILL.md +2 -0
  70. package/claude/skills/record-screencast/SKILL.md +31 -11
  71. package/claude/skills/review-address/SKILL.md +76 -158
  72. package/claude/skills/review-address/references/rebase-only.md +34 -0
  73. package/claude/skills/review-craft/SKILL.md +27 -11
  74. package/claude/skills/review-pr/SKILL.md +91 -40
  75. package/claude/skills/review-ui/SKILL.md +63 -37
  76. package/claude/skills/review-ui/references/hosted-preview.md +30 -0
  77. package/claude/skills/role-orchestrator/SKILL.md +105 -74
  78. package/claude/skills/role-orchestrator/references/orchestrator-filing.md +26 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  80. package/claude/skills/role-planner/SKILL.md +66 -64
  81. package/claude/skills/role-reviewer/SKILL.md +57 -46
  82. package/claude/skills/role-worker/SKILL.md +68 -63
  83. package/claude/skills/search-craft/SKILL.md +7 -5
  84. package/claude/skills/session-compact/SKILL.md +16 -11
  85. package/claude/skills/session-map/SKILL.md +31 -10
  86. package/claude/skills/session-relay/SKILL.md +32 -65
  87. package/claude/skills/session-resume/SKILL.md +18 -13
  88. package/claude/skills/session-worktree/SKILL.md +70 -42
  89. package/claude/skills/systematic-debugging/SKILL.md +6 -1
  90. package/claude/skills/target-check/SKILL.md +13 -6
  91. package/claude/skills/target-setup/SKILL.md +39 -27
  92. package/claude/skills/task-board/SKILL.md +68 -60
  93. package/claude/skills/task-board/references/board.md +1 -1
  94. package/claude/skills/task-board/references/create-report.md +36 -0
  95. package/claude/skills/task-board/references/tasks.md +1 -1
  96. package/claude/skills/task-complete/SKILL.md +20 -18
  97. package/claude/skills/teach-workspace/SKILL.md +61 -33
  98. package/claude/skills/test-craft/SKILL.md +2 -1
  99. package/claude/skills/test-first/SKILL.md +15 -9
  100. package/claude/skills/ui-checklist/SKILL.md +18 -16
  101. package/claude/skills/ux-audit/SKILL.md +9 -10
  102. package/claude/skills/ux-measure/SKILL.md +23 -19
  103. package/claude/skills/video-craft/SKILL.md +15 -7
  104. package/claude/skills/write-human/SKILL.md +9 -1
  105. package/claude/skills/youtube-transcripts/SKILL.md +2 -1
  106. package/docs/agents/audits.md +61 -60
  107. package/docs/agents/context-audit.md +40 -58
  108. package/docs/agents/index.md +11 -61
  109. package/docs/agents/indexes.md +25 -29
  110. package/docs/agents/install-and-sync.md +125 -235
  111. package/docs/agents/markdown-audit.md +54 -96
  112. package/docs/agents/migrate.md +41 -16
  113. package/docs/agents/output-shape.md +60 -45
  114. package/docs/agents/overview.md +36 -12
  115. package/docs/agents/records.md +37 -79
  116. package/docs/agents/sessions-transfer.md +26 -70
  117. package/docs/agents/targets.md +25 -77
  118. package/docs/target/index.md +0 -1
  119. package/docs/target/migrations.md +57 -66
  120. package/docs/target/projects.md +36 -78
  121. package/docs/target/scenarios.md +9 -19
  122. package/docs/target/sync.md +27 -68
  123. package/docs/workflow/ai-workflow.md +22 -68
  124. package/docs/workflow/operating-model.md +32 -186
  125. package/docs/workflow/parallel-features.md +29 -66
  126. package/docs/workflow/skill-map-on-request.md +22 -24
  127. package/docs/workflow/skill-map.md +72 -76
  128. package/docs/workflow/visual-design-workflow.md +25 -118
  129. package/docs/workflow/zshrc-aliases.md +18 -37
  130. package/package.json +1 -1
  131. package/src/canvas/client/inspector/fields.css +4 -0
  132. package/src/canvas/index.html +6 -0
  133. package/src/claude/skills-reach.ts +2 -2
  134. package/src/cli/ui.ts +3 -1
  135. package/src/commands/canvas.ts +23 -0
  136. package/src/commands/capture.ts +14 -0
  137. package/src/commands/claude.ts +14 -1
  138. package/src/commands/context.ts +106 -7
  139. package/src/commands/demo.ts +22 -0
  140. package/src/commands/deps.ts +6 -0
  141. package/src/commands/driver.ts +28 -2
  142. package/src/commands/gov.ts +52 -18
  143. package/src/commands/hooks.ts +63 -0
  144. package/src/commands/intake.ts +17 -0
  145. package/src/commands/labels.ts +38 -5
  146. package/src/commands/markdown.ts +169 -32
  147. package/src/commands/pr.ts +160 -43
  148. package/src/commands/records.ts +8 -0
  149. package/src/commands/secrets.ts +6 -0
  150. package/src/commands/sessions.ts +8 -0
  151. package/src/commands/tasks.ts +52 -0
  152. package/src/commands/teach.ts +13 -0
  153. package/src/commands/upstream.ts +25 -1
  154. package/src/commands/worktrees.ts +5 -0
  155. package/src/context/audit.ts +274 -5
  156. package/src/context/classify/prompts.ts +1 -1
  157. package/src/context/folders.ts +1 -1
  158. package/src/context/gate.ts +23 -7
  159. package/src/context/shape-gated.ts +30 -0
  160. package/src/counts/scan.ts +1 -1
  161. package/src/design/components.ts +1 -1
  162. package/src/design/document.ts +1 -1
  163. package/src/design/tokens.ts +1 -1
  164. package/src/gate/boundaries.ts +4 -1
  165. package/src/gate/measures.ts +16 -191
  166. package/src/gate/stages.ts +14 -30
  167. package/src/git/env.ts +6 -0
  168. package/src/hooks/reply-links.ts +110 -0
  169. package/src/markdown/density.ts +258 -0
  170. package/src/markdown/gate.ts +57 -18
  171. package/src/markdown/reference-shape.ts +103 -0
  172. package/src/markdown/scope.ts +162 -0
  173. package/src/markdown/structure.ts +9 -59
  174. package/src/pr/lift.ts +153 -19
  175. package/src/pr/painted.ts +87 -0
  176. package/src/roots/record.ts +18 -1
  177. package/src/sandbox/census.ts +1 -1
  178. package/src/sandbox/headless/run.ts +3 -2
  179. package/src/sandbox/headless/watch.ts +3 -4
  180. package/src/shipped/references.ts +1 -1
  181. package/src/slides/convert/master.ts +3 -0
  182. package/src/sync/format.ts +46 -0
  183. package/src/sync/stamp.ts +6 -0
  184. package/tooling/astro/reference.md +2 -2
  185. package/tooling/base/reference.md +1 -1
  186. package/tooling/claude/seeds/.claude/hooks/reply-links.sh +72 -0
  187. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +22 -1
  188. package/tooling/claude/seeds/.claude/settings.json +4 -0
  189. package/tooling/go/reference.md +1 -1
  190. package/tooling/nextjs/reference.md +1 -1
  191. package/tooling/php/reference.md +1 -1
  192. package/tooling/python/reference.md +1 -1
  193. package/tooling/vite-react/reference.md +1 -1
  194. package/tooling/web/reference.md +1 -1
  195. package/docs/agents/canvas.md +0 -86
  196. package/docs/agents/capture.md +0 -65
  197. package/docs/agents/census.md +0 -23
  198. package/docs/agents/commands.md +0 -154
  199. package/docs/agents/comments.md +0 -34
  200. package/docs/agents/context-audit-checks.md +0 -90
  201. package/docs/agents/context-classify.md +0 -101
  202. package/docs/agents/counts.md +0 -74
  203. package/docs/agents/demo.md +0 -140
  204. package/docs/agents/docs.md +0 -27
  205. package/docs/agents/drift-surfaces.md +0 -201
  206. package/docs/agents/driver.md +0 -103
  207. package/docs/agents/gate.md +0 -90
  208. package/docs/agents/gov-detect.md +0 -47
  209. package/docs/agents/hooks.md +0 -58
  210. package/docs/agents/intake.md +0 -81
  211. package/docs/agents/key-changes.md +0 -116
  212. package/docs/agents/label-coverage.md +0 -86
  213. package/docs/agents/markdown-audit-weight.md +0 -81
  214. package/docs/agents/pr-evidence-layout.md +0 -70
  215. package/docs/agents/pr-evidence.md +0 -273
  216. package/docs/agents/pr-frames.md +0 -131
  217. package/docs/agents/pr-lift.md +0 -132
  218. package/docs/agents/pr-local.md +0 -111
  219. package/docs/agents/pr-preview.md +0 -155
  220. package/docs/agents/pr-reads.md +0 -111
  221. package/docs/agents/pr-tick.md +0 -72
  222. package/docs/agents/records-push.md +0 -61
  223. package/docs/agents/records-stale.md +0 -40
  224. package/docs/agents/records-validate.md +0 -57
  225. package/docs/agents/restated.md +0 -83
  226. package/docs/agents/routing.md +0 -61
  227. package/docs/agents/rule-citations.md +0 -90
  228. package/docs/agents/sandbox.md +0 -126
  229. package/docs/agents/scripting.md +0 -142
  230. package/docs/agents/sessions.md +0 -128
  231. package/docs/agents/skills-audit.md +0 -120
  232. package/docs/agents/skills-reach.md +0 -64
  233. package/docs/agents/slides.md +0 -176
  234. package/docs/agents/state-scoped-risk.md +0 -105
  235. package/docs/agents/superseded.md +0 -85
  236. package/docs/agents/tasks-archive.md +0 -83
  237. package/docs/agents/tasks-plans.md +0 -152
  238. package/docs/agents/tasks-validate.md +0 -133
  239. package/docs/agents/tasks.md +0 -123
  240. package/docs/agents/teach-authoring.md +0 -118
  241. package/docs/agents/teach.md +0 -157
  242. package/docs/agents/test-order.md +0 -58
  243. package/docs/agents/upstream.md +0 -94
  244. package/docs/agents/worktrees.md +0 -118
  245. package/docs/target/standards-moved.md +0 -34
  246. package/src/markdown/growth.ts +0 -43
@@ -84,6 +84,7 @@ const CANON_RULES_REL = join('.claude', 'rules', 'canon')
84
84
 
85
85
  interface InstallOptions {
86
86
  readonly add?: string
87
+ readonly yes?: boolean
87
88
  }
88
89
 
89
90
  interface RegenOptions {
@@ -147,6 +148,7 @@ export function register(program: Command): void {
147
148
  .argument('[target]', 'Target directory', '.')
148
149
  .helpOption('-h, --help', 'Show this help message')
149
150
  .option('--add <rules>', 'Comma-separated rules to layer on the stack')
151
+ .option('--yes', 'Answer the install confirm without a prompt')
150
152
  .addHelpText(
151
153
  'after',
152
154
  [
@@ -156,6 +158,7 @@ export function register(program: Command): void {
156
158
  ' canon gov install node ../my-app',
157
159
  ' canon gov install node --add astro,shadcn ../my-app',
158
160
  ' canon gov install --add tasks,plan ../my-app',
161
+ ' canon gov install node --yes ../my-app',
159
162
  '',
160
163
  'With --add and no stack, the named rules install alone. A first',
161
164
  'positional naming a stack is read as the stack, so pass . or a path',
@@ -164,6 +167,10 @@ export function register(program: Command): void {
164
167
  'Copies each rule into .claude/rules/canon/, replacing a copy already',
165
168
  'there. Rules under .claude/rules/project/ are never read.',
166
169
  '',
170
+ 'The install confirm needs a TTY. --yes answers it, so a headless call',
171
+ 'installs without setting CANON_NON_INTERACTIVE=1. It does not pick a',
172
+ 'stack, so a headless call still names one.',
173
+ '',
167
174
  'Exit codes:',
168
175
  ' 0 installed, the stack held no rules, or the prompt was cancelled',
169
176
  ' 1 refused: no stack named headlessly, an unknown stack, no --add',
@@ -349,6 +356,10 @@ export function register(program: Command): void {
349
356
  ' 1 refused, with the reason on stderr or in the JSON record',
350
357
  ' 2 at least one stated figure disagrees with the tree',
351
358
  '',
359
+ 'Reasons:',
360
+ ' no-git git could not list the tree',
361
+ ' no-markdown the tree holds no markdown file',
362
+ '',
352
363
  'It reports and never gates. The false-positive rate is read off the',
353
364
  'first run rather than assumed, and a push failing on a stated figure',
354
365
  'that reads correctly to a person would teach a contributor to route',
@@ -415,6 +426,9 @@ export function register(program: Command): void {
415
426
  ' 1 refused, with the reason on stderr or in the JSON record',
416
427
  ' 2 at least one declaration asserts either',
417
428
  '',
429
+ 'Refuses an empty superseded value, a superseded value equal to its',
430
+ 'replacement, and a tree git cannot list. An empty replacement is allowed.',
431
+ '',
418
432
  'Examples:',
419
433
  ' canon gov superseded feature-feat- feature-',
420
434
  ' canon gov superseded feature-feat- feature- --json',
@@ -496,6 +510,9 @@ export function register(program: Command): void {
496
510
  ' 2 at least one cited path resolves to nothing, or one glob matches',
497
511
  ' nothing in this tree',
498
512
  '',
513
+ 'Refuses a tree holding neither rule corpus, and a `git check-ignore`',
514
+ 'read that fails.',
515
+ '',
499
516
  'Examples:',
500
517
  ' canon gov citations',
501
518
  ' canon gov citations --json',
@@ -543,6 +560,10 @@ export function register(program: Command): void {
543
560
  ' 1 refused, with the reason on stderr or in the JSON record',
544
561
  ' 2 at least one instruction is restated outside a declared mirror',
545
562
  '',
563
+ 'Reasons:',
564
+ ' no-instructions the tree holds no always-loaded file or seed to read',
565
+ ' no-surfaces the tree holds no skills tree or rules tree to match against',
566
+ '',
546
567
  'Examples:',
547
568
  ' canon gov restated',
548
569
  ' canon gov restated --json',
@@ -1268,8 +1289,9 @@ async function runInstall(
1268
1289
  intro('canon gov install')
1269
1290
 
1270
1291
  const add = opts.add ?? ''
1292
+ const yes = opts.yes === true
1271
1293
  if (add !== '' && isAddWithoutStack(stack, target)) {
1272
- return installExtras(add, stack ?? target)
1294
+ return installExtras(add, stack ?? target, yes)
1273
1295
  }
1274
1296
 
1275
1297
  const resolved = resolveTarget(target, PROJECT_ROOT)
@@ -1314,14 +1336,7 @@ async function runInstall(
1314
1336
  for (const rule of missing) logWarn(`${rule} (source not found, skipping)`)
1315
1337
  for (const source of found) logInfo(source.rule)
1316
1338
 
1317
- const shouldInstall = await select({
1318
- message: `Install ${found.length} rules to ${displayPath(target, CANON_RULES_REL)}?`,
1319
- options: [
1320
- { value: true, label: 'Yes' },
1321
- { value: false, label: 'No' },
1322
- ],
1323
- nonInteractiveDefault: true,
1324
- })
1339
+ const shouldInstall = await confirmInstall(found.length, target, yes)
1325
1340
 
1326
1341
  if (!shouldInstall) {
1327
1342
  logWarn('Cancelled')
@@ -1347,6 +1362,28 @@ async function runInstall(
1347
1362
  return 0
1348
1363
  }
1349
1364
 
1365
+ /**
1366
+ * The install confirm. `--yes` answers it without a prompt, so the line it
1367
+ * would have printed is skipped rather than duplicated under
1368
+ * `CANON_NON_INTERACTIVE=1`.
1369
+ */
1370
+ async function confirmInstall(
1371
+ count: number,
1372
+ target: string,
1373
+ yes: boolean,
1374
+ ): Promise<boolean> {
1375
+ if (yes) return true
1376
+ return select({
1377
+ message: `Install ${count} rules to ${displayPath(target, CANON_RULES_REL)}?`,
1378
+ options: [
1379
+ { value: true, label: 'Yes' },
1380
+ { value: false, label: 'No' },
1381
+ ],
1382
+ nonInteractiveDefault: true,
1383
+ ttyHint: 'Pass --yes',
1384
+ })
1385
+ }
1386
+
1350
1387
  /**
1351
1388
  * Whether `--add` runs with no stack under it. Commander fills the first
1352
1389
  * positional before the second, so `--add a,b ../my-app` arrives with the
@@ -1366,7 +1403,11 @@ function isAddWithoutStack(stack: string | undefined, target: string): boolean {
1366
1403
  * extras are not a stack, and overwriting the chain would cost a target the
1367
1404
  * missing-rule report its stack install recorded.
1368
1405
  */
1369
- async function installExtras(add: string, target: string): Promise<number> {
1406
+ async function installExtras(
1407
+ add: string,
1408
+ target: string,
1409
+ yes: boolean,
1410
+ ): Promise<number> {
1370
1411
  const resolved = resolveTarget(target, PROJECT_ROOT)
1371
1412
  if (typeof resolved === 'number') return resolved
1372
1413
 
@@ -1382,14 +1423,7 @@ async function installExtras(add: string, target: string): Promise<number> {
1382
1423
  return 1
1383
1424
  }
1384
1425
 
1385
- const shouldInstall = await select({
1386
- message: `Install ${found.length} rules to ${displayPath(target, CANON_RULES_REL)}?`,
1387
- options: [
1388
- { value: true, label: 'Yes' },
1389
- { value: false, label: 'No' },
1390
- ],
1391
- nonInteractiveDefault: true,
1392
- })
1426
+ const shouldInstall = await confirmInstall(found.length, target, yes)
1393
1427
 
1394
1428
  if (!shouldInstall) {
1395
1429
  logWarn('Cancelled')
@@ -1,4 +1,6 @@
1
1
  import type { Command } from 'commander'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { findMissingReplyLinks, formatMissing } from '@/hooks/reply-links'
2
4
  import {
3
5
  readMergedSubjects,
4
6
  runCanon,
@@ -11,6 +13,11 @@ interface PostMergeOptions {
11
13
  readonly root?: string
12
14
  }
13
15
 
16
+ interface ReplyLinksOptions {
17
+ readonly root?: string
18
+ readonly json?: boolean
19
+ }
20
+
14
21
  export function register(program: Command): void {
15
22
  const hooks = program
16
23
  .command('hooks')
@@ -64,4 +71,60 @@ export function register(program: Command): void {
64
71
  })
65
72
  process.exitCode = 0
66
73
  })
74
+
75
+ hooks
76
+ .command('reply-links')
77
+ .description(
78
+ 'Check that every markdown link in a reply on stdin names a file on disk',
79
+ )
80
+ .helpOption('-h, --help', 'Show this help message')
81
+ .option(
82
+ '--root <path>',
83
+ 'Root a relative link resolves against, default the main worktree root',
84
+ )
85
+ .option('--json', 'Write the findings as a record on stdout')
86
+ .addHelpText(
87
+ 'after',
88
+ [
89
+ '',
90
+ 'Reads the reply from stdin and resolves each link target on disk.',
91
+ 'Placeholders, globs, URLs, anchors, and links inside code are skipped.',
92
+ 'A :42 or #L42 tail is stripped before the check.',
93
+ '',
94
+ 'Missing paths go to stderr, and --json adds a record on stdout.',
95
+ '',
96
+ 'Exit codes:',
97
+ ' 0 every link resolves, or the reply holds none',
98
+ ' 1 at least one link names nothing on disk, or stdin is a terminal',
99
+ '',
100
+ 'Examples:',
101
+ ' printf "%s" "$reply" | canon hooks reply-links',
102
+ ' canon hooks reply-links --json < reply.md',
103
+ '',
104
+ ].join('\n'),
105
+ )
106
+ .action(async (opts: ReplyLinksOptions) => {
107
+ if (process.stdin.isTTY) {
108
+ process.stderr.write('Pipe the reply on stdin.\n')
109
+ process.exitCode = 1
110
+ return
111
+ }
112
+
113
+ const found = findMissingReplyLinks(
114
+ readFileSync(0, 'utf8'),
115
+ opts.root ?? (await mainWorktreeRoot()),
116
+ existsSync,
117
+ )
118
+ if (opts.json) {
119
+ process.stdout.write(
120
+ `${JSON.stringify({
121
+ message: found.length > 0 ? formatMissing(found) : undefined,
122
+ missing: found,
123
+ ok: found.length === 0,
124
+ })}\n`,
125
+ )
126
+ }
127
+ if (found.length > 0) process.stderr.write(`${formatMissing(found)}\n`)
128
+ process.exitCode = found.length > 0 ? 1 : 0
129
+ })
67
130
  }
@@ -64,6 +64,12 @@ export function register(program: Command): void {
64
64
  .addHelpText(
65
65
  'after',
66
66
  [
67
+ '',
68
+ 'Refusals, read off `reason`:',
69
+ ' no-intake the project holds no intake folder',
70
+ ' no-folder no folder matches the slug',
71
+ ' ambiguous-slug a bare slug matches more than one ordinal-prefixed',
72
+ ' folder, all named in `detail`',
67
73
  '',
68
74
  'Exit codes:',
69
75
  ' 0 the folders or items were listed',
@@ -105,6 +111,17 @@ export function register(program: Command): void {
105
111
  .addHelpText(
106
112
  'after',
107
113
  [
114
+ '',
115
+ 'Refusals, read off `reason`:',
116
+ ' no-intake the project holds no intake folder',
117
+ ' no-folder no folder matches the slug',
118
+ ' ambiguous-slug a bare slug matches more than one ordinal-prefixed',
119
+ ' folder, all named in `detail`',
120
+ ' no-cluster the cluster file named does not exist',
121
+ ' no-item the item number named does not exist in the cluster',
122
+ ' answered the item already carries an answer',
123
+ ' bad-input a malformed command line, such as no selection, an empty',
124
+ ' answer, or two answers for one item',
108
125
  '',
109
126
  'Exit codes:',
110
127
  ' 0 every named item now carries its answer',
@@ -111,6 +111,10 @@ export function register(program: Command): void {
111
111
  ' 1 refused, with the reason on stderr or in the JSON record',
112
112
  ' 2 at least one changed path is reached by no row',
113
113
  '',
114
+ 'Reasons:',
115
+ ' no-map the project declares no label map, so it labels nothing. An',
116
+ ' expected absence for `canon audits run`, not a fault',
117
+ '',
114
118
  'Examples:',
115
119
  ' canon labels audit',
116
120
  ' canon labels audit --json',
@@ -130,7 +134,10 @@ export function register(program: Command): void {
130
134
  )
131
135
  .argument('<number>', "This branch's open pull request")
132
136
  .helpOption('-h, --help', 'Show this help message')
133
- .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
137
+ .option(
138
+ '--base <ref>',
139
+ "Read the git range back to this ref instead of the pull request's files",
140
+ )
134
141
  .option('--root <path>', 'Repository to read, defaulting to the cwd')
135
142
  .option('--check', 'Report the labels owed and write nothing')
136
143
  .option('--json', 'Add a machine-readable record on stdout')
@@ -138,9 +145,10 @@ export function register(program: Command): void {
138
145
  'after',
139
146
  [
140
147
  '',
141
- 'Resolves the earned set the way `canon labels audit` does, binds to the',
142
- 'pull request only when its head is the current branch and it is open,',
143
- 'and adds each earned label the pull request lacks over REST.',
148
+ "Resolves the earned set from the pull request's own files listing, or",
149
+ 'from the git range when --base is passed, binds to the pull request only',
150
+ 'when its head is the current branch and it is open, and adds each earned',
151
+ 'label the pull request lacks over REST.',
144
152
  '',
145
153
  'It adds and never removes, so a label a person applied by hand stays.',
146
154
  'A label the remote does not carry is reported under missingOnRemote and',
@@ -155,6 +163,12 @@ export function register(program: Command): void {
155
163
  ' 2 labels are still owed, being any under --check, or a label the',
156
164
  ' remote lacks or a failed add otherwise',
157
165
  '',
166
+ 'Reasons:',
167
+ ' head-mismatch the pull request is not open on the current branch, under',
168
+ ' --check as well',
169
+ ' gh-failed the files listing or the labels could not be read, and the',
170
+ ' run does not fall back to git',
171
+ '',
158
172
  'Examples:',
159
173
  ' canon labels apply 123 --json',
160
174
  ' canon labels apply 123 --check',
@@ -396,7 +410,26 @@ async function runApply(number: string, opts: ApplyOptions): Promise<number> {
396
410
  if (target === null) return refuse('gh-failed')
397
411
  if (target.trim() !== `${head} open`) return refuse('head-mismatch')
398
412
 
399
- const report = await auditLabels(root, { base: opts.base })
413
+ // An explicit --base is an explicit request for the git route. Otherwise the
414
+ // pull request's own file listing is the diff the labels describe, and a
415
+ // failed read refuses rather than falling back, since binding needed gh too.
416
+ let paths: string[] | undefined
417
+ if (opts.base === undefined) {
418
+ const listing = await gh(root, [
419
+ 'api',
420
+ '--paginate',
421
+ `repos/{owner}/{repo}/pulls/${number}/files`,
422
+ '--jq',
423
+ '.[].filename',
424
+ ])
425
+ if (listing === null) return refuse('gh-failed')
426
+ paths = nonEmptyLines(listing)
427
+ }
428
+
429
+ const report = await auditLabels(root, {
430
+ base: opts.base,
431
+ ...(paths !== undefined && { paths }),
432
+ })
400
433
 
401
434
  // A project declaring no map earns nothing and is labelled silently, which
402
435
  // is an answer and exits clean rather than reading as a break.
@@ -6,19 +6,30 @@ 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 { isGating } from '@/markdown/gate'
9
+ import { densityFindings, type DensityKind } from '@/markdown/density'
10
+ import {
11
+ type GatedDensityFinding,
12
+ type GatedReferenceShapeFinding,
13
+ isGating,
14
+ referenceShapeGates,
15
+ } from '@/markdown/gate'
10
16
  import {
11
17
  findHeadingCase,
12
18
  type HeadingCaseFinding,
13
19
  } from '@/markdown/heading-case'
14
20
  import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
21
+ import {
22
+ REFERENCE_SECTIONS,
23
+ type ReferenceShapeKind,
24
+ referenceShapeFindings,
25
+ } from '@/markdown/reference-shape'
15
26
  import {
16
27
  type BanFinding,
17
28
  type BanSets,
18
29
  bodyLines,
19
30
  scanBans,
20
31
  } from '@/markdown/scan'
21
- import { bulletStacks, CAP, type StackFinding } from '@/markdown/stacks'
32
+ import { densityGates, GATED_PATHS, inDensityScope } from '@/markdown/scope'
22
33
  import {
23
34
  BASELINE,
24
35
  CHECKPOINTS,
@@ -36,6 +47,7 @@ import {
36
47
  pipeOutput,
37
48
  plural,
38
49
  } from '@/cli/ui'
50
+ import { isOwnCheckout } from '@/roots/project'
39
51
 
40
52
  const EXIT_REFUSED = 1
41
53
  const EXIT_GATE = 2
@@ -60,7 +72,10 @@ interface FileReport {
60
72
  readonly links: readonly LinkFinding[]
61
73
  readonly colons: readonly ColonFinding[]
62
74
  readonly headingCase: readonly HeadingCaseFinding[]
63
- readonly bulletStacks: readonly StackFinding[]
75
+ /** Empty on a path outside the density scope, so a plan never carries one. */
76
+ readonly density: readonly GatedDensityFinding[]
77
+ /** Empty on a page whose frontmatter does not carry `kind: reference`. */
78
+ readonly referenceShape: readonly GatedReferenceShapeFinding[]
64
79
  readonly structure: StructureReport
65
80
  readonly renderedLines: number
66
81
  /** The stated reason, or null where the document is not exempt. */
@@ -76,7 +91,7 @@ export function register(program: Command): void {
76
91
  markdown
77
92
  .command('audit')
78
93
  .description(
79
- 'Fail on a banned character, and report bullet, paragraph, cadence, depth, and length',
94
+ 'Fail on a banned character, a dead link, or a gated density finding, and report weight, cadence, depth, and length',
80
95
  )
81
96
  .argument(
82
97
  '[path...]',
@@ -91,22 +106,33 @@ export function register(program: Command): void {
91
106
  'Exit codes:',
92
107
  ' 0 the audit completed with no gating finding',
93
108
  ' 1 refused, with the reason on stderr',
94
- ' 2 a banned character is present, or a relative link resolves',
95
- ' to nothing on disk',
109
+ ' 2 a banned character is present, a relative link resolves to',
110
+ ' nothing on disk, or a density or reference shape finding sits',
111
+ ' on a gated path',
96
112
  ' 3 a shipped ban set is empty, so the run measured nothing',
97
113
  '',
98
114
  'A ban hit and a dead link are each a fact and gate unconditionally.',
99
- 'Bullet, paragraph, and depth weight are judgments a reader settles,',
100
- 'so all three report and none of them fails a run.',
115
+ 'Depth weight is a judgment a reader settles, so it reports and never',
116
+ 'fails a run.',
117
+ '',
118
+ 'Density holds six limits: a 350-character paragraph, 2 paragraphs in',
119
+ 'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
120
+ 'paragraph or table opening every H3. Only context, docs, skills, tooling',
121
+ 'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
122
+ 'finding. One gates under a prefix in GATED_PATHS in src/markdown/scope.ts,',
123
+ 'and a bullet run gates in context, docs, and skills from the start.',
124
+ 'Outside a canon checkout every density finding reports and none gates.',
125
+ '',
126
+ 'A page whose frontmatter carries kind: reference takes the reference',
127
+ 'shape: no H2 beyond Output, Refusals, and See also, each at most once',
128
+ 'and in that order, no H3, and no table whose first header cell names',
129
+ 'flags or options. It gates under the same prefixes density does, and',
130
+ 'an unmarked page is never measured.',
101
131
  '',
102
132
  'A mid-sentence colon and a heading with a capital after its first word',
103
133
  'report the same way. A proper noun is the one case the second cannot',
104
134
  'tell from title case, and the first is sometimes right.',
105
135
  '',
106
- 'Bullet stacks report a run of more than 5 plain bullets under one',
107
- 'heading and never exit 2. The Bullet stacks gate stage owns that',
108
- 'verdict, for the reason the length line below states.',
109
- '',
110
136
  'Length reports a whole document past the ceiling and never exits 2.',
111
137
  'The Document ceiling gate stage owns that verdict, since this audit also',
112
138
  'runs on a record kept out of version control, where a long plan is not',
@@ -131,7 +157,7 @@ export function register(program: Command): void {
131
157
  ' canon markdown audit',
132
158
  ' canon markdown audit --json',
133
159
  ' canon markdown audit .claude/rules governance',
134
- ' canon markdown audit docs/agents/commands.md',
160
+ ' canon markdown audit docs/agents/install-and-sync.md',
135
161
  " canon markdown audit 'standards/**/*.md'",
136
162
  '',
137
163
  ].join('\n'),
@@ -171,6 +197,9 @@ async function runAudit(
171
197
  const bans = BAN_SETS
172
198
  const empty = emptyBanSets(bans)
173
199
  const checkpoints = CHECKPOINTS
200
+ // `GATED_PATHS` describes this repository's rewrite progress, so a target
201
+ // reads every density finding as a report until its own corpus is gated.
202
+ const gates = isOwnCheckout(root)
174
203
 
175
204
  const reports: FileReport[] = await Promise.all(
176
205
  scope.files.map(async (rel) => {
@@ -184,7 +213,16 @@ async function runAudit(
184
213
  bans: scanBans(lines, bans),
185
214
  colons: advisory ? findColons(lines) : [],
186
215
  headingCase: advisory ? findHeadingCase(source) : [],
187
- bulletStacks: advisory ? bulletStacks(lines) : [],
216
+ density: inDensityScope(rel)
217
+ ? densityFindings(lines).map((finding) => ({
218
+ ...finding,
219
+ gating: gates && densityGates(rel, finding.kind),
220
+ }))
221
+ : [],
222
+ referenceShape: referenceShapeFindings(source).map((finding) => ({
223
+ ...finding,
224
+ gating: gates && referenceShapeGates(rel),
225
+ })),
188
226
  links: findBrokenLinks(lines, (path) =>
189
227
  existsSync(resolve(dirname(abs), path)),
190
228
  ),
@@ -201,7 +239,8 @@ async function runAudit(
201
239
  reportLinks(reports, root)
202
240
  reportColons(reports)
203
241
  reportHeadingCase(reports)
204
- reportStacks(reports)
242
+ reportDensity(reports, gates)
243
+ reportReferenceShape(reports, gates)
205
244
  reportBullets(reports, checkpoints)
206
245
  reportParagraphs(reports, checkpoints)
207
246
  reportCadence(reports, checkpoints)
@@ -237,7 +276,8 @@ async function runAudit(
237
276
  links: report.links,
238
277
  colons: report.colons,
239
278
  headingCase: report.headingCase,
240
- bulletStacks: report.bulletStacks,
279
+ density: report.density,
280
+ referenceShape: report.referenceShape,
241
281
  longestRun: report.structure.longestRun,
242
282
  longestRunLine: report.structure.longestRunLine,
243
283
  heavyBullets: report.structure.heavyBullets,
@@ -260,7 +300,8 @@ async function runAudit(
260
300
  structure: reports.map((report) => report.structure),
261
301
  colons: reports.flatMap((report) => report.colons),
262
302
  headingCase: reports.flatMap((report) => report.headingCase),
263
- bulletStacks: reports.flatMap((report) => report.bulletStacks),
303
+ density: reports.flatMap((report) => report.density),
304
+ referenceShape: reports.flatMap((report) => report.referenceShape),
264
305
  })
265
306
 
266
307
  return gating ? EXIT_GATE : 0
@@ -414,46 +455,142 @@ function reportLinks(reports: readonly FileReport[], root: string): void {
414
455
  )
415
456
  }
416
457
 
417
- function reportStacks(reports: readonly FileReport[]): void {
418
- logStep('Bullet stacks')
458
+ const DENSITY_LABELS: Record<DensityKind, string> = {
459
+ 'paragraph-length': 'paragraph length',
460
+ 'paragraph-run': 'paragraphs in a row',
461
+ 'bullet-length': 'bullet length',
462
+ 'bullet-run': 'bullets in a row',
463
+ 'heading-depth': 'heading depth',
464
+ 'h3-opening': 'H3 opening',
465
+ }
466
+
467
+ function describeDensity(finding: GatedDensityFinding): string {
468
+ const measured =
469
+ finding.kind === 'h3-opening'
470
+ ? 'opens with neither a paragraph nor a table'
471
+ : finding.kind === 'heading-depth'
472
+ ? `H${finding.value} past H${finding.limit}`
473
+ : `${finding.value} past ${finding.limit}`
474
+ const gate = finding.gating ? ' gating' : ''
475
+ return ` :${finding.line} ${DENSITY_LABELS[finding.kind]} ${measured}${gate}`
476
+ }
477
+
478
+ /**
479
+ * Lists every density finding with whether it gates, since an in-scope path
480
+ * under no gated prefix reports the same finding a gated one fails on and a
481
+ * reader has to tell the two apart in one frame.
482
+ */
483
+ function reportDensity(reports: readonly FileReport[], gates: boolean): void {
484
+ logStep('Density')
485
+ logInfo(
486
+ `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.`,
487
+ )
419
488
  logInfo(
420
- `A run of more than ${CAP} top-level bullets, with no paragraph or heading between them.`,
489
+ 'Measured on context entries, docs, skill bodies and references, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
421
490
  )
422
491
  logInfo(
423
- 'A run whose every item opens with a bold label or a link is exempt. This never fails a run. The Bullet stacks gate stage owns that verdict.',
492
+ gates
493
+ ? `A finding gates under a gated prefix (${GATED_PATHS.length === 0 ? 'none yet' : GATED_PATHS.join(', ')}), and a bullet run gates in context, docs, and skills already. Every other finding reports.`
494
+ : 'This tree is not a canon checkout, so every density finding reports and none gates.',
424
495
  )
425
496
 
426
497
  const carrying = reports
427
- .filter((report) => report.bulletStacks.length > 0)
428
- .sort((a, b) => longestStack(b) - longestStack(a))
498
+ .filter((report) => report.density.length > 0)
499
+ .sort((a, b) => b.density.length - a.density.length)
429
500
 
430
501
  if (carrying.length === 0) {
431
- logInfo(`No run past ${CAP} bullets.`)
502
+ logInfo('No density finding.')
432
503
  return
433
504
  }
434
505
 
435
- const total = carrying.reduce(
436
- (sum, report) => sum + report.bulletStacks.length,
506
+ const total = carrying.reduce((sum, report) => sum + report.density.length, 0)
507
+ const gating = carrying.reduce(
508
+ (sum, report) =>
509
+ sum + report.density.filter((finding) => finding.gating).length,
437
510
  0,
438
511
  )
439
- logWarn(`${plural(total, 'stack')} across ${plural(carrying.length, 'file')}`)
512
+ logWarn(
513
+ `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}, ${gating} gating`,
514
+ )
515
+ if (gating > 0) logWarn('A gating finding fails the run.')
440
516
  logInfo(
441
- 'Fold the run into prose, give each item an H3, or split the list at its seam.',
517
+ 'Split a long block at its seam, put a heading or a table between paragraphs, give a long list H3s, and open each H3 with a sentence or a table.',
442
518
  )
443
519
  pipeOutput(
444
520
  carrying
445
521
  .map(
446
522
  (report) =>
447
- `${report.rel} ${plural(report.bulletStacks.length, 'stack')}\n${report.bulletStacks
448
- .map((found) => ` :${found.line} ${found.count} bullets`)
523
+ `${report.rel} ${plural(report.density.length, 'finding')}\n${report.density
524
+ .map(describeDensity)
449
525
  .join('\n')}`,
450
526
  )
451
527
  .join('\n'),
452
528
  )
453
529
  }
454
530
 
455
- function longestStack(report: FileReport): number {
456
- return Math.max(0, ...report.bulletStacks.map((found) => found.count))
531
+ const REFERENCE_SHAPE_LABELS: Record<ReferenceShapeKind, string> = {
532
+ 'extra-section': 'section outside the shape',
533
+ 'section-order': 'section out of order or repeated',
534
+ subsection: 'H3',
535
+ 'flag-table': 'flag table',
536
+ }
537
+
538
+ function describeReferenceShape(finding: GatedReferenceShapeFinding): string {
539
+ const gate = finding.gating ? ' gating' : ''
540
+ return ` :${finding.line} ${REFERENCE_SHAPE_LABELS[finding.kind]} ${finding.text}${gate}`
541
+ }
542
+
543
+ /**
544
+ * Lists every reference shape finding with whether it gates, under the same
545
+ * prefixes the density step names.
546
+ */
547
+ function reportReferenceShape(
548
+ reports: readonly FileReport[],
549
+ gates: boolean,
550
+ ): void {
551
+ logStep('Reference shape')
552
+ logInfo(
553
+ `A page whose frontmatter carries kind: reference holds its opening, then ${REFERENCE_SECTIONS.join(', ')}, each at most once and in that order, with no H3 and no table of flags. An unmarked page is never measured.`,
554
+ )
555
+ logInfo(
556
+ gates
557
+ ? 'A finding gates under the prefixes the density step names. Every other finding reports.'
558
+ : 'This tree is not a canon checkout, so every reference shape finding reports and none gates.',
559
+ )
560
+
561
+ const carrying = reports.filter((report) => report.referenceShape.length > 0)
562
+
563
+ if (carrying.length === 0) {
564
+ logInfo('No reference shape finding.')
565
+ return
566
+ }
567
+
568
+ const total = carrying.reduce(
569
+ (sum, report) => sum + report.referenceShape.length,
570
+ 0,
571
+ )
572
+ const gating = carrying.reduce(
573
+ (sum, report) =>
574
+ sum + report.referenceShape.filter((finding) => finding.gating).length,
575
+ 0,
576
+ )
577
+ logWarn(
578
+ `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}, ${gating} gating`,
579
+ )
580
+ if (gating > 0) logWarn('A gating finding fails the run.')
581
+ logInfo(
582
+ "Fold an H3 into its section, move a section the shape lacks into the opening or a linked page, and point at the command's own --help for flags.",
583
+ )
584
+ pipeOutput(
585
+ carrying
586
+ .map(
587
+ (report) =>
588
+ `${report.rel} ${plural(report.referenceShape.length, 'finding')}\n${report.referenceShape
589
+ .map(describeReferenceShape)
590
+ .join('\n')}`,
591
+ )
592
+ .join('\n'),
593
+ )
457
594
  }
458
595
 
459
596
  function reportColons(reports: readonly FileReport[]): void {