@erclx/canon 5.36.0 → 5.38.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 (165) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +6 -0
  3. package/claude/skills/auto-ship/SKILL.md +1 -1
  4. package/claude/skills/backlog-triage/SKILL.md +8 -8
  5. package/claude/skills/build-in-slices/SKILL.md +7 -1
  6. package/claude/skills/build-in-slices/references/adopted.md +3 -0
  7. package/claude/skills/canon-cli/SKILL.md +3 -0
  8. package/claude/skills/canon-feedback/SKILL.md +5 -0
  9. package/claude/skills/canon-feedback-triage/SKILL.md +3 -0
  10. package/claude/skills/canvas-board/SKILL.md +3 -0
  11. package/claude/skills/canvas-board/references/canvas.md +12 -0
  12. package/claude/skills/ci-workflow/SKILL.md +5 -0
  13. package/claude/skills/code-craft/SKILL.md +6 -0
  14. package/claude/skills/context-fold/references/context.md +48 -34
  15. package/claude/skills/create-rule/SKILL.md +11 -9
  16. package/claude/skills/create-rule/references/rule.md +6 -0
  17. package/claude/skills/create-skill/SKILL.md +11 -2
  18. package/claude/skills/create-skill/references/skill-disclosure.md +26 -0
  19. package/claude/skills/create-skill/references/skill-paths.md +3 -0
  20. package/claude/skills/create-skill/references/skill-reference.md +12 -0
  21. package/claude/skills/create-skill/references/skill.md +25 -24
  22. package/claude/skills/deprecation-migration/SKILL.md +6 -0
  23. package/claude/skills/deprecation-migration/references/adopted.md +3 -0
  24. package/claude/skills/design-taste/SKILL.md +6 -0
  25. package/claude/skills/design-taste/references/craft.md +3 -0
  26. package/claude/skills/design-taste/references/preflight.md +16 -0
  27. package/claude/skills/docs-sync/references/docs.md +6 -0
  28. package/claude/skills/docs-sync/references/readme.md +15 -0
  29. package/claude/skills/draft-and-pick/SKILL.md +12 -0
  30. package/claude/skills/draft-doc/references/context.md +1 -1
  31. package/claude/skills/draft-doc/references/readme.md +3 -0
  32. package/claude/skills/draft-figure/references/mermaid.md +6 -0
  33. package/claude/skills/draft-slides/SKILL.md +3 -0
  34. package/claude/skills/git-branch/references/branch.md +16 -11
  35. package/claude/skills/git-commit/references/commit.md +19 -11
  36. package/claude/skills/git-commit/references/versioning.md +9 -0
  37. package/claude/skills/git-issue/references/issue.md +13 -9
  38. package/claude/skills/git-pr/SKILL.md +5 -0
  39. package/claude/skills/git-pr/references/pr.md +24 -6
  40. package/claude/skills/git-ship/SKILL.md +3 -0
  41. package/claude/skills/git-split/SKILL.md +6 -0
  42. package/claude/skills/git-stage/SKILL.md +3 -0
  43. package/claude/skills/git-worktree/SKILL.md +6 -6
  44. package/claude/skills/markdown-craft/references/markdown.md +25 -6
  45. package/claude/skills/plan-feature/SKILL.md +9 -0
  46. package/claude/skills/plan-feature/references/plan.md +20 -8
  47. package/claude/skills/plan-groundwork/SKILL.md +6 -0
  48. package/claude/skills/plan-groundwork/references/groundwork.md +61 -29
  49. package/claude/skills/plan-intake/references/intake.md +6 -6
  50. package/claude/skills/read-frames/SKILL.md +3 -0
  51. package/claude/skills/review-craft/SKILL.md +3 -0
  52. package/claude/skills/review-pr/references/post.md +1 -1
  53. package/claude/skills/review-ui/SKILL.md +6 -3
  54. package/claude/skills/role-orchestrator/SKILL.md +4 -1
  55. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +7 -4
  56. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  57. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +3 -0
  58. package/claude/skills/role-planner/SKILL.md +4 -1
  59. package/claude/skills/role-worker/SKILL.md +13 -1
  60. package/claude/skills/search-craft/SKILL.md +3 -0
  61. package/claude/skills/systematic-debugging/SKILL.md +6 -0
  62. package/claude/skills/target-check/references/domains.md +5 -0
  63. package/claude/skills/target-setup/SKILL.md +8 -4
  64. package/claude/skills/target-setup/references/verify.md +6 -5
  65. package/claude/skills/task-board/references/tasks.md +12 -7
  66. package/claude/skills/task-complete/references/archive.md +3 -0
  67. package/claude/skills/teach-workspace/SKILL.md +3 -0
  68. package/claude/skills/teach-workspace/references/glossary.md +3 -0
  69. package/claude/skills/teach-workspace/references/lesson-build.md +3 -0
  70. package/claude/skills/teach-workspace/references/lesson-craft.md +3 -0
  71. package/claude/skills/teach-workspace/references/teach.md +8 -8
  72. package/claude/skills/test-craft/SKILL.md +5 -0
  73. package/claude/skills/video-craft/SKILL.md +6 -0
  74. package/claude/skills/write-human/SKILL.md +9 -0
  75. package/docs/agents/capture.md +2 -0
  76. package/docs/agents/commands.md +54 -54
  77. package/docs/agents/context-audit-checks.md +6 -0
  78. package/docs/agents/context-audit.md +8 -8
  79. package/docs/agents/context-classify.md +2 -0
  80. package/docs/agents/drift-surfaces.md +5 -5
  81. package/docs/agents/gate.md +6 -7
  82. package/docs/agents/index.md +1 -1
  83. package/docs/agents/install-and-sync.md +2 -2
  84. package/docs/agents/markdown-audit-weight.md +11 -3
  85. package/docs/agents/markdown-audit.md +2 -2
  86. package/docs/agents/pr-frames.md +17 -3
  87. package/docs/agents/sessions-transfer.md +8 -6
  88. package/docs/agents/skills-audit.md +9 -1
  89. package/docs/target/projects.md +1 -1
  90. package/docs/target/scenarios.md +2 -2
  91. package/docs/target/standards-moved.md +20 -18
  92. package/docs/workflow/ai-workflow.md +3 -0
  93. package/docs/workflow/visual-design-workflow.md +5 -0
  94. package/governance/detect.toml +6 -1
  95. package/package.json +7 -4
  96. package/scripts/core/install-check.sh +1 -1
  97. package/src/cli/help.ts +1 -1
  98. package/src/commands/claude.ts +0 -1
  99. package/src/commands/context.ts +29 -4
  100. package/src/commands/gate.ts +1 -8
  101. package/src/commands/markdown.ts +52 -0
  102. package/src/commands/pr.ts +18 -11
  103. package/src/commands/records.ts +0 -1
  104. package/src/context/audit.ts +83 -0
  105. package/src/context/classify/prompts.ts +6 -1
  106. package/src/context/gate.ts +6 -3
  107. package/src/docs/read.ts +15 -21
  108. package/src/gate/measures.ts +186 -1
  109. package/src/gate/sequencer.ts +0 -4
  110. package/src/gate/stages.ts +46 -27
  111. package/src/git/files.ts +30 -0
  112. package/src/gov/adapter.ts +2 -2
  113. package/src/gov/install.ts +2 -2
  114. package/src/gov/list.ts +1 -1
  115. package/src/gov/payload.ts +1 -1
  116. package/src/gov/stacks.ts +1 -1
  117. package/src/markdown/gate.ts +10 -0
  118. package/src/markdown/growth.ts +43 -0
  119. package/src/markdown/stacks.ts +74 -0
  120. package/src/markdown/structure.ts +73 -5
  121. package/src/migrate/rule-layout.ts +12 -12
  122. package/src/pr/frames.ts +66 -1
  123. package/src/sandbox/equivalence.ts +7 -9
  124. package/src/sync/check.ts +23 -23
  125. package/src/teach/browser/head.ts +2 -2
  126. package/src/teach/browser/sidebar.ts +3 -3
  127. package/src/teach/browser/theme.ts +2 -2
  128. package/src/tooling/inject.ts +2 -2
  129. package/src/tooling/package.ts +15 -18
  130. package/src/tooling/verify-stack.ts +6 -3
  131. package/tooling/astro/configs/.oxfmtrc.json +8 -0
  132. package/tooling/astro/configs/.prettierrc +13 -0
  133. package/tooling/astro/configs/tsconfig.json +1 -1
  134. package/tooling/astro/manifest.toml +13 -0
  135. package/tooling/astro/reference.md +17 -7
  136. package/tooling/base/configs/.oxfmtrc.json +7 -0
  137. package/tooling/base/configs/scripts/verify.json +1 -1
  138. package/tooling/base/configs/scripts/verify.ts +3 -9
  139. package/tooling/base/manifest.toml +6 -6
  140. package/tooling/base/reference.md +20 -17
  141. package/tooling/base/seeds/.lintstagedrc +1 -3
  142. package/tooling/base/seeds/canon/context/ci.md +7 -7
  143. package/tooling/base/seeds/canon/context/development.md +10 -10
  144. package/tooling/claude/manifest.toml +5 -1
  145. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -0
  146. package/tooling/go/reference.md +2 -2
  147. package/tooling/nextjs/configs/.oxlintrc.json +223 -0
  148. package/tooling/nextjs/manifest.toml +2 -5
  149. package/tooling/nextjs/reference.md +4 -4
  150. package/tooling/php/manifest.toml +6 -1
  151. package/tooling/php/reference.md +2 -2
  152. package/tooling/python/manifest.toml +6 -1
  153. package/tooling/python/reference.md +4 -4
  154. package/tooling/vite-react/configs/tsconfig.json +1 -1
  155. package/tooling/vite-react/configs/vitest.config.ts +1 -1
  156. package/tooling/vite-react/manifest.toml +1 -4
  157. package/tooling/vite-react/reference.md +5 -5
  158. package/tooling/web/configs/.oxlintrc.json +213 -0
  159. package/tooling/web/configs/src/test/setup.ts +1 -1
  160. package/tooling/web/manifest.toml +12 -16
  161. package/tooling/web/reference.md +15 -15
  162. package/tooling/base/configs/.prettierrc +0 -12
  163. package/tooling/base/seeds/.prettierignore +0 -0
  164. package/tooling/nextjs/configs/eslint.config.js +0 -91
  165. package/tooling/web/configs/eslint.config.js +0 -82
@@ -18,6 +18,7 @@ import {
18
18
  bodyLines,
19
19
  scanBans,
20
20
  } from '@/markdown/scan'
21
+ import { bulletStacks, CAP, type StackFinding } from '@/markdown/stacks'
21
22
  import {
22
23
  BASELINE,
23
24
  CHECKPOINTS,
@@ -59,6 +60,7 @@ interface FileReport {
59
60
  readonly links: readonly LinkFinding[]
60
61
  readonly colons: readonly ColonFinding[]
61
62
  readonly headingCase: readonly HeadingCaseFinding[]
63
+ readonly bulletStacks: readonly StackFinding[]
62
64
  readonly structure: StructureReport
63
65
  readonly renderedLines: number
64
66
  /** The stated reason, or null where the document is not exempt. */
@@ -101,6 +103,10 @@ export function register(program: Command): void {
101
103
  'report the same way. A proper noun is the one case the second cannot',
102
104
  'tell from title case, and the first is sometimes right.',
103
105
  '',
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
+ '',
104
110
  'Length reports a whole document past the ceiling and never exits 2.',
105
111
  'The Document ceiling gate stage owns that verdict, since this audit also',
106
112
  'runs on a record kept out of version control, where a long plan is not',
@@ -178,6 +184,7 @@ async function runAudit(
178
184
  bans: scanBans(lines, bans),
179
185
  colons: advisory ? findColons(lines) : [],
180
186
  headingCase: advisory ? findHeadingCase(source) : [],
187
+ bulletStacks: advisory ? bulletStacks(lines) : [],
181
188
  links: findBrokenLinks(lines, (path) =>
182
189
  existsSync(resolve(dirname(abs), path)),
183
190
  ),
@@ -194,6 +201,7 @@ async function runAudit(
194
201
  reportLinks(reports, root)
195
202
  reportColons(reports)
196
203
  reportHeadingCase(reports)
204
+ reportStacks(reports)
197
205
  reportBullets(reports, checkpoints)
198
206
  reportParagraphs(reports, checkpoints)
199
207
  reportCadence(reports, checkpoints)
@@ -229,6 +237,7 @@ async function runAudit(
229
237
  links: report.links,
230
238
  colons: report.colons,
231
239
  headingCase: report.headingCase,
240
+ bulletStacks: report.bulletStacks,
232
241
  longestRun: report.structure.longestRun,
233
242
  longestRunLine: report.structure.longestRunLine,
234
243
  heavyBullets: report.structure.heavyBullets,
@@ -251,6 +260,7 @@ async function runAudit(
251
260
  structure: reports.map((report) => report.structure),
252
261
  colons: reports.flatMap((report) => report.colons),
253
262
  headingCase: reports.flatMap((report) => report.headingCase),
263
+ bulletStacks: reports.flatMap((report) => report.bulletStacks),
254
264
  })
255
265
 
256
266
  return gating ? EXIT_GATE : 0
@@ -404,6 +414,48 @@ function reportLinks(reports: readonly FileReport[], root: string): void {
404
414
  )
405
415
  }
406
416
 
417
+ function reportStacks(reports: readonly FileReport[]): void {
418
+ logStep('Bullet stacks')
419
+ logInfo(
420
+ `A run of more than ${CAP} top-level bullets, with no paragraph or heading between them.`,
421
+ )
422
+ 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.',
424
+ )
425
+
426
+ const carrying = reports
427
+ .filter((report) => report.bulletStacks.length > 0)
428
+ .sort((a, b) => longestStack(b) - longestStack(a))
429
+
430
+ if (carrying.length === 0) {
431
+ logInfo(`No run past ${CAP} bullets.`)
432
+ return
433
+ }
434
+
435
+ const total = carrying.reduce(
436
+ (sum, report) => sum + report.bulletStacks.length,
437
+ 0,
438
+ )
439
+ logWarn(`${plural(total, 'stack')} across ${plural(carrying.length, 'file')}`)
440
+ logInfo(
441
+ 'Fold the run into prose, give each item an H3, or split the list at its seam.',
442
+ )
443
+ pipeOutput(
444
+ carrying
445
+ .map(
446
+ (report) =>
447
+ `${report.rel} ${plural(report.bulletStacks.length, 'stack')}\n${report.bulletStacks
448
+ .map((found) => ` :${found.line} ${found.count} bullets`)
449
+ .join('\n')}`,
450
+ )
451
+ .join('\n'),
452
+ )
453
+ }
454
+
455
+ function longestStack(report: FileReport): number {
456
+ return Math.max(0, ...report.bulletStacks.map((found) => found.count))
457
+ }
458
+
407
459
  function reportColons(reports: readonly FileReport[]): void {
408
460
  logStep('Colons')
409
461
  logInfo(
@@ -44,7 +44,7 @@ import {
44
44
  settleChecklist,
45
45
  } from '@/pr/evidence'
46
46
  import {
47
- dropFrames,
47
+ dropFramesWithLegacy,
48
48
  FRAMES_BRANCH,
49
49
  type FramesApi,
50
50
  type FramesRefusal,
@@ -53,7 +53,8 @@ import {
53
53
  isFrameHead,
54
54
  isFramePass,
55
55
  isFrameTheme,
56
- pruneFrames,
56
+ LEGACY_FRAMES_BRANCH,
57
+ pruneFramesWithLegacy,
57
58
  pushFrame,
58
59
  readFrame,
59
60
  type TreeEntry,
@@ -933,7 +934,7 @@ export function register(program: Command): void {
933
934
  'after',
934
935
  [
935
936
  '',
936
- `Writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
937
+ `An add writes only refs/heads/${FRAMES_BRANCH}, through the GitHub git data API,`,
937
938
  'so nothing is checked out and no other branch is touched. A frame lands',
938
939
  'at pr-<number>/<short-head>/<pass>/box-<n>.png, or box-<n>-<theme>.png',
939
940
  'with --theme, and `link` names that',
@@ -948,6 +949,11 @@ export function register(program: Command): void {
948
949
  'request still open, reopened, closed exactly the given days ago, or one',
949
950
  'whose state could not be read, listing that last kind under `unread`.',
950
951
  '',
952
+ `Both also drain the ${LEGACY_FRAMES_BRANCH} branch a rename left behind,`,
953
+ 'removing only, and carry `legacy: true` when it changed. --prune empties',
954
+ 'it as its pull requests expire, and --drop clears one pull request at',
955
+ 'once. An add never writes it.',
956
+ '',
951
957
  'Read `reason` on the JSON record:',
952
958
  ' ok an add pushed, or a drop or prune finished',
953
959
  ' bad-mode not exactly one of --add, --drop, --prune',
@@ -1885,7 +1891,7 @@ async function mapPool<T, R>(
1885
1891
  limit: number,
1886
1892
  task: (input: T) => Promise<R>,
1887
1893
  ): Promise<R[]> {
1888
- const results: R[] = new Array(inputs.length)
1894
+ const results: R[] = Array.from({ length: inputs.length })
1889
1895
  let next = 0
1890
1896
  const lane = async (): Promise<void> => {
1891
1897
  while (next < inputs.length) {
@@ -2876,13 +2882,13 @@ function isTreeEntry(value: unknown): value is TreeEntry {
2876
2882
  * that already exists or no longer does, which is another writer's doing and
2877
2883
  * worth a re-read rather than a failure.
2878
2884
  */
2879
- function ghFramesApi(call: ApiCaller): FramesApi {
2880
- const ref = `git/refs/heads/${FRAMES_BRANCH}`
2885
+ function ghFramesApi(call: ApiCaller, branch: string): FramesApi {
2886
+ const ref = `git/refs/heads/${branch}`
2881
2887
  const refWrite = (result: ApiCall) =>
2882
2888
  result.ok ? 'ok' : result.status === 422 ? 'conflict' : 'failed'
2883
2889
  return {
2884
2890
  async readTip() {
2885
- const head = await call('GET', `git/ref/heads/${FRAMES_BRANCH}`)
2891
+ const head = await call('GET', `git/ref/heads/${branch}`)
2886
2892
  if (!head.ok) {
2887
2893
  return head.status === 404
2888
2894
  ? { kind: 'missing' }
@@ -2937,7 +2943,7 @@ function ghFramesApi(call: ApiCaller): FramesApi {
2937
2943
  async createRef(commit) {
2938
2944
  return refWrite(
2939
2945
  await call('POST', 'git/refs', {
2940
- ref: `refs/heads/${FRAMES_BRANCH}`,
2946
+ ref: `refs/heads/${branch}`,
2941
2947
  sha: commit,
2942
2948
  }),
2943
2949
  )
@@ -3034,7 +3040,8 @@ async function runFrames(
3034
3040
 
3035
3041
  if (Bun.which('gh') === null) return refuseFrames('gh-missing')
3036
3042
  const runner = ghApiRunner(root)
3037
- const api = ghFramesApi(runner.call)
3043
+ const api = ghFramesApi(runner.call, FRAMES_BRANCH)
3044
+ const legacyApi = ghFramesApi(runner.call, LEGACY_FRAMES_BRANCH)
3038
3045
  const refuseWrite = (reason: FramesRefusal) =>
3039
3046
  refuseFrames(
3040
3047
  reason === 'push-failed' && runner.refusedWrites.length > 0
@@ -3059,7 +3066,7 @@ async function runFrames(
3059
3066
 
3060
3067
  if (opts.prune !== undefined) {
3061
3068
  const days = Number(opts.prune)
3062
- const result = await pruneFrames(api, days, new Date())
3069
+ const result = await pruneFramesWithLegacy(api, legacyApi, days, new Date())
3063
3070
  if (result.kind === 'refused') return refuseWrite(result.reason)
3064
3071
  const { kind: _kind, ...rest } = result
3065
3072
  return finish({ days, ...rest }, [
@@ -3072,7 +3079,7 @@ async function runFrames(
3072
3079
 
3073
3080
  const pull = Number(number)
3074
3081
  if (opts.drop === true) {
3075
- const result = await dropFrames(api, [pull])
3082
+ const result = await dropFramesWithLegacy(api, legacyApi, [pull])
3076
3083
  if (result.kind === 'refused') return refuseWrite(result.reason)
3077
3084
  const { kind: _kind, ...rest } = result
3078
3085
  return finish({ number: pull, ...rest }, [
@@ -17,7 +17,6 @@ import {
17
17
  type FolderSize,
18
18
  formatBytes,
19
19
  GROWTH_WINDOWS,
20
- sizedFolders,
21
20
  type SizeOutcome,
22
21
  sizeRecords,
23
22
  } from '@/records/size'
@@ -63,6 +63,22 @@ export const REQUIRED_SECTIONS_BY_FOLDER: Readonly<
63
63
  context: ['Overview', 'Layout'],
64
64
  }
65
65
 
66
+ /**
67
+ * The closed set of H2 sections per audited folder name, which a required
68
+ * section belongs to. An entry carrying another H2 is a finding under
69
+ * `--gate`, since the set is what makes a context entry cheap to find a fact
70
+ * in: a domain's own topic names go one level down as H3 items under
71
+ * `Decisions` and `Gotchas`.
72
+ *
73
+ * Held beside the required lists for the same reason they are held here, and
74
+ * keyed on the folder whose standard states it.
75
+ */
76
+ export const ALLOWED_SECTIONS_BY_FOLDER: Readonly<
77
+ Record<string, readonly string[]>
78
+ > = {
79
+ context: ['Overview', 'Layout', 'Decisions', 'Gotchas'],
80
+ }
81
+
66
82
  /**
67
83
  * The context standard's list, kept as its own export because the audit record
68
84
  * has published it flat under `checkpoints.requiredSections` since before any
@@ -225,6 +241,12 @@ export interface EntryReport {
225
241
  * is `missingSections`, since one entry answers for its siblings.
226
242
  */
227
243
  readonly sections: readonly string[]
244
+ /**
245
+ * H2 headings outside the folder's closed set, in document order, and empty
246
+ * outside a folder whose standard states one. What the folder carries in
247
+ * all is `extraSections`.
248
+ */
249
+ readonly otherSections: readonly string[]
228
250
  /**
229
251
  * Whether the file declares itself a skeleton, which excludes it from the
230
252
  * section check alone. Every other measure still reads it, since a stub is
@@ -281,6 +303,13 @@ export interface LengthFinding {
281
303
  readonly causes: readonly LengthCause[]
282
304
  }
283
305
 
306
+ export interface ExtraSectionFinding {
307
+ /** Repo-relative path of the entry carrying the headings. */
308
+ readonly rel: string
309
+ /** H2 headings outside the closed set, never empty. */
310
+ readonly headings: readonly string[]
311
+ }
312
+
284
313
  export interface SectionFinding {
285
314
  /**
286
315
  * Repo-relative path of whatever owes the sections: the entry itself in the
@@ -442,6 +471,33 @@ function declaredSections(
442
471
  return required.filter((section) => found.has(section))
443
472
  }
444
473
 
474
+ const H2_TEXT = /^##\s+(.+?)\s*$/
475
+
476
+ /**
477
+ * Finds the H2 headings outside the allowed set.
478
+ *
479
+ * H2 only, since the set closes the sections of an entry and says nothing of
480
+ * where its H3 items sit. Fenced blocks are skipped, so a template quoted
481
+ * inside an example declares nothing about the entry quoting it.
482
+ */
483
+ function otherSections(
484
+ lines: readonly BodyLine[],
485
+ allowed: readonly string[],
486
+ ): string[] {
487
+ if (allowed.length === 0) return []
488
+
489
+ const found: string[] = []
490
+
491
+ for (const line of lines) {
492
+ if (line.fenced) continue
493
+
494
+ const match = line.text.match(H2_TEXT)
495
+ if (match && !allowed.includes(match[1])) found.push(match[1])
496
+ }
497
+
498
+ return found
499
+ }
500
+
445
501
  /**
446
502
  * Finds the references naming a sibling entry by bare filename.
447
503
  *
@@ -500,6 +556,7 @@ export function measureEntry(
500
556
  governsContent = true,
501
557
  siblings: readonly string[] = [],
502
558
  required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
559
+ allowed: readonly string[] = [],
503
560
  ): EntryReport {
504
561
  const lines = bodyLines(source)
505
562
 
@@ -510,6 +567,7 @@ export function measureEntry(
510
567
  provenance: governsContent ? provenance(lines) : [],
511
568
  bareReferences: bareReferences(lines, siblings),
512
569
  sections: declaredSections(lines, required),
570
+ otherSections: otherSections(lines, allowed),
513
571
  stub: isStubSeed(source),
514
572
  governed: governsContent,
515
573
  }
@@ -589,6 +647,7 @@ export async function measureFolders(
589
647
  governsContent(folder),
590
648
  names.filter((name) => name !== self),
591
649
  requiredSections(folder),
650
+ allowedSections(folder),
592
651
  ),
593
652
  )
594
653
  }
@@ -609,6 +668,13 @@ export function requiredSections(folder: AuditedFolder): readonly string[] {
609
668
  : []
610
669
  }
611
670
 
671
+ /** Names the H2 sections the folder's standard allows, empty where it states no set. */
672
+ export function allowedSections(folder: AuditedFolder): readonly string[] {
673
+ return Object.hasOwn(ALLOWED_SECTIONS_BY_FOLDER, folder.name)
674
+ ? ALLOWED_SECTIONS_BY_FOLDER[folder.name]
675
+ : []
676
+ }
677
+
612
678
  /**
613
679
  * Reports whether a bare sibling name here is a reference by construction.
614
680
  *
@@ -688,3 +754,20 @@ export function missingSections(
688
754
 
689
755
  return findings
690
756
  }
757
+
758
+ /**
759
+ * Names each entry carrying an H2 outside its folder's closed set.
760
+ *
761
+ * Per entry rather than per folder, unlike `missingSections`: a split folder's
762
+ * entries each answer for their own headings, since an extra section is a fact
763
+ * about the file it sits in. A stub seed is dropped for the reason
764
+ * `missingSections` drops one, a skeleton being exempt from the shape rather
765
+ * than from being well formed.
766
+ */
767
+ export function extraSections(
768
+ entries: readonly EntryReport[],
769
+ ): ExtraSectionFinding[] {
770
+ return entries
771
+ .filter((entry) => !entry.stub && entry.otherSections.length > 0)
772
+ .map((entry) => ({ rel: entry.rel, headings: entry.otherSections }))
773
+ }
@@ -2,7 +2,10 @@
2
2
  * System prompts and user-message builders for the model layer, ported
3
3
  * verbatim as data from the groundwork spike at
4
4
  * `.canon/groundwork/93-canonical-doc-drift/scripts/prompts/v3.md` (diff
5
- * mode) and `sweep-v1.md` (sweep mode). Neither prompt text is invented here.
5
+ * mode) and `sweep-v1.md` (sweep mode). The diff prompt is verbatim. The sweep
6
+ * prompt was tuned past `sweep-v1.md` with one decision check, measured
7
+ * against the groundwork's labelled set in
8
+ * `canon/context/context-model/overview.md`.
6
9
  *
7
10
  * Each prompt already states its own rules per canonical doc type inside one
8
11
  * string, which is the shape the spike measured, so there is one prompt per
@@ -45,6 +48,8 @@ Decision checks, in order:
45
48
  1. Judge what the text says, not how long it is. A long section that states current design once is KEEP.
46
49
  2. A trailing "Measured at <sha> on <date>" does not make a paragraph current if another paragraph in the section states an older value on the same subject.
47
50
  3. Short rationale for a current rule ("the form checks length itself because...") is KEEP. A story of the rounds that produced the rule is REWRITE.
51
+ 4. Rationale is KEEP even when it is told in the past tense. A reason naming why an alternative was left out, what an earlier approach got wrong, what a measurement caught, or what a past run cost is the evidence for the current rule, so a sentence such as "the first form timed out" or "two runs took 1281s and 1924s" or "reading figures from real data caught three wrong claims" supports a rule that stands and stays KEEP. Past tense alone is never the test.
52
+ 5. REWRITE needs the section to restate its own subject as a sequence: a superseded figure kept beside its replacement, "now" or "no longer" marking a change, branch or PR narration, or rounds of candidates. A maintenance step or an operator decision stated as a rule is current, not history.
48
53
 
49
54
  Return only JSON: {"verdict": "...", "quotes": ["<up to three short phrases copied from the section that decided it>"], "reason": "<one sentence>"}
50
55
  `
@@ -1,4 +1,4 @@
1
- import type { SectionFinding } from '@/context/audit'
1
+ import type { ExtraSectionFinding, SectionFinding } from '@/context/audit'
2
2
  import type { FolderDrift } from '@/context/index-drift'
3
3
 
4
4
  export interface GateInput {
@@ -13,6 +13,8 @@ export interface GateInput {
13
13
  */
14
14
  readonly requirementsOverWords: boolean
15
15
  readonly sections: readonly SectionFinding[]
16
+ /** H2 headings outside a folder's closed set, gating under `--gate` alone. */
17
+ readonly extraSections: readonly ExtraSectionFinding[]
16
18
  readonly drift: readonly FolderDrift[]
17
19
  /**
18
20
  * Whether the caller asked for the widened gate. False leaves a missing
@@ -36,7 +38,7 @@ export function hasDrift(drift: readonly FolderDrift[]): boolean {
36
38
  * does a requirements record past its own word cap: the record states its rule
37
39
  * for itself, which makes it a fact rather than a threshold a reader weighs. The findings `--gate` adds are
38
40
  * the ones answerable from the file itself: a required section it does not
39
- * declare, and an index disagreeing with its folder. Entry length, depth, bullet,
41
+ * declare, an H2 outside the closed set, and an index disagreeing with its folder. Entry length, depth, bullet,
40
42
  * table, provenance, and the record's claim coverage are judgments, so they
41
43
  * stay out under both modes.
42
44
  */
@@ -44,6 +46,7 @@ export function isGating({
44
46
  unresolvedCitations,
45
47
  requirementsOverWords,
46
48
  sections,
49
+ extraSections,
47
50
  drift,
48
51
  widened,
49
52
  }: GateInput): boolean {
@@ -51,5 +54,5 @@ export function isGating({
51
54
  if (requirementsOverWords) return true
52
55
  if (!widened) return false
53
56
 
54
- return sections.length > 0 || hasDrift(drift)
57
+ return sections.length > 0 || extraSections.length > 0 || hasDrift(drift)
55
58
  }
package/src/docs/read.ts CHANGED
@@ -149,29 +149,23 @@ export function walkEntries(root: string, dir: string): WalkedEntry[] {
149
149
  const cwd = join(root, dir)
150
150
  if (!existsSync(cwd)) return []
151
151
 
152
- const files = markdownNames(cwd).map(
153
- (name): WalkedEntry => ({
154
- name,
155
- kind: 'file',
156
- rel: join(dir, `${name}.md`),
157
- }),
158
- )
152
+ const files = markdownNames(cwd).map((name): WalkedEntry => ({
153
+ name,
154
+ kind: 'file',
155
+ rel: join(dir, `${name}.md`),
156
+ }))
159
157
  const folders = catalogedFolders(cwd)
160
- const split = folders.map(
161
- (folder): WalkedEntry => ({
162
- name: folder,
163
- kind: 'folder',
164
- rel: join(dir, folder, `${INDEX_TOPIC}.md`),
165
- }),
166
- )
158
+ const split = folders.map((folder): WalkedEntry => ({
159
+ name: folder,
160
+ kind: 'folder',
161
+ rel: join(dir, folder, `${INDEX_TOPIC}.md`),
162
+ }))
167
163
  const leaves = folders.flatMap((folder) =>
168
- markdownNames(join(cwd, folder)).map(
169
- (name): WalkedEntry => ({
170
- name,
171
- kind: 'leaf',
172
- rel: join(dir, folder, `${name}.md`),
173
- }),
174
- ),
164
+ markdownNames(join(cwd, folder)).map((name): WalkedEntry => ({
165
+ name,
166
+ kind: 'leaf',
167
+ rel: join(dir, folder, `${name}.md`),
168
+ })),
175
169
  )
176
170
 
177
171
  return [...files, ...split, ...leaves]
@@ -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.
@@ -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)