@erclx/canon 5.37.0 → 5.39.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 (197) hide show
  1. package/README.md +1 -1
  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 +36 -17
  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 +7 -4
  43. package/claude/skills/git-branch/SKILL.md +3 -3
  44. package/claude/skills/git-commit/SKILL.md +5 -3
  45. package/claude/skills/git-followup/SKILL.md +16 -9
  46. package/claude/skills/git-issue/SKILL.md +6 -5
  47. package/claude/skills/git-issue/references/issue.md +11 -10
  48. package/claude/skills/git-pr/SKILL.md +56 -44
  49. package/claude/skills/git-ship/SKILL.md +43 -34
  50. package/claude/skills/git-ship/references/ci-watch.md +31 -0
  51. package/claude/skills/git-split/SKILL.md +28 -100
  52. package/claude/skills/git-split/references/independent.md +40 -0
  53. package/claude/skills/git-split/references/stacked.md +73 -0
  54. package/claude/skills/git-stage/SKILL.md +9 -7
  55. package/claude/skills/git-worktree/SKILL.md +27 -49
  56. package/claude/skills/git-worktree/references/cleanup.md +55 -0
  57. package/claude/skills/markdown-craft/SKILL.md +2 -2
  58. package/claude/skills/markdown-craft/references/markdown.md +82 -52
  59. package/claude/skills/plan-feature/SKILL.md +52 -46
  60. package/claude/skills/plan-feature/references/batches.md +46 -0
  61. package/claude/skills/plan-groundwork/SKILL.md +60 -74
  62. package/claude/skills/plan-groundwork/references/groundwork.md +53 -24
  63. package/claude/skills/plan-groundwork/references/output.md +48 -0
  64. package/claude/skills/plan-intake/SKILL.md +28 -21
  65. package/claude/skills/plan-intake-answer/SKILL.md +14 -13
  66. package/claude/skills/read-frames/SKILL.md +2 -0
  67. package/claude/skills/record-screencast/SKILL.md +31 -11
  68. package/claude/skills/review-address/SKILL.md +76 -158
  69. package/claude/skills/review-address/references/rebase-only.md +34 -0
  70. package/claude/skills/review-craft/SKILL.md +27 -11
  71. package/claude/skills/review-pr/SKILL.md +91 -40
  72. package/claude/skills/review-pr/references/post.md +1 -1
  73. package/claude/skills/review-ui/SKILL.md +62 -37
  74. package/claude/skills/review-ui/references/hosted-preview.md +30 -0
  75. package/claude/skills/role-orchestrator/SKILL.md +105 -74
  76. package/claude/skills/role-orchestrator/references/orchestrator-filing.md +26 -0
  77. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  78. package/claude/skills/role-planner/SKILL.md +66 -64
  79. package/claude/skills/role-reviewer/SKILL.md +57 -46
  80. package/claude/skills/role-worker/SKILL.md +68 -63
  81. package/claude/skills/search-craft/SKILL.md +7 -5
  82. package/claude/skills/session-compact/SKILL.md +16 -11
  83. package/claude/skills/session-map/SKILL.md +31 -10
  84. package/claude/skills/session-relay/SKILL.md +32 -65
  85. package/claude/skills/session-resume/SKILL.md +18 -13
  86. package/claude/skills/session-worktree/SKILL.md +70 -42
  87. package/claude/skills/systematic-debugging/SKILL.md +6 -1
  88. package/claude/skills/target-check/SKILL.md +13 -6
  89. package/claude/skills/target-setup/SKILL.md +41 -30
  90. package/claude/skills/target-setup/references/verify.md +6 -5
  91. package/claude/skills/task-board/SKILL.md +68 -60
  92. package/claude/skills/task-board/references/create-report.md +36 -0
  93. package/claude/skills/task-board/references/tasks.md +3 -1
  94. package/claude/skills/task-complete/SKILL.md +20 -18
  95. package/claude/skills/teach-workspace/SKILL.md +61 -33
  96. package/claude/skills/test-craft/SKILL.md +2 -1
  97. package/claude/skills/test-first/SKILL.md +15 -9
  98. package/claude/skills/ui-checklist/SKILL.md +18 -16
  99. package/claude/skills/ux-audit/SKILL.md +9 -10
  100. package/claude/skills/ux-measure/SKILL.md +22 -18
  101. package/claude/skills/video-craft/SKILL.md +15 -7
  102. package/claude/skills/write-human/SKILL.md +9 -1
  103. package/claude/skills/youtube-transcripts/SKILL.md +2 -1
  104. package/docs/agents/context-audit-checks.md +19 -1
  105. package/docs/agents/context-audit.md +2 -2
  106. package/docs/agents/driver.md +1 -1
  107. package/docs/agents/hooks.md +21 -1
  108. package/docs/agents/index.md +10 -14
  109. package/docs/agents/indexes.md +25 -29
  110. package/docs/agents/install-and-sync.md +124 -235
  111. package/docs/agents/markdown-audit-density.md +39 -0
  112. package/docs/agents/markdown-audit-weight.md +9 -7
  113. package/docs/agents/markdown-audit.md +18 -14
  114. package/docs/agents/migrate.md +41 -16
  115. package/docs/agents/output-shape.md +60 -45
  116. package/docs/agents/overview.md +36 -12
  117. package/docs/agents/routing.md +1 -1
  118. package/docs/agents/rule-citations.md +1 -1
  119. package/docs/agents/skills-reach.md +1 -1
  120. package/docs/agents/targets.md +25 -77
  121. package/docs/target/migrations.md +1 -1
  122. package/docs/target/projects.md +3 -3
  123. package/docs/target/scenarios.md +1 -1
  124. package/docs/workflow/ai-workflow.md +2 -2
  125. package/docs/workflow/parallel-features.md +2 -2
  126. package/docs/workflow/skill-map.md +1 -1
  127. package/docs/workflow/visual-design-workflow.md +1 -1
  128. package/governance/detect.toml +6 -1
  129. package/package.json +6 -3
  130. package/scripts/core/install-check.sh +1 -1
  131. package/src/commands/claude.ts +1 -2
  132. package/src/commands/context.ts +106 -7
  133. package/src/commands/hooks.ts +63 -0
  134. package/src/commands/markdown.ts +169 -32
  135. package/src/commands/pr.ts +1 -1
  136. package/src/commands/records.ts +0 -1
  137. package/src/context/audit.ts +256 -5
  138. package/src/context/classify/prompts.ts +1 -1
  139. package/src/context/gate.ts +23 -7
  140. package/src/context/shape-gated.ts +28 -0
  141. package/src/design/document.ts +1 -1
  142. package/src/design/tokens.ts +1 -1
  143. package/src/docs/read.ts +15 -21
  144. package/src/gate/measures.ts +15 -190
  145. package/src/gate/stages.ts +21 -23
  146. package/src/hooks/reply-links.ts +110 -0
  147. package/src/markdown/density.ts +258 -0
  148. package/src/markdown/gate.ts +57 -18
  149. package/src/markdown/reference-shape.ts +103 -0
  150. package/src/markdown/scope.ts +174 -0
  151. package/src/markdown/structure.ts +10 -60
  152. package/src/sandbox/equivalence.ts +7 -9
  153. package/src/shipped/references.ts +1 -1
  154. package/src/teach/browser/head.ts +2 -2
  155. package/src/teach/browser/sidebar.ts +3 -3
  156. package/src/teach/browser/theme.ts +2 -2
  157. package/src/tooling/inject.ts +2 -2
  158. package/src/tooling/package.ts +15 -18
  159. package/src/tooling/verify-stack.ts +6 -3
  160. package/tooling/astro/configs/.oxfmtrc.json +8 -0
  161. package/tooling/astro/configs/.prettierrc +13 -0
  162. package/tooling/astro/manifest.toml +13 -0
  163. package/tooling/astro/reference.md +15 -5
  164. package/tooling/base/configs/.oxfmtrc.json +7 -0
  165. package/tooling/base/configs/scripts/verify.ts +1 -1
  166. package/tooling/base/manifest.toml +6 -6
  167. package/tooling/base/reference.md +12 -9
  168. package/tooling/base/seeds/.lintstagedrc +1 -3
  169. package/tooling/base/seeds/canon/context/ci.md +3 -3
  170. package/tooling/base/seeds/canon/context/development.md +2 -2
  171. package/tooling/claude/manifest.toml +5 -1
  172. package/tooling/claude/seeds/.claude/hooks/reply-links.sh +72 -0
  173. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +22 -1
  174. package/tooling/claude/seeds/.claude/settings.json +4 -0
  175. package/tooling/go/reference.md +2 -2
  176. package/tooling/nextjs/configs/.oxlintrc.json +223 -0
  177. package/tooling/nextjs/manifest.toml +2 -5
  178. package/tooling/nextjs/reference.md +4 -4
  179. package/tooling/php/manifest.toml +6 -1
  180. package/tooling/php/reference.md +2 -2
  181. package/tooling/python/manifest.toml +6 -1
  182. package/tooling/python/reference.md +4 -4
  183. package/tooling/vite-react/manifest.toml +1 -4
  184. package/tooling/vite-react/reference.md +3 -3
  185. package/tooling/web/configs/.oxlintrc.json +213 -0
  186. package/tooling/web/manifest.toml +9 -13
  187. package/tooling/web/reference.md +14 -14
  188. package/docs/agents/commands.md +0 -154
  189. package/docs/agents/docs.md +0 -27
  190. package/docs/agents/drift-surfaces.md +0 -201
  191. package/docs/agents/gov-detect.md +0 -47
  192. package/docs/agents/scripting.md +0 -142
  193. package/src/markdown/growth.ts +0 -43
  194. package/tooling/base/configs/.prettierrc +0 -12
  195. package/tooling/base/seeds/.prettierignore +0 -0
  196. package/tooling/nextjs/configs/eslint.config.js +0 -91
  197. package/tooling/web/configs/eslint.config.js +0 -82
@@ -18,18 +18,9 @@ import {
18
18
  import { familyKeys } from '@/claude/skills-families'
19
19
  import { measureDesignProse } from '@/design/budget'
20
20
  import { DESIGN_DOCUMENT } from '@/design/regen'
21
- import {
22
- listChangedFiles,
23
- listFilesAtRef,
24
- listRepositoryFiles,
25
- readFileAtRef,
26
- resolveBaseRef,
27
- } from '@/git/files'
21
+ import { listRepositoryFiles } from '@/git/files'
28
22
  import { ceilingFindings } from '@/markdown/ceiling'
29
23
  import { resolveMarkdown } from '@/markdown/files'
30
- import { grownBlocks, normalizeProse } from '@/markdown/growth'
31
- import { bodyLines } from '@/markdown/scan'
32
- import { bulletStacks as findBulletStacks } from '@/markdown/stacks'
33
24
  import { CHECKPOINTS } from '@/markdown/structure'
34
25
  import {
35
26
  isShippedCorpus,
@@ -510,182 +501,6 @@ export const documentCeiling: Measure = async (ctx) => {
510
501
  }
511
502
  }
512
503
 
513
- /**
514
- * The tracked documents a bullet run is held against: the context entries, the
515
- * docs pages, and the skill bodies and references of both skill trees.
516
- *
517
- * Rules and the requirement records stay out, since a rule is one imperative
518
- * per bullet and a requirement is a record of its own. Named here and nowhere
519
- * else, so the stage and its tests read one scope.
520
- */
521
- const STACK_SCOPE = [
522
- /^canon\/context\/.+\.md$/,
523
- /^docs\/.+\.md$/,
524
- /^(?:claude|\.claude)\/skills\/[^/]+\/SKILL\.md$/,
525
- /^(?:claude|\.claude)\/skills\/[^/]+\/references\/.+\.md$/,
526
- ]
527
-
528
- /** Whether a path is one the bullet cap holds. */
529
- export function inStackScope(rel: string): boolean {
530
- return STACK_SCOPE.some((pattern) => pattern.test(rel))
531
- }
532
-
533
- /** The context entries alone, where length drifts because `context-fold` appends after every merge. */
534
- const GROWTH_SCOPE = /^canon\/context\/.+\.md$/
535
-
536
- /** Whether a path is one the growth check reads. */
537
- export function inGrowthScope(rel: string): boolean {
538
- return GROWTH_SCOPE.test(rel)
539
- }
540
-
541
- /**
542
- * Every in-scope document against the five-bullet cap.
543
- *
544
- * The failure names each file with its longest run and where it opens. Read
545
- * in-process rather than through `canon markdown audit`, whose exit code stays
546
- * bans and dead links only, for the reason `documentCeiling` gives: the plan
547
- * and groundwork skills run that audit on gitignored records, where a long
548
- * list is not a defect.
549
- */
550
- export const bulletStacks: Measure = async (ctx) => {
551
- const files = await listRepositoryFiles(ctx.root)
552
- if (files === undefined) {
553
- return {
554
- emissions: [],
555
- unmeasured: 'git could not list the tree, so no bullet run was measured.',
556
- }
557
- }
558
-
559
- const scoped = files.filter(inStackScope)
560
- const failing: { rel: string; count: number; line: number }[] = []
561
-
562
- for (const rel of scoped) {
563
- let source: string
564
- try {
565
- source = readFileSync(join(ctx.root, rel), 'utf8')
566
- } catch {
567
- continue
568
- }
569
- const runs = findBulletStacks(bodyLines(source))
570
- if (runs.length === 0) continue
571
- const longest = runs.reduce((a, b) => (b.count > a.count ? b : a))
572
- failing.push({ rel, count: longest.count, line: longest.line })
573
- }
574
-
575
- if (failing.length === 0) {
576
- return {
577
- emissions: [
578
- info(
579
- `No run of more than 5 plain bullets across ${plural(scoped.length, 'document')}`,
580
- ),
581
- ],
582
- }
583
- }
584
-
585
- const where = failing
586
- .sort((a, b) => b.count - a.count)
587
- .map((f) => `${f.rel}:${f.line} ${f.count} bullets`)
588
- .join(', ')
589
- return {
590
- emissions: [],
591
- failure: `${plural(failing.length, 'document')} carrying a run of more than 5 plain bullets: ${where}. Split each run under H3 headings with a few bullets or prose beneath, or open every item with a bold label or a link.`,
592
- }
593
- }
594
-
595
- /**
596
- * A context entry's paragraph or top-level bullet past its checkpoint that
597
- * this branch added or changed.
598
- *
599
- * A block fails when its text is absent from every context entry the merge
600
- * base held, so an unchanged long block passes wherever it now sits and an
601
- * edited or new one fails. Reading the whole base folder rather than the same
602
- * path is what lets a block move into a sibling entry. No resolvable base
603
- * reports unmeasured rather than a pass, since a missing base would otherwise
604
- * read every block as new or none as new.
605
- *
606
- * Only context entries are held to it. Length drifts there because
607
- * `context-fold` appends after every merge, and in docs and skills it stays the
608
- * audit's advisory warning.
609
- */
610
- export const paragraphGrowth: Measure = async (ctx) => {
611
- const base = await resolveBaseRef(ctx.root)
612
- if (base === undefined) {
613
- return {
614
- emissions: [],
615
- unmeasured:
616
- 'no merge base resolved, so no paragraph or bullet was compared against it.',
617
- }
618
- }
619
-
620
- const changed = await listChangedFiles(ctx.root, base)
621
- if (changed === undefined) {
622
- return {
623
- emissions: [],
624
- unmeasured:
625
- 'git could not list the branch range, so no block was compared.',
626
- }
627
- }
628
-
629
- const grown: {
630
- rel: string
631
- kind: string
632
- line: number
633
- characters: number
634
- }[] = []
635
- let corpus: string | undefined
636
-
637
- for (const rel of changed.filter(inGrowthScope)) {
638
- let source: string
639
- try {
640
- source = readFileSync(join(ctx.root, rel), 'utf8')
641
- } catch {
642
- continue
643
- }
644
-
645
- const own = normalizeProse((await readFileAtRef(ctx.root, base, rel)) ?? '')
646
- const first = grownBlocks(source, own)
647
- if (first.length === 0) continue
648
-
649
- if (corpus === undefined) {
650
- const paths =
651
- (await listFilesAtRef(ctx.root, base, 'canon/context')) ?? []
652
- const texts = await Promise.all(
653
- paths
654
- .filter(inGrowthScope)
655
- .map((path) => readFileAtRef(ctx.root, base, path)),
656
- )
657
- corpus = normalizeProse(texts.filter((t) => t !== undefined).join('\n'))
658
- }
659
-
660
- for (const block of grownBlocks(source, corpus)) {
661
- grown.push({
662
- rel,
663
- kind: block.kind,
664
- line: block.line,
665
- characters: block.characters,
666
- })
667
- }
668
- }
669
-
670
- if (grown.length === 0) {
671
- return {
672
- emissions: [
673
- info(
674
- `No paragraph past ${CHECKPOINTS.paragraph} characters and no bullet past ${CHECKPOINTS.bullet} grew in a context entry`,
675
- ),
676
- ],
677
- }
678
- }
679
-
680
- const where = grown
681
- .map((g) => `${g.rel}:${g.line} ${g.kind} ${g.characters} characters`)
682
- .join(', ')
683
- return {
684
- emissions: [],
685
- failure: `${plural(grown.length, 'block')} past the checkpoint added or changed on this branch: ${where}. Split each at a seam, or cut it under ${CHECKPOINTS.paragraph} characters for a paragraph and ${CHECKPOINTS.bullet} for a bullet.`,
686
- }
687
- }
688
-
689
504
  /**
690
505
  * Whether a path's stem, its basename up to the first `.`, repeats its
691
506
  * immediate parent folder, either whole or as a `<folder>-` prefix.
@@ -693,8 +508,12 @@ export const paragraphGrowth: Measure = async (ctx) => {
693
508
  * Exact rather than singular-aware, so `skills/skill.md` passes. A false
694
509
  * positive on a gate stage teaches a contributor to route around it, and a
695
510
  * widened match is cheap to add once a real echo slips through.
511
+ *
512
+ * `canon/context/context-model.md` is exempt, since "context model" is the
513
+ * domain's own name and the entry reads as nothing else without the prefix.
696
514
  */
697
515
  export function isFolderEchoedName(path: string): boolean {
516
+ if (path === 'canon/context/context-model.md') return false
698
517
  const segments = path.split('/')
699
518
  if (segments.length < 2) return false
700
519
  const folder = segments[segments.length - 2] ?? ''
@@ -806,8 +625,10 @@ export const recordIdempotence: Measure = async (ctx) => {
806
625
  }
807
626
 
808
627
  /**
809
- * A banned character is a fact rather than a threshold, so it fails the push
810
- * while bullet, paragraph, and depth weight stay advisory.
628
+ * A banned character, a dead link, and a density finding on a gated path are
629
+ * each a fact rather than a threshold, so any one fails the push while depth
630
+ * weight and cadence stay advisory. The audit decides which density findings
631
+ * gate, from `src/markdown/scope.ts`, so this stage reads its exit alone.
811
632
  *
812
633
  * The whole corpus is measured rather than the changed files, because a
813
634
  * `Do not use` bullet added to a standard bans a token retroactively and no
@@ -821,7 +642,11 @@ export const markdownBans: Measure = async (ctx) => {
821
642
  const { exitCode } = await ctx.cli(['markdown', 'audit', '--json'])
822
643
 
823
644
  if (exitCode === 0) {
824
- return { emissions: [info('No banned character')] }
645
+ return {
646
+ emissions: [
647
+ info('No banned character, dead link, or gated density finding'),
648
+ ],
649
+ }
825
650
  }
826
651
 
827
652
  if (exitCode === 1) {
@@ -844,7 +669,7 @@ export const markdownBans: Measure = async (ctx) => {
844
669
  return {
845
670
  emissions: [output(frame.all)],
846
671
  failure:
847
- 'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence, since a colon or a comma standing where the em dash was is the same sentence, or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
672
+ 'Markdown prose carries a banned character, a relative link resolves to nothing on disk, or a gated path breaks a density limit. Rewrite the sentence, since a colon or a comma standing where the em dash was is the same sentence, fix the link, or split the block the Density section names, and reach for a code span only where the token is genuinely an identifier under discussion.',
848
673
  }
849
674
  }
850
675
 
@@ -11,12 +11,10 @@ import {
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
13
  designProse,
14
- bulletStacks,
15
14
  documentCeiling,
16
15
  folderEchoedNames,
17
16
  markdownBans,
18
17
  type Measure,
19
- paragraphGrowth,
20
18
  pluginManifests,
21
19
  readmeCitations,
22
20
  recordIdempotence,
@@ -321,7 +319,8 @@ export const STAGES: readonly Stage[] = [
321
319
  {
322
320
  // The facts the audit can answer from the files gate: a cited path that
323
321
  // does not resolve, a required section an entry does not declare, an H2
324
- // outside the closed set of four, and an index disagreeing with its folder.
322
+ // outside the closed set of three, a shape break in an entry under a
323
+ // shape-gated folder, and an index disagreeing with its folder.
325
324
  // Its length, depth, bullet, table, and provenance findings are judgment
326
325
  // thresholds, and failing a push on one would make the stage something to
327
326
  // route around. The length readings that gate sit in their own stages
@@ -335,7 +334,7 @@ export const STAGES: readonly Stage[] = [
335
334
  kind: 'cli',
336
335
  argv: ['context', 'audit', '--gate'],
337
336
  failure:
338
- 'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, Decisions, and Gotchas, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
337
+ 'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
339
338
  },
340
339
  ],
341
340
  success: 'Context citations resolve',
@@ -354,23 +353,6 @@ export const STAGES: readonly Stage[] = [
354
353
  label: 'Document ceiling',
355
354
  checks: [{ kind: 'measure', measure: documentCeiling }],
356
355
  },
357
- {
358
- // Unscoped, since any branch can add a bullet to any entry. A run of more
359
- // than 5 plain bullets fails the push in the context entries, the docs
360
- // pages, and the skill bodies and references. Apart from the audit's exit
361
- // code for the reason the Document ceiling stage above states.
362
- id: 'bullet-stacks',
363
- label: 'Bullet stacks',
364
- checks: [{ kind: 'measure', measure: bulletStacks }],
365
- },
366
- {
367
- // Context entries only, where length drifts. A paragraph past 700
368
- // characters or a top-level bullet past 250 fails when this branch added or
369
- // changed it, and an unchanged one passes until it is edited.
370
- id: 'paragraph-growth',
371
- label: 'Paragraph growth',
372
- checks: [{ kind: 'measure', measure: paragraphGrowth }],
373
- },
374
356
  {
375
357
  // Unscoped, since any branch can add a file anywhere and one git ls-files
376
358
  // costs nothing. Reads paths rather than markdown, so it sits here rather
@@ -410,8 +392,10 @@ export const STAGES: readonly Stage[] = [
410
392
  checks: [{ kind: 'measure', measure: recordIdempotence }],
411
393
  },
412
394
  {
413
- id: 'markdown-bans',
414
- label: 'Markdown bans',
395
+ // Bans and dead links in every file, and a density finding on a path the
396
+ // audit gates, all read off one exit code.
397
+ id: 'markdown',
398
+ label: 'Markdown',
415
399
  checks: [{ kind: 'measure', measure: markdownBans }],
416
400
  },
417
401
  {
@@ -544,6 +528,20 @@ export const STAGES: readonly Stage[] = [
544
528
  ],
545
529
  success: 'Shell check passed',
546
530
  },
531
+ {
532
+ id: 'lint',
533
+ label: 'Lint',
534
+ // Unscoped, since a full run costs less than spawning the process and a
535
+ // scope is one more pattern to fall behind the extensions oxlint reads.
536
+ checks: [
537
+ {
538
+ kind: 'command',
539
+ argv: ['bun', 'run', 'check:lint'],
540
+ failure: 'Lint failed',
541
+ },
542
+ ],
543
+ success: 'Lint passed',
544
+ },
547
545
  {
548
546
  id: 'types',
549
547
  label: 'Types',
@@ -0,0 +1,110 @@
1
+ import { isAbsolute, join } from 'node:path'
2
+ import { decodePath } from '@/markdown/links'
3
+ import { LINK, linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
4
+
5
+ export interface MissingLink {
6
+ /** The destination as the reply wrote it. */
7
+ readonly destination: string
8
+ /** The path the check looked for. */
9
+ readonly path: string
10
+ /** Where the file is instead, for a records link written under a worktree. */
11
+ readonly suggestion?: string
12
+ }
13
+
14
+ // A bare filename followed by a line tail reads as a scheme, so a colon that
15
+ // opens nothing but a position (`README.md:12`) is not one.
16
+ const SCHEME = /^[a-z][a-z0-9+.-]*:(?!\d+(?:-\d+)?(?::\d+)?$)/i
17
+ const POSITION_TAIL = /(?::\d+){1,2}(?:-\d+)?$/
18
+ const WORKTREE_RECORDS = /\/\.claude\/worktrees\/[^/]+\/(\.canon\/.*)$/
19
+
20
+ function isSkipped(destination: string): boolean {
21
+ return (
22
+ destination === '' ||
23
+ SCHEME.test(destination) ||
24
+ destination.startsWith('#') ||
25
+ /[<*{]/.test(destination)
26
+ )
27
+ }
28
+
29
+ /**
30
+ * The path a destination names, or undefined when it is not one to check.
31
+ *
32
+ * A chat link is resolved from the editor root and not from a file's own
33
+ * folder, which is why `findBrokenLinks` cannot serve here: it skips every
34
+ * absolute destination and resolves the rest beside the file holding them.
35
+ * A malformed percent escape is skipped, since the reply is text a model
36
+ * typed and the hook has no business throwing on it.
37
+ */
38
+ function linkedPath(destination: string): string | undefined {
39
+ if (isSkipped(destination)) return undefined
40
+ const decoded = decodePath(destination)
41
+ if (decoded === undefined) return undefined
42
+ const path = decoded.replace(POSITION_TAIL, '')
43
+ return path === '' ? undefined : path
44
+ }
45
+
46
+ /**
47
+ * Markdown link targets in a reply that name nothing on disk.
48
+ *
49
+ * A relative target resolves against `root`, the main worktree root, since the
50
+ * editor is rooted there. A relative link to a file that exists only inside a
51
+ * linked worktree therefore reads as missing, which is the report the output
52
+ * rule asks for. `exists` stays injected so a test asserts against a fake disk.
53
+ */
54
+ export function findMissingReplyLinks(
55
+ reply: string,
56
+ root: string,
57
+ exists: (path: string) => boolean,
58
+ ): MissingLink[] {
59
+ const checked = new Map<string, MissingLink | undefined>()
60
+
61
+ for (const line of linesOutsideFences(reply)) {
62
+ for (const match of maskCodeSpans(line).matchAll(LINK)) {
63
+ const destination = match[0].slice(match[0].indexOf('](') + 2, -1)
64
+ const target = linkedPath(destination)
65
+ if (target === undefined) continue
66
+
67
+ const path = isAbsolute(target) ? target : join(root, target)
68
+ if (checked.has(path)) continue
69
+
70
+ checked.set(
71
+ path,
72
+ exists(path) ? undefined : missing(destination, path, root, exists),
73
+ )
74
+ }
75
+ }
76
+
77
+ return [...checked.values()].filter((entry) => entry !== undefined)
78
+ }
79
+
80
+ /**
81
+ * The block reason the wrapper hands back to the session. The closing line
82
+ * covers the one miss that is not a typo: a relative link to a file living only
83
+ * inside a linked worktree, which the editor, rooted at the main checkout,
84
+ * cannot open.
85
+ */
86
+ export function formatMissing(found: readonly MissingLink[]): string {
87
+ return [
88
+ 'The final reply links to paths that do not exist on disk:',
89
+ ...found.map((entry) =>
90
+ entry.suggestion === undefined
91
+ ? `- ${entry.destination}`
92
+ : `- ${entry.destination} (the file is at ${entry.suggestion})`,
93
+ ),
94
+ '',
95
+ 'Post the corrected links in a short follow-up. Use the full path from the project root, and the absolute path for a file inside a linked worktree.',
96
+ ].join('\n')
97
+ }
98
+
99
+ function missing(
100
+ destination: string,
101
+ path: string,
102
+ root: string,
103
+ exists: (path: string) => boolean,
104
+ ): MissingLink {
105
+ const records = WORKTREE_RECORDS.exec(path)?.[1]
106
+ const relocated = records === undefined ? undefined : join(root, records)
107
+ return relocated !== undefined && exists(relocated)
108
+ ? { destination, path, suggestion: relocated }
109
+ : { destination, path }
110
+ }