@erclx/canon 5.35.0 → 5.37.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 (238) hide show
  1. package/README.md +11 -11
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +6 -0
  4. package/claude/skills/backlog-triage/SKILL.md +7 -7
  5. package/claude/skills/build-in-slices/SKILL.md +6 -0
  6. package/claude/skills/build-in-slices/references/adopted.md +3 -0
  7. package/claude/skills/canon-cli/REQUIREMENT.md +2 -2
  8. package/claude/skills/canon-cli/SKILL.md +5 -1
  9. package/claude/skills/canon-feedback/SKILL.md +5 -0
  10. package/claude/skills/canon-feedback-triage/SKILL.md +3 -0
  11. package/claude/skills/canvas-board/REQUIREMENT.md +1 -1
  12. package/claude/skills/canvas-board/SKILL.md +5 -2
  13. package/claude/skills/canvas-board/references/canvas.md +12 -0
  14. package/claude/skills/canvas-board/references/design.md +53 -36
  15. package/claude/skills/ci-workflow/SKILL.md +5 -0
  16. package/claude/skills/code-craft/SKILL.md +6 -0
  17. package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
  18. package/claude/skills/codebase-layout/SKILL.md +2 -2
  19. package/claude/skills/context-fold/references/context.md +48 -34
  20. package/claude/skills/create-rule/REQUIREMENT.md +6 -8
  21. package/claude/skills/create-rule/SKILL.md +19 -17
  22. package/claude/skills/create-rule/references/rule.md +17 -23
  23. package/claude/skills/create-skill/SKILL.md +11 -2
  24. package/claude/skills/create-skill/references/skill-disclosure.md +26 -0
  25. package/claude/skills/create-skill/references/skill-paths.md +3 -0
  26. package/claude/skills/create-skill/references/skill-reference.md +12 -0
  27. package/claude/skills/create-skill/references/skill.md +26 -25
  28. package/claude/skills/deprecation-migration/SKILL.md +6 -0
  29. package/claude/skills/deprecation-migration/references/adopted.md +3 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +6 -6
  31. package/claude/skills/design-taste/SKILL.md +8 -2
  32. package/claude/skills/design-taste/references/craft.md +4 -1
  33. package/claude/skills/design-taste/references/preflight.md +18 -2
  34. package/claude/skills/design-taste/references/tells.md +1 -1
  35. package/claude/skills/docs-sync/references/docs.md +6 -0
  36. package/claude/skills/docs-sync/references/readme.md +15 -0
  37. package/claude/skills/draft-and-pick/SKILL.md +12 -0
  38. package/claude/skills/draft-doc/references/context.md +1 -1
  39. package/claude/skills/draft-doc/references/readme.md +3 -0
  40. package/claude/skills/draft-figure/references/mermaid.md +6 -0
  41. package/claude/skills/draft-identity/SKILL.md +3 -2
  42. package/claude/skills/draft-slides/SKILL.md +3 -0
  43. package/claude/skills/git-branch/references/branch.md +16 -11
  44. package/claude/skills/git-commit/references/commit.md +19 -11
  45. package/claude/skills/git-commit/references/versioning.md +9 -0
  46. package/claude/skills/git-issue/references/issue.md +12 -9
  47. package/claude/skills/git-pr/SKILL.md +5 -0
  48. package/claude/skills/git-pr/references/pr.md +24 -6
  49. package/claude/skills/git-ship/SKILL.md +3 -0
  50. package/claude/skills/git-split/SKILL.md +6 -0
  51. package/claude/skills/git-stage/SKILL.md +3 -0
  52. package/claude/skills/git-worktree/SKILL.md +6 -6
  53. package/claude/skills/markdown-craft/references/markdown.md +27 -8
  54. package/claude/skills/plan-feature/SKILL.md +10 -1
  55. package/claude/skills/plan-feature/references/plan.md +20 -8
  56. package/claude/skills/plan-groundwork/SKILL.md +6 -0
  57. package/claude/skills/plan-groundwork/references/groundwork.md +10 -7
  58. package/claude/skills/plan-intake/references/intake.md +6 -6
  59. package/claude/skills/read-frames/SKILL.md +3 -0
  60. package/claude/skills/review-craft/SKILL.md +3 -0
  61. package/claude/skills/review-craft/references/ui.md +1 -1
  62. package/claude/skills/review-ui/SKILL.md +6 -3
  63. package/claude/skills/role-orchestrator/SKILL.md +3 -0
  64. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +7 -4
  65. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +3 -0
  66. package/claude/skills/role-planner/SKILL.md +3 -0
  67. package/claude/skills/role-worker/SKILL.md +12 -0
  68. package/claude/skills/search-craft/SKILL.md +3 -0
  69. package/claude/skills/systematic-debugging/SKILL.md +6 -0
  70. package/claude/skills/target-check/references/domains.md +5 -0
  71. package/claude/skills/target-setup/SKILL.md +5 -0
  72. package/claude/skills/task-board/references/tasks.md +9 -6
  73. package/claude/skills/task-complete/references/archive.md +3 -0
  74. package/claude/skills/teach-workspace/SKILL.md +3 -0
  75. package/claude/skills/teach-workspace/references/glossary.md +3 -0
  76. package/claude/skills/teach-workspace/references/lesson-build.md +3 -0
  77. package/claude/skills/teach-workspace/references/lesson-craft.md +3 -0
  78. package/claude/skills/teach-workspace/references/teach.md +8 -8
  79. package/claude/skills/test-craft/SKILL.md +7 -2
  80. package/claude/skills/ux-audit/REQUIREMENT.md +1 -1
  81. package/claude/skills/ux-audit/SKILL.md +2 -2
  82. package/claude/skills/ux-measure/REQUIREMENT.md +1 -1
  83. package/claude/skills/video-craft/SKILL.md +6 -0
  84. package/claude/skills/write-human/SKILL.md +9 -0
  85. package/claude/skills/write-human/references/machine-tells.md +12 -0
  86. package/docs/agents/capture.md +2 -0
  87. package/docs/agents/commands.md +59 -56
  88. package/docs/agents/comments.md +1 -1
  89. package/docs/agents/context-audit-checks.md +6 -0
  90. package/docs/agents/context-audit.md +8 -8
  91. package/docs/agents/context-classify.md +2 -0
  92. package/docs/agents/drift-surfaces.md +5 -5
  93. package/docs/agents/gate.md +6 -7
  94. package/docs/agents/gov-detect.md +1 -1
  95. package/docs/agents/index.md +2 -2
  96. package/docs/agents/install-and-sync.md +6 -6
  97. package/docs/agents/markdown-audit-weight.md +11 -3
  98. package/docs/agents/markdown-audit.md +14 -2
  99. package/docs/agents/output-shape.md +1 -1
  100. package/docs/agents/overview.md +2 -2
  101. package/docs/agents/pr-frames.md +17 -3
  102. package/docs/agents/rule-citations.md +5 -10
  103. package/docs/agents/scripting.md +1 -1
  104. package/docs/agents/sessions-transfer.md +8 -6
  105. package/docs/agents/skills-audit.md +9 -1
  106. package/docs/target/projects.md +2 -2
  107. package/docs/target/scenarios.md +5 -5
  108. package/docs/target/standards-moved.md +20 -18
  109. package/docs/workflow/ai-workflow.md +4 -1
  110. package/docs/workflow/visual-design-workflow.md +8 -3
  111. package/governance/detect.toml +32 -32
  112. package/governance/renames.toml +44 -0
  113. package/governance/rules/lib/{305-e2e-reliability.md → e2e-reliability.md} +1 -1
  114. package/governance/rules/lib/{306-test-scope.md → test-scope.md} +2 -2
  115. package/governance/rules/ui/{410-a11y.md → a11y.md} +1 -1
  116. package/governance/rules/ui/{440-surface-capture.md → surface-capture.md} +3 -3
  117. package/governance/stacks/base.toml +6 -6
  118. package/governance/stacks/go.toml +1 -1
  119. package/governance/stacks/node.toml +1 -1
  120. package/governance/stacks/php.toml +1 -1
  121. package/governance/stacks/python.toml +1 -1
  122. package/package.json +2 -2
  123. package/scripts/core/install-check.sh +2 -7
  124. package/src/capture/frame-values.ts +3 -6
  125. package/src/cli/help-all.ts +96 -0
  126. package/src/cli/help.ts +28 -27
  127. package/src/cli.ts +8 -1
  128. package/src/commands/claude.ts +29 -0
  129. package/src/commands/context.ts +29 -4
  130. package/src/commands/design.ts +22 -1
  131. package/src/commands/gate.ts +1 -8
  132. package/src/commands/gov.ts +25 -3
  133. package/src/commands/init.ts +10 -1
  134. package/src/commands/markdown.ts +148 -3
  135. package/src/commands/pr.ts +17 -10
  136. package/src/commands/records.ts +1 -1
  137. package/src/commands/sync.ts +11 -0
  138. package/src/commands/tooling.ts +8 -0
  139. package/src/context/audit.ts +83 -0
  140. package/src/context/classify/prompts.ts +6 -1
  141. package/src/context/gate.ts +6 -3
  142. package/src/design/budget.ts +14 -10
  143. package/src/design/document.ts +5 -5
  144. package/src/design/parse.ts +21 -4
  145. package/src/design/render.ts +3 -3
  146. package/src/gate/measures.ts +187 -2
  147. package/src/gate/sequencer.ts +0 -4
  148. package/src/gate/stages.ts +32 -27
  149. package/src/git/files.ts +30 -0
  150. package/src/gov/adapter.ts +2 -2
  151. package/src/gov/citations.ts +10 -33
  152. package/src/gov/install.ts +2 -2
  153. package/src/gov/list.ts +1 -1
  154. package/src/gov/payload.ts +1 -1
  155. package/src/gov/stacks.ts +4 -4
  156. package/src/inventory/subjects.ts +1 -1
  157. package/src/markdown/ceiling.ts +6 -1
  158. package/src/markdown/colons.ts +86 -0
  159. package/src/markdown/gate.ts +25 -0
  160. package/src/markdown/growth.ts +43 -0
  161. package/src/markdown/heading-case.ts +74 -0
  162. package/src/markdown/stacks.ts +74 -0
  163. package/src/markdown/structure.ts +72 -4
  164. package/src/migrate/rule-layout.ts +12 -12
  165. package/src/pr/frames.ts +66 -1
  166. package/src/shipped/references.ts +14 -14
  167. package/src/sync/check.ts +23 -23
  168. package/src/sync/copy.ts +4 -5
  169. package/tooling/astro/configs/tsconfig.json +1 -1
  170. package/tooling/astro/manifest.toml +0 -1
  171. package/tooling/astro/reference.md +2 -10
  172. package/tooling/base/configs/scripts/verify.json +1 -1
  173. package/tooling/base/configs/scripts/verify.ts +23 -13
  174. package/tooling/base/manifest.toml +6 -5
  175. package/tooling/base/reference.md +15 -22
  176. package/tooling/base/seeds/.github/workflows/verify.yml +0 -6
  177. package/tooling/base/seeds/.lintstagedrc +1 -2
  178. package/tooling/base/seeds/canon/context/ci.md +7 -8
  179. package/tooling/base/seeds/canon/context/development.md +10 -10
  180. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +9 -1
  181. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +27 -2
  182. package/tooling/claude/seeds/CLAUDE.md +1 -1
  183. package/tooling/claude/seeds/canon/DESIGN.md +24 -37
  184. package/tooling/shell/configs/.github/workflows/shell.yml +30 -0
  185. package/tooling/shell/manifest.toml +12 -0
  186. package/tooling/shell/reference.md +46 -0
  187. package/tooling/vite-react/configs/tsconfig.json +1 -1
  188. package/tooling/vite-react/configs/vitest.config.ts +1 -1
  189. package/tooling/vite-react/manifest.toml +0 -1
  190. package/tooling/vite-react/reference.md +2 -10
  191. package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -5
  192. package/tooling/web/configs/.github/workflows/verify.yml +0 -6
  193. package/tooling/web/configs/src/test/setup.ts +1 -1
  194. package/tooling/web/manifest.toml +4 -4
  195. package/tooling/web/reference.md +4 -4
  196. package/tooling/base/configs/scripts/verify.sh +0 -2
  197. /package/governance/rules/canon/{606-git.md → git.md} +0 -0
  198. /package/governance/rules/canon/{611-indexes.md → indexes.md} +0 -0
  199. /package/governance/rules/canon/{604-scratch.md → scratch.md} +0 -0
  200. /package/governance/rules/canon/{602-tasks.md → task-scope.md} +0 -0
  201. /package/governance/rules/canon/{605-worktrees.md → worktrees.md} +0 -0
  202. /package/governance/rules/claude/{566-output.md → output.md} +0 -0
  203. /package/governance/rules/code/{000-code.md → routing.md} +0 -0
  204. /package/governance/rules/code/{010-testing.md → testing.md} +0 -0
  205. /package/governance/rules/framework/{210-astro.md → astro.md} +0 -0
  206. /package/governance/rules/framework/{220-fastapi.md → fastapi.md} +0 -0
  207. /package/governance/rules/framework/{230-nextjs.md → nextjs.md} +0 -0
  208. /package/governance/rules/framework/{200-react.md → react.md} +0 -0
  209. /package/governance/rules/framework/{260-shadcn.md → shadcn.md} +0 -0
  210. /package/governance/rules/framework/{250-tailwind.md → tailwind.md} +0 -0
  211. /package/governance/rules/lang/{130-go.md → go.md} +0 -0
  212. /package/governance/rules/lang/{140-php.md → php.md} +0 -0
  213. /package/governance/rules/lang/{110-python.md → python.md} +0 -0
  214. /package/governance/rules/lang/{100-typescript.md → typescript.md} +0 -0
  215. /package/governance/rules/lib/{370-database.md → database.md} +0 -0
  216. /package/governance/rules/lib/{340-pydantic.md → pydantic.md} +0 -0
  217. /package/governance/rules/lib/{360-security-server.md → security-server.md} +0 -0
  218. /package/governance/rules/lib/{350-security-web.md → security-web.md} +0 -0
  219. /package/governance/rules/lib/{320-tanstack-query.md → tanstack-query.md} +0 -0
  220. /package/governance/rules/lib/{335-testing-go.md → testing-go.md} +0 -0
  221. /package/governance/rules/lib/{336-testing-php.md → testing-php.md} +0 -0
  222. /package/governance/rules/lib/{330-testing-py.md → testing-py.md} +0 -0
  223. /package/governance/rules/lib/{300-testing-ts.md → testing-ts.md} +0 -0
  224. /package/governance/rules/lib/{310-zod.md → zod.md} +0 -0
  225. /package/governance/rules/tooling/{700-ci-workflow.md → ci-workflow.md} +0 -0
  226. /package/governance/rules/tooling/{790-code-comments.md → code-comments.md} +0 -0
  227. /package/governance/rules/tooling/{780-config-comments.md → config-comments.md} +0 -0
  228. /package/governance/rules/tooling/{775-dependencies.md → dependencies.md} +0 -0
  229. /package/governance/rules/ui/{480-canvas.md → canvas.md} +0 -0
  230. /package/governance/rules/ui/{400-ui.md → copy.md} +0 -0
  231. /package/governance/rules/ui/{460-design-taste.md → design-taste.md} +0 -0
  232. /package/governance/rules/ui/{420-forms.md → forms.md} +0 -0
  233. /package/governance/rules/ui/{465-interface-casing.md → interface-casing.md} +0 -0
  234. /package/governance/rules/ui/{450-link-behavior.md → link-behavior.md} +0 -0
  235. /package/governance/rules/ui/{470-motion.md → motion.md} +0 -0
  236. /package/governance/rules/ui/{430-ux-completeness.md → ux-completeness.md} +0 -0
  237. /package/tooling/{base → shell}/configs/.editorconfig +0 -0
  238. /package/tooling/{base → shell}/configs/.shellcheckrc +0 -0
@@ -25,6 +25,23 @@ export interface DesignDoc {
25
25
  iconography: string
26
26
  }
27
27
 
28
+ /**
29
+ * The three headings the standard renamed toward Google's DESIGN.md, each read
30
+ * as the section it became. Records the seed and `canvas-board` wrote before
31
+ * the rename sit in targets and will not rename themselves, and an unmatched
32
+ * heading parses to nothing without saying so. The field names on `DesignDoc`
33
+ * keep the old words, since the preview reading them is retired separately.
34
+ */
35
+ export const LEGACY_HEADINGS: Readonly<Record<string, string>> = {
36
+ Personality: 'Overview',
37
+ Color: 'Colors',
38
+ Borders: 'Shapes',
39
+ }
40
+
41
+ export function canonicalHeading(heading: string): string {
42
+ return LEGACY_HEADINGS[heading] ?? heading
43
+ }
44
+
28
45
  const VERIFY_TAG = /\s*\?\s*verify\s*$/
29
46
 
30
47
  /** A cell whose whole content is one balanced code span and nothing else. */
@@ -34,11 +51,11 @@ export function parseDesignDoc(path: string): DesignDoc {
34
51
  const raw = readFileSync(path, 'utf8')
35
52
  const sections = splitSections(raw)
36
53
  return {
37
- personality: prose(sections['Personality']),
38
- color: table(sections['Color']),
54
+ personality: prose(sections['Overview']),
55
+ color: table(sections['Colors']),
39
56
  typography: table(sections['Typography']),
40
57
  spacing: table(sections['Spacing']),
41
- borders: table(sections['Borders']),
58
+ borders: table(sections['Shapes']),
42
59
  motion: prose(sections['Motion']),
43
60
  iconography: prose(sections['Iconography']),
44
61
  }
@@ -53,7 +70,7 @@ function splitSections(raw: string): Record<string, string> {
53
70
  const match = line.match(/^##\s+(.+?)\s*$/)
54
71
  if (match) {
55
72
  if (current) out[current] = buffer.join('\n')
56
- current = match[1]
73
+ current = canonicalHeading(match[1])
57
74
  buffer = []
58
75
  } else if (current) {
59
76
  buffer.push(line)
@@ -268,7 +268,7 @@ ${sections.join('\n')}
268
268
 
269
269
  function sectionPersonality(text: string): string {
270
270
  if (!text) return ''
271
- return `<h2>Personality</h2>\n<p>${escape(text)}</p>`
271
+ return `<h2>Overview</h2>\n<p>${escape(text)}</p>`
272
272
  }
273
273
 
274
274
  function sectionColor(rows: Row[]): string {
@@ -283,7 +283,7 @@ function sectionColor(rows: Row[]): string {
283
283
  return `<tr><td>${swatch}${cellText(r, 'Role')}</td><td>${cellText(r, 'Intent')}</td><td><code>${shown}</code>${mark(r, 'Value')}</td></tr>`
284
284
  })
285
285
  .join('\n')
286
- return `<h2>Color</h2>\n<table><thead><tr><th>Role</th><th>Intent</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`
286
+ return `<h2>Colors</h2>\n<table><thead><tr><th>Role</th><th>Intent</th><th>Value</th></tr></thead><tbody>${body}</tbody></table>`
287
287
  }
288
288
 
289
289
  function sectionTypography(rows: Row[]): string {
@@ -325,7 +325,7 @@ function sectionBorders(rows: Row[]): string {
325
325
  return `<tr><td>${cellText(r, 'Role')}</td><td><code>${escape(radius)}</code>${mark(r, 'Radius')}</td><td><code>${escape(width)}</code>${mark(r, 'Width')}</td><td>${cellText(r, 'When used')}</td><td>${sample}</td></tr>`
326
326
  })
327
327
  .join('\n')
328
- return `<h2>Borders</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
328
+ return `<h2>Shapes</h2>\n<table><thead><tr><th>Role</th><th>Radius</th><th>Width</th><th>When used</th><th>Sample</th></tr></thead><tbody>${body}</tbody></table>`
329
329
  }
330
330
 
331
331
  /** A section the record writes as one-line rules draws them as a list. */
@@ -18,9 +18,18 @@ 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 { listRepositoryFiles } from '@/git/files'
21
+ import {
22
+ listChangedFiles,
23
+ listFilesAtRef,
24
+ listRepositoryFiles,
25
+ readFileAtRef,
26
+ resolveBaseRef,
27
+ } from '@/git/files'
22
28
  import { ceilingFindings } from '@/markdown/ceiling'
23
29
  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'
24
33
  import { CHECKPOINTS } from '@/markdown/structure'
25
34
  import {
26
35
  isShippedCorpus,
@@ -501,6 +510,182 @@ export const documentCeiling: Measure = async (ctx) => {
501
510
  }
502
511
  }
503
512
 
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
+
504
689
  /**
505
690
  * Whether a path's stem, its basename up to the first `.`, repeats its
506
691
  * immediate parent folder, either whole or as a `<folder>-` prefix.
@@ -659,7 +844,7 @@ export const markdownBans: Measure = async (ctx) => {
659
844
  return {
660
845
  emissions: [output(frame.all)],
661
846
  failure:
662
- 'Markdown prose carries a banned character, or a relative link resolves to nothing on disk. Rewrite the sentence or fix the link, and reach for a code span only where the token is genuinely an identifier under discussion.',
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.',
663
848
  }
664
849
  }
665
850
 
@@ -113,8 +113,6 @@ export function readProcLoad(): LoadReading | undefined {
113
113
  }
114
114
 
115
115
  export interface GateContext extends MeasureContext {
116
- /** Whether the format stage writes or checks. */
117
- readonly write: boolean
118
116
  /**
119
117
  * The changed set a scoped stage is read against, or `undefined` where
120
118
  * scoping is off and every stage runs.
@@ -444,8 +442,6 @@ export async function runStages(
444
442
  const results: StageResult[] = []
445
443
 
446
444
  for (const stage of stages) {
447
- if (stage.when !== undefined && !stage.when({ write: ctx.write })) continue
448
-
449
445
  const result = await runStage(stage, ctx)
450
446
  results.push(result)
451
447
  onResult?.(result)
@@ -11,10 +11,12 @@ import {
11
11
  captureStamps,
12
12
  clientCommandCitations,
13
13
  designProse,
14
+ bulletStacks,
14
15
  documentCeiling,
15
16
  folderEchoedNames,
16
17
  markdownBans,
17
18
  type Measure,
19
+ paragraphGrowth,
18
20
  pluginManifests,
19
21
  readmeCitations,
20
22
  recordIdempotence,
@@ -81,11 +83,6 @@ export interface Stage {
81
83
  readonly scope?: RegExp
82
84
  /** What a scoped-out stage says, so a skip never reads as a pass. */
83
85
  readonly skipped?: string
84
- /**
85
- * Whether this stage belongs in the run at all, read off the write grant
86
- * rather than the changed set. Absent means it always belongs.
87
- */
88
- readonly when?: (options: { readonly write: boolean }) => boolean
89
86
  readonly checks: readonly Check[]
90
87
  /**
91
88
  * The machine lock this stage holds while its checks run, so one box runs it
@@ -144,28 +141,15 @@ const script = (name: string, failure: string): Check => ({
144
141
  * whole set at once.
145
142
  */
146
143
  export const STAGES: readonly Stage[] = [
147
- {
148
- id: 'format',
149
- label: 'Formatting',
150
- when: ({ write }) => write,
151
- checks: [
152
- {
153
- kind: 'command',
154
- argv: ['bun', 'run', 'format'],
155
- failure: 'Format failed',
156
- },
157
- ],
158
- success: 'Format applied',
159
- },
160
144
  {
161
145
  id: 'format-check',
162
146
  label: 'Format check',
163
- when: ({ write }) => !write,
164
147
  checks: [
165
148
  {
166
149
  kind: 'command',
167
150
  argv: ['bun', 'run', 'check:format'],
168
- failure: 'Format check failed',
151
+ failure:
152
+ 'Format check failed. Run bun run format and commit the result.',
169
153
  },
170
154
  ],
171
155
  success: 'Format check passed',
@@ -335,19 +319,23 @@ export const STAGES: readonly Stage[] = [
335
319
  checks: [{ kind: 'measure', measure: unreferencedRules }],
336
320
  },
337
321
  {
338
- // Only the citation half of the audit gates. Its length, depth, table, and
339
- // index findings are judgment thresholds, and failing a push on one would
340
- // make the stage something to route around. The one length reading that
341
- // gates is the whole-document ceiling, which the Document ceiling stage
342
- // below owns rather than this audit.
322
+ // The facts the audit can answer from the files gate: a cited path that
323
+ // 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.
325
+ // Its length, depth, bullet, table, and provenance findings are judgment
326
+ // thresholds, and failing a push on one would make the stage something to
327
+ // route around. The length readings that gate sit in their own stages
328
+ // below, apart from the audit's exit code: the whole-document ceiling, the
329
+ // bullet cap, and the growth of a paragraph or a bullet past its
330
+ // checkpoint.
343
331
  id: 'context-citations',
344
332
  label: 'Context citations',
345
333
  checks: [
346
334
  {
347
335
  kind: 'cli',
348
- argv: ['context', 'audit', '--citations-only'],
336
+ argv: ['context', 'audit', '--gate'],
349
337
  failure:
350
- 'A cited context path does not resolve. Run bun src/cli.ts context audit.',
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.',
351
339
  },
352
340
  ],
353
341
  success: 'Context citations resolve',
@@ -366,6 +354,23 @@ export const STAGES: readonly Stage[] = [
366
354
  label: 'Document ceiling',
367
355
  checks: [{ kind: 'measure', measure: documentCeiling }],
368
356
  },
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
+ },
369
374
  {
370
375
  // Unscoped, since any branch can add a file anywhere and one git ls-files
371
376
  // costs nothing. Reads paths rather than markdown, so it sits here rather
package/src/git/files.ts CHANGED
@@ -165,3 +165,33 @@ export async function listRepositoryFiles(
165
165
 
166
166
  return [...new Set([...tracked, ...untracked])].sort()
167
167
  }
168
+
169
+ /**
170
+ * A file as `ref` holds it, or undefined where the ref holds no such path or
171
+ * git cannot answer. A path absent at the ref and a failed read are the same
172
+ * answer for a caller asking what the copy before this branch said.
173
+ */
174
+ export async function readFileAtRef(
175
+ root: string,
176
+ ref: string,
177
+ rel: string,
178
+ ): Promise<string | undefined> {
179
+ return git(root, ['show', `${ref}:${rel}`])
180
+ }
181
+
182
+ /** Every path under `prefix` that `ref` holds, or undefined where git cannot answer. */
183
+ export async function listFilesAtRef(
184
+ root: string,
185
+ ref: string,
186
+ prefix: string,
187
+ ): Promise<string[] | undefined> {
188
+ const listed = await git(root, [
189
+ 'ls-tree',
190
+ '-r',
191
+ '--name-only',
192
+ ref,
193
+ '--',
194
+ prefix,
195
+ ])
196
+ return listed === undefined ? undefined : listed.split('\n').filter(Boolean)
197
+ }
@@ -98,8 +98,8 @@ export function createGovAdapter(root: string): SyncAdapter {
98
98
  }
99
99
 
100
100
  /**
101
- * Where the target already holds a rule, in whichever band folder. A
102
- * successor held outside the source's band stays where it is, the same way
101
+ * Where the target already holds a rule, in whichever rule folder. A
102
+ * successor held outside the source's folder stays where it is, the same way
103
103
  * `locateSource` syncs a moved rule in place, rather than gaining a second copy.
104
104
  *
105
105
  * Holding the predecessor is the whole entitlement test, so no stamped chain
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
- import { dirname, join, resolve } from 'node:path'
3
+ import { join, resolve } from 'node:path'
4
4
  import { $ } from 'bun'
5
5
  import { isMarked } from '@/sweep/exempt-marker'
6
6
  import { gitEnv } from '@/git/env'
@@ -41,14 +41,11 @@ export const RULE_DIRS: readonly string[] = [
41
41
  export const GLOB_CORPUS = join('internal', 'rules')
42
42
 
43
43
  /**
44
- * How the citation was written, kept on the finding because the two resolve
45
- * against different roots and a reader repairing one needs to know which.
46
- *
47
- * `path` is a backticked repository path. `sibling` is a rule naming another
48
- * rule by filename alone, which resolves inside the folder the citing rule
49
- * sits in.
44
+ * How the citation was written, being a backticked repository path. A bare
45
+ * filename is no form, since with no folder around it nothing tells a rule
46
+ * from a convention such as `index.md`.
50
47
  */
51
- export type CitationForm = 'path' | 'sibling'
48
+ export type CitationForm = 'path'
52
49
 
53
50
  /**
54
51
  * What the sweep decided about one citation.
@@ -115,9 +112,6 @@ const FENCE = /^\s*(?:```|~~~)/
115
112
 
116
113
  const FRONTMATTER_DELIMITER = /^---\s*$/
117
114
 
118
- /** A rule filename, which is how a rule names a sibling with no folder around it. */
119
- const SIBLING_RULE = /^\d{3}-[a-z0-9-]+\.md$/
120
-
121
115
  /**
122
116
  * A character that puts the span outside this repository, or outside paths
123
117
  * altogether.
@@ -160,24 +154,7 @@ function namesAFile(span: string): boolean {
160
154
  export function classifySpan(span: string): CitationForm | undefined {
161
155
  if (isNotRepositoryPath(span)) return undefined
162
156
  if (!namesAFile(span)) return undefined
163
- if (span.includes('/')) return 'path'
164
- return SIBLING_RULE.test(span) ? 'sibling' : undefined
165
- }
166
-
167
- /**
168
- * Where a citation could answer, which is wherever the reader's own tools look
169
- * and nowhere else.
170
- *
171
- * A sibling resolves inside the folder the citing rule sits in, since that is
172
- * the only place a bare rule filename means anything.
173
- */
174
- function candidatesFor(
175
- form: CitationForm,
176
- cited: string,
177
- ruleFile: string,
178
- ): string[] {
179
- if (form === 'sibling') return [join(dirname(ruleFile), cited)]
180
- return [cited]
157
+ return span.includes('/') ? 'path' : undefined
181
158
  }
182
159
 
183
160
  interface RawCitation {
@@ -268,7 +245,7 @@ function isGoverned(cited: string, declared: readonly string[]): boolean {
268
245
  *
269
246
  * Anchored on the entry shape rather than searched for as a substring. A bare
270
247
  * scan for the glob text finds it in the `description:` line first wherever a
271
- * rule names what it governs in prose, which is how `596-claude-md.md` reported
248
+ * rule names what it governs in prose, which is how `seed-mirror.md` reported
272
249
  * its `CLAUDE.md` glob against line 2 instead of line 4.
273
250
  */
274
251
  const LIST_ENTRY = /^\s*-\s*(?:'([^']*)'|"([^"]*)"|(\S.*?))\s*$/
@@ -410,7 +387,7 @@ export async function readCitations(root: string): Promise<CitationReport> {
410
387
  }
411
388
 
412
389
  for (const raw of collectCitations(text)) {
413
- const candidates = candidatesFor(raw.form, raw.cited, file)
390
+ const candidates = [raw.cited]
414
391
  const resolved = candidates.find((path) =>
415
392
  existsSync(resolve(root, path)),
416
393
  )
@@ -439,7 +416,7 @@ function classifyStatus(
439
416
  ): CitationStatus {
440
417
  if (resolved !== undefined) return 'resolved'
441
418
  if (raw.marked) return 'exempt'
442
- if (raw.form === 'path' && isGoverned(raw.cited, declared)) return 'governed'
419
+ if (isGoverned(raw.cited, declared)) return 'governed'
443
420
  return 'dead'
444
421
  }
445
422
 
@@ -456,7 +433,7 @@ async function applyIgnored(
456
433
  measured: Extract<CitationReport, { kind: 'measured' }>,
457
434
  ): Promise<CitationReport> {
458
435
  const pending = measured.citations.filter(
459
- (citation) => citation.status === 'dead' && citation.form === 'path',
436
+ (citation) => citation.status === 'dead',
460
437
  )
461
438
  if (pending.length === 0) return measured
462
439
 
@@ -28,7 +28,7 @@ export function canonRulesDir(target: string): string {
28
28
 
29
29
  /**
30
30
  * This repository's own toolkit-only rules, installed beside `canon/` rather
31
- * than interleaved into its bands. No target ever holds this folder.
31
+ * than interleaved into its folders. No target ever holds this folder.
32
32
  */
33
33
  export function installedInternalRulesDir(target: string): string {
34
34
  return join(installedRulesDir(target), 'internal')
@@ -134,7 +134,7 @@ export function lookupRules(
134
134
 
135
135
  /**
136
136
  * Copies each rule into the subdirectory it was authored in, so the installed
137
- * tree keeps the band structure `governance/rules/` carries. Returns the
137
+ * tree keeps the rule folder structure `governance/rules/` carries. Returns the
138
138
  * target-relative paths the timeline prints.
139
139
  *
140
140
  * `destSubdir` picks which wrapper the rule lands under: `canon` for every
package/src/gov/list.ts CHANGED
@@ -74,7 +74,7 @@ export function buildStackEntries(root: string): StackEntry[] {
74
74
 
75
75
  /**
76
76
  * Reads the catalog straight off the source tree. A rule's domain is the
77
- * subdirectory it sits in, which is the band grouping install preserves, and
77
+ * subdirectory it sits in, which is the rule folder grouping install preserves, and
78
78
  * its `detect` entry is the evidence that proposes it, null for a rule
79
79
  * `detect.toml` does not name.
80
80
  */
@@ -5,7 +5,7 @@ import { stripFrontmatter } from '@/markdown/frontmatter'
5
5
  /**
6
6
  * Lists installed rules in the order the payload concatenates them. Sorting by
7
7
  * path keeps the numeric filename prefixes ordered inside each subdirectory,
8
- * which is how the rule bands are meant to read.
8
+ * which is how the rule folders are meant to read.
9
9
  */
10
10
  export function listRuleFiles(rulesDir: string): string[] {
11
11
  return [
package/src/gov/stacks.ts CHANGED
@@ -78,9 +78,9 @@ export function loadGovStack(
78
78
  * entry resolves to itself, so a folder and a slug reach the caller as one
79
79
  * shape rather than two the caller has to tell apart.
80
80
  *
81
- * The directory wins over a rule file of the same name. They cannot collide
82
- * while `claude/skills/create-rule/references/rule.md` requires a numeric prefix on a rule slug, since a
83
- * band folder carries none.
81
+ * The directory wins over a rule file of the same name, which would leave
82
+ * that rule unreachable from a stack entry. `src/gov/rule-layout.test.ts`
83
+ * fails on any rule basename equal to a folder name, so the two never meet.
84
84
  */
85
85
  export function expandStackEntry(root: string, entry: string): string[] {
86
86
  const dir = join(rulesSourceDir(root), entry)
@@ -163,7 +163,7 @@ export function unreferencedRules(root: string): string[] {
163
163
  * does not hold. `resolveRules` already walks a stack's `extends` ancestors,
164
164
  * so reading its leaf entry is enough; no second walk resolves the chain
165
165
  * itself. A stack the toolkit no longer ships resolves to nothing rather than
166
- * throwing, the same way `readNewRules`'s band fallback already treats it.
166
+ * throwing, the same way `readNewRules`'s folder fallback already treats it.
167
167
  *
168
168
  * A held rule's declared successor counts as held, since the sync that
169
169
  * renames the old file installs it.
@@ -27,7 +27,7 @@ export interface Subject {
27
27
 
28
28
  /**
29
29
  * Reports what each element changes about itself when it takes focus, which is
30
- * the reading `governance/rules/ui/410-a11y.md` states three rules against and
30
+ * the reading `governance/rules/ui/a11y.md` states three rules against and
31
31
  * nothing measures.
32
32
  *
33
33
  * The difference between rest and focus is the treatment, rather than the
@@ -34,9 +34,14 @@ export interface CeilingFinding {
34
34
  readonly exempt: string | null
35
35
  }
36
36
 
37
+ /** Whether the release tool writes this document, which no measure reads. */
38
+ export function isChangelog(rel: string): boolean {
39
+ return basename(rel) === CHANGELOG
40
+ }
41
+
37
42
  /** The reason a document is exempt from the ceiling, or null where it is not. */
38
43
  export function lengthExemption(rel: string, source: string): string | null {
39
- if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
44
+ if (isChangelog(rel)) return CHANGELOG_REASON
40
45
 
41
46
  for (const line of bodyLines(source)) {
42
47
  if (line.fenced) continue