@erclx/canon 5.38.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 (149) 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-pr/SKILL.md +56 -44
  48. package/claude/skills/git-ship/SKILL.md +43 -34
  49. package/claude/skills/git-ship/references/ci-watch.md +31 -0
  50. package/claude/skills/git-split/SKILL.md +28 -100
  51. package/claude/skills/git-split/references/independent.md +40 -0
  52. package/claude/skills/git-split/references/stacked.md +73 -0
  53. package/claude/skills/git-stage/SKILL.md +9 -7
  54. package/claude/skills/git-worktree/SKILL.md +27 -49
  55. package/claude/skills/git-worktree/references/cleanup.md +55 -0
  56. package/claude/skills/markdown-craft/SKILL.md +2 -2
  57. package/claude/skills/markdown-craft/references/markdown.md +82 -53
  58. package/claude/skills/plan-feature/SKILL.md +52 -46
  59. package/claude/skills/plan-feature/references/batches.md +46 -0
  60. package/claude/skills/plan-groundwork/SKILL.md +60 -74
  61. package/claude/skills/plan-groundwork/references/output.md +48 -0
  62. package/claude/skills/plan-intake/SKILL.md +28 -21
  63. package/claude/skills/plan-intake-answer/SKILL.md +14 -13
  64. package/claude/skills/read-frames/SKILL.md +2 -0
  65. package/claude/skills/record-screencast/SKILL.md +31 -11
  66. package/claude/skills/review-address/SKILL.md +76 -158
  67. package/claude/skills/review-address/references/rebase-only.md +34 -0
  68. package/claude/skills/review-craft/SKILL.md +27 -11
  69. package/claude/skills/review-pr/SKILL.md +91 -40
  70. package/claude/skills/review-ui/SKILL.md +62 -37
  71. package/claude/skills/review-ui/references/hosted-preview.md +30 -0
  72. package/claude/skills/role-orchestrator/SKILL.md +105 -74
  73. package/claude/skills/role-orchestrator/references/orchestrator-filing.md +26 -0
  74. package/claude/skills/role-planner/SKILL.md +66 -64
  75. package/claude/skills/role-reviewer/SKILL.md +57 -46
  76. package/claude/skills/role-worker/SKILL.md +68 -63
  77. package/claude/skills/search-craft/SKILL.md +7 -5
  78. package/claude/skills/session-compact/SKILL.md +16 -11
  79. package/claude/skills/session-map/SKILL.md +31 -10
  80. package/claude/skills/session-relay/SKILL.md +32 -65
  81. package/claude/skills/session-resume/SKILL.md +18 -13
  82. package/claude/skills/session-worktree/SKILL.md +70 -42
  83. package/claude/skills/systematic-debugging/SKILL.md +6 -1
  84. package/claude/skills/target-check/SKILL.md +13 -6
  85. package/claude/skills/target-setup/SKILL.md +39 -27
  86. package/claude/skills/task-board/SKILL.md +68 -60
  87. package/claude/skills/task-board/references/create-report.md +36 -0
  88. package/claude/skills/task-complete/SKILL.md +20 -18
  89. package/claude/skills/teach-workspace/SKILL.md +61 -33
  90. package/claude/skills/test-craft/SKILL.md +2 -1
  91. package/claude/skills/test-first/SKILL.md +15 -9
  92. package/claude/skills/ui-checklist/SKILL.md +18 -16
  93. package/claude/skills/ux-audit/SKILL.md +9 -10
  94. package/claude/skills/ux-measure/SKILL.md +22 -18
  95. package/claude/skills/video-craft/SKILL.md +15 -7
  96. package/claude/skills/write-human/SKILL.md +9 -1
  97. package/claude/skills/youtube-transcripts/SKILL.md +2 -1
  98. package/docs/agents/context-audit-checks.md +19 -1
  99. package/docs/agents/context-audit.md +2 -2
  100. package/docs/agents/driver.md +1 -1
  101. package/docs/agents/hooks.md +21 -1
  102. package/docs/agents/index.md +10 -14
  103. package/docs/agents/indexes.md +25 -29
  104. package/docs/agents/install-and-sync.md +124 -235
  105. package/docs/agents/markdown-audit-density.md +39 -0
  106. package/docs/agents/markdown-audit-weight.md +9 -7
  107. package/docs/agents/markdown-audit.md +18 -14
  108. package/docs/agents/migrate.md +41 -16
  109. package/docs/agents/output-shape.md +60 -45
  110. package/docs/agents/overview.md +36 -12
  111. package/docs/agents/routing.md +1 -1
  112. package/docs/agents/rule-citations.md +1 -1
  113. package/docs/agents/skills-reach.md +1 -1
  114. package/docs/agents/targets.md +25 -77
  115. package/docs/target/migrations.md +1 -1
  116. package/docs/target/projects.md +2 -2
  117. package/docs/workflow/ai-workflow.md +2 -2
  118. package/docs/workflow/parallel-features.md +2 -2
  119. package/docs/workflow/skill-map.md +1 -1
  120. package/docs/workflow/visual-design-workflow.md +1 -1
  121. package/package.json +1 -1
  122. package/src/commands/claude.ts +1 -1
  123. package/src/commands/context.ts +106 -7
  124. package/src/commands/hooks.ts +63 -0
  125. package/src/commands/markdown.ts +169 -32
  126. package/src/context/audit.ts +256 -5
  127. package/src/context/classify/prompts.ts +1 -1
  128. package/src/context/gate.ts +23 -7
  129. package/src/context/shape-gated.ts +28 -0
  130. package/src/design/document.ts +1 -1
  131. package/src/design/tokens.ts +1 -1
  132. package/src/gate/measures.ts +15 -190
  133. package/src/gate/stages.ts +7 -23
  134. package/src/hooks/reply-links.ts +110 -0
  135. package/src/markdown/density.ts +258 -0
  136. package/src/markdown/gate.ts +57 -18
  137. package/src/markdown/reference-shape.ts +103 -0
  138. package/src/markdown/scope.ts +174 -0
  139. package/src/markdown/structure.ts +9 -59
  140. package/src/shipped/references.ts +1 -1
  141. package/tooling/claude/seeds/.claude/hooks/reply-links.sh +72 -0
  142. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +22 -1
  143. package/tooling/claude/seeds/.claude/settings.json +4 -0
  144. package/docs/agents/commands.md +0 -154
  145. package/docs/agents/docs.md +0 -27
  146. package/docs/agents/drift-surfaces.md +0 -201
  147. package/docs/agents/gov-detect.md +0 -47
  148. package/docs/agents/scripting.md +0 -142
  149. package/src/markdown/growth.ts +0 -43
@@ -1,4 +1,4 @@
1
- import { resolve } from 'node:path'
1
+ import { join, relative, resolve } from 'node:path'
2
2
  import type { Command } from 'commander'
3
3
  import {
4
4
  type ContextAuditRefusal,
@@ -12,14 +12,22 @@ import {
12
12
  matchesSiblings,
13
13
  measureFolders,
14
14
  extraSections,
15
+ GOTCHA_CAP,
16
+ LAYOUT_BULLET_CAP,
17
+ LAYOUT_PARAGRAPH_CAP,
15
18
  missingSections,
19
+ OTHER_FOLDERS,
16
20
  PROVENANCE_FOLDER,
17
21
  ALLOWED_SECTIONS_BY_FOLDER,
18
22
  REQUIRED_SECTIONS,
19
23
  REQUIRED_SECTIONS_BY_FOLDER,
20
24
  requiredSections,
25
+ RETIRED_SECTIONS_BY_FOLDER,
26
+ shapeFindings,
21
27
  type ExtraSectionFinding,
22
28
  type SectionFinding,
29
+ type ShapeFinding,
30
+ type ShapeIssue,
23
31
  } from '@/context/audit'
24
32
  import { auditCitations, type CitationReport } from '@/context/citations'
25
33
  import {
@@ -49,6 +57,8 @@ import {
49
57
  resolveFolders,
50
58
  } from '@/context/folders'
51
59
  import { isGating } from '@/context/gate'
60
+ import { isShapeGated, SHAPE_GATED_FOLDERS } from '@/context/shape-gated'
61
+ import { currentWorktreeRoot } from '@/git/worktree'
52
62
  import {
53
63
  isOverWordCap,
54
64
  measureRequirements,
@@ -103,7 +113,7 @@ export function register(program: Command): void {
103
113
  .option('--citations-only', 'Run the gating citation check alone')
104
114
  .option(
105
115
  '--gate',
106
- 'Also fail on a missing required section or index drift, the findings that are facts',
116
+ 'Also fail on a missing required section, an H2 outside the set, a shape break in a gated folder, or index drift, the findings that are facts',
107
117
  )
108
118
  .addHelpText(
109
119
  'after',
@@ -118,10 +128,13 @@ export function register(program: Command): void {
118
128
  'stating a word cap gates past it, on any run except',
119
129
  '--citations-only, which never measures it. A record stating no',
120
130
  'cap is reported and never gated. --gate widens the gate to the',
121
- 'other two findings that are facts rather than judgments: a',
122
- 'missing required section and index drift. A context entry',
123
- 'requires Overview and Layout. Entry length, reference form,',
124
- 'table, and provenance are judgments under both.',
131
+ 'findings that are facts rather than judgments: a missing',
132
+ 'required section, an H2 outside the closed set, and index drift.',
133
+ 'A context entry requires Overview and Layout and allows Gotchas.',
134
+ 'A shape break, such as a Decisions section or a sixth gotcha, is',
135
+ 'reported everywhere and gated only under a shape-gated folder.',
136
+ 'Entry length, reference form, table, and provenance are',
137
+ 'judgments under both.',
125
138
  '',
126
139
  'Depth and bullet weight are stated over every markdown file rather',
127
140
  'than over a context entry, so `canon markdown audit` measures them.',
@@ -644,6 +657,15 @@ async function runAudit(
644
657
  const drift = gateOnly ? [] : await auditIndexes(folders)
645
658
  const sections = gateOnly ? [] : missingSections(root, folders, entries)
646
659
  const extras = gateOnly ? [] : extraSections(entries)
660
+ // The gated prefixes name paths from the repository root, while an entry's
661
+ // path runs from the audit root, which is a seed tree's own root when one is
662
+ // passed. Resolving here keeps a seed tree listed under its toolkit path.
663
+ const top = gateOnly ? root : await currentWorktreeRoot(root)
664
+ const shape = gateOnly
665
+ ? []
666
+ : shapeFindings(entries, (rel) =>
667
+ isShapeGated(relative(top, join(root, rel))),
668
+ )
647
669
  const length = gateOnly ? undefined : lengthFindings(entries)
648
670
  // Absent under `--citations-only` and null when the project carries no
649
671
  // record. A run that never looked and a project with nothing to look at are
@@ -658,6 +680,7 @@ async function runAudit(
658
680
  reportCitations(citations, cited)
659
681
  reportReferenceForm(entries, folders)
660
682
  reportSections(sections, extras, folders)
683
+ reportShape(shape)
661
684
  reportLength(length ?? [])
662
685
  reportTables(entries)
663
686
  reportProvenance(entries, folders)
@@ -696,6 +719,7 @@ async function runAudit(
696
719
  length,
697
720
  missingSections: sections,
698
721
  extraSections: extras,
722
+ shapeFindings: shape,
699
723
  indexDrift: drift,
700
724
  // Null says the run opened the project and found no record, which a
701
725
  // target that never wrote one is entitled to. Absent says the run
@@ -711,6 +735,11 @@ async function runAudit(
711
735
  // caller without an error.
712
736
  requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
713
737
  allowedSectionsByFolder: ALLOWED_SECTIONS_BY_FOLDER,
738
+ retiredSectionsByFolder: RETIRED_SECTIONS_BY_FOLDER,
739
+ gotchaCap: GOTCHA_CAP,
740
+ layoutBulletCap: LAYOUT_BULLET_CAP,
741
+ layoutParagraphCap: LAYOUT_PARAGRAPH_CAP,
742
+ shapeGatedFolders: SHAPE_GATED_FOLDERS,
714
743
  },
715
744
  })}\n`,
716
745
  )
@@ -722,6 +751,7 @@ async function runAudit(
722
751
  requirements !== undefined && isOverWordCap(requirements),
723
752
  sections,
724
753
  extraSections: extras,
754
+ shape,
725
755
  drift,
726
756
  widened,
727
757
  })
@@ -958,7 +988,7 @@ function reportSections(
958
988
  logInfo(
959
989
  `The set is closed: ${Object.entries(ALLOWED_SECTIONS_BY_FOLDER)
960
990
  .map(([name, set]) => `canon/${name}/ allows ${set.join(', ')}`)
961
- .join('. ')} as H2. Any other topic is an H3 under one of them.`,
991
+ .join('. ')} as H2. A folder is an H3 under Layout.`,
962
992
  )
963
993
 
964
994
  if (extras.length === 0) {
@@ -974,6 +1004,75 @@ function reportSections(
974
1004
  )
975
1005
  }
976
1006
 
1007
+ /** Characters of a paragraph the report quotes, enough to find it by. */
1008
+ const QUOTE_WIDTH = 60
1009
+
1010
+ function clip(text: string): string {
1011
+ return text.length > QUOTE_WIDTH ? `${text.slice(0, QUOTE_WIDTH)}...` : text
1012
+ }
1013
+
1014
+ /** How each shape break reads in the report. */
1015
+ function readIssue(issue: ShapeIssue): string {
1016
+ switch (issue.kind) {
1017
+ case 'retired-section':
1018
+ return `retired section: ${issue.text}`
1019
+ case 'gotcha-count':
1020
+ return `${issue.count ?? 0} gotchas, past ${GOTCHA_CAP}`
1021
+ case 'gotcha-heading':
1022
+ return `heading inside Gotchas: ${issue.text}`
1023
+ case 'gotcha-paragraph':
1024
+ return `paragraph inside Gotchas: ${clip(issue.text)}`
1025
+ case 'layout-heading':
1026
+ return `Layout H3 titled neither a path nor ${OTHER_FOLDERS}: ${issue.text}`
1027
+ case 'layout-bullets':
1028
+ return `${issue.count ?? 0} bullets under ${issue.text}, past ${LAYOUT_BULLET_CAP}`
1029
+ case 'layout-paragraphs':
1030
+ return `${issue.count ?? 0} paragraphs under ${issue.text}, past ${LAYOUT_PARAGRAPH_CAP}`
1031
+ case 'layout-depth':
1032
+ return `heading below H3 under Layout: ${issue.text}`
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Names each entry breaking the section shape, gated ones first, and states
1038
+ * which prefixes gate on every run, since an empty gated list and a corpus that
1039
+ * conforms would otherwise print the same report.
1040
+ */
1041
+ function reportShape(shape: readonly ShapeFinding[]): void {
1042
+ logStep('Shape')
1043
+
1044
+ logInfo(
1045
+ SHAPE_GATED_FOLDERS.length === 0
1046
+ ? 'No folder is shape-gated yet, so every shape break reports and none gates.'
1047
+ : `Gated under --gate: ${SHAPE_GATED_FOLDERS.join(', ')}. A break elsewhere reports and never gates.`,
1048
+ )
1049
+
1050
+ if (shape.length === 0) {
1051
+ logInfo('Every entry holds the section shape.')
1052
+ return
1053
+ }
1054
+
1055
+ const gated = shape.filter((finding) => finding.gated)
1056
+ if (gated.length > 0) {
1057
+ logWarn(
1058
+ `${gated.length} gated ${gated.length === 1 ? 'entry' : 'entries'} breaking the shape`,
1059
+ )
1060
+ }
1061
+ logInfo(
1062
+ `${shape.length} ${shape.length === 1 ? 'entry' : 'entries'} breaking the shape in all`,
1063
+ )
1064
+ pipeOutput(
1065
+ [...gated, ...shape.filter((finding) => !finding.gated)]
1066
+ .map(
1067
+ (finding) =>
1068
+ `${finding.rel}${finding.gated ? ' gated' : ''}\n${finding.issues
1069
+ .map((issue) => ` :${issue.line} ${readIssue(issue)}`)
1070
+ .join('\n')}`,
1071
+ )
1072
+ .join('\n'),
1073
+ )
1074
+ }
1075
+
977
1076
  /** How each question reads in the report, in the standard's own order. */
978
1077
  const QUESTION_LABEL: Record<LengthQuestion, string> = {
979
1078
  domain: 'one domain',
@@ -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
  }
@@ -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 {