@erclx/canon 4.116.0 → 4.117.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 (48) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/canon-cli/SKILL.md +12 -12
  4. package/claude/skills/create-skill/REQUIREMENT.md +3 -0
  5. package/claude/skills/create-skill/SKILL.md +3 -1
  6. package/claude/skills/docs-fold/SKILL.md +1 -1
  7. package/claude/skills/memory-review/SKILL.md +4 -4
  8. package/claude/skills/review-pr/REQUIREMENT.md +2 -0
  9. package/claude/skills/review-pr/SKILL.md +2 -2
  10. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
  11. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  12. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
  13. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  14. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
  15. package/claude/skills/role-worker/REQUIREMENT.md +2 -0
  16. package/claude/skills/role-worker/SKILL.md +2 -2
  17. package/claude/skills/session-compact/REQUIREMENT.md +2 -1
  18. package/claude/skills/session-compact/SKILL.md +5 -1
  19. package/claude/skills/write-human/references/source-material.md +2 -2
  20. package/docs/agents/context-audit-checks.md +1 -1
  21. package/docs/agents/install-and-sync.md +1 -1
  22. package/docs/agents/markdown-audit.md +7 -3
  23. package/docs/agents/skills-audit.md +8 -1
  24. package/docs/workflow/ai-workflow.md +9 -9
  25. package/governance/rules/ui/440-surface-capture.md +2 -0
  26. package/package.json +1 -1
  27. package/scripts/core/check-capability-seeding.sh +4 -2
  28. package/src/claude/cases/misc.ts +5 -0
  29. package/src/claude/skills-audit.ts +59 -2
  30. package/src/commands/claude.ts +30 -0
  31. package/src/commands/markdown.ts +63 -2
  32. package/src/context/audit.ts +6 -8
  33. package/src/gate/measures.ts +60 -0
  34. package/src/gate/stages.ts +12 -2
  35. package/src/markdown/ceiling.ts +70 -0
  36. package/src/markdown/structure.ts +22 -0
  37. package/src/tooling/inject.ts +13 -5
  38. package/src/tooling/manifest.ts +9 -0
  39. package/src/tooling/scan.ts +23 -6
  40. package/src/tooling/subfolder.ts +3 -3
  41. package/standards/context.md +2 -2
  42. package/standards/markdown.md +2 -0
  43. package/standards/skill.md +1 -0
  44. package/tooling/base/reference.md +2 -2
  45. package/tooling/web/configs/e2e/screenshot.ts +43 -7
  46. package/tooling/web/reference.md +1 -1
  47. /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
  48. /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
@@ -60,7 +60,8 @@ check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
60
60
  "$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
61
61
 
62
62
  check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
63
- "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
63
+ "$PROJECT_ROOT"/tooling/*/configs/.github/workflows/* \
64
+ "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
64
65
 
65
66
  check_capability "Husky" "$PROJECT_ROOT/.husky" \
66
67
  "$PROJECT_ROOT"/tooling/base/configs/.husky/*
@@ -90,7 +91,8 @@ check_orphans() {
90
91
  check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
91
92
  "$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
92
93
 
93
- for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows; do
94
+ for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
95
+ "$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
94
96
  check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
95
97
  done
96
98
 
@@ -36,6 +36,11 @@ export const MISC_CASES: readonly SkillCase[] = [
36
36
  "We're about to hit the context limit, write the handoff before we lose state.",
37
37
  expect: 'session-compact',
38
38
  },
39
+ {
40
+ prompt:
41
+ "I'm moving this session to another machine, write the handoff before I pack it up.",
42
+ expect: 'session-compact',
43
+ },
39
44
  {
40
45
  prompt:
41
46
  'I am the orchestrating session and about to compact, so write the board handoff as a session map on the task board.',
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
4
+ import { bodyLines, maskDisplayed } from '@/markdown/scan'
4
5
 
5
6
  /** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
6
7
  export const EXIT_MISSING_REQUIREMENT = 2
@@ -33,6 +34,14 @@ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
33
34
 
34
35
  const HEADING = /^#{1,6}\s+(.+?)\s*$/
35
36
 
37
+ /**
38
+ * An ISO date, read as provenance wherever a reader is told it. The context
39
+ * audit's detector clears a date after "measured" or "verified", and a skill
40
+ * body keeps no such carve-out, since the standard sends every incident and its
41
+ * date to the requirement's Gap or to git.
42
+ */
43
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
44
+
36
45
  export interface SkillFinding {
37
46
  readonly rel: string
38
47
  readonly detail: string
@@ -59,6 +68,7 @@ export interface SkillsAudit {
59
68
  readonly nameMismatch: readonly SkillFinding[]
60
69
  readonly longDescription: readonly SkillFinding[]
61
70
  readonly requirementSections: readonly SkillFinding[]
71
+ readonly datedProvenance: readonly SkillFinding[]
62
72
  }
63
73
 
64
74
  interface SkillSource {
@@ -69,6 +79,17 @@ interface SkillSource {
69
79
  readonly description: string | undefined
70
80
  /** Undefined when the folder carries no `REQUIREMENT.md` at all. */
71
81
  readonly requirementHeadings: readonly string[] | undefined
82
+ /**
83
+ * `SKILL.md` and every reference below the folder, keyed by the path a
84
+ * finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
85
+ * requirement is where the standard sends a date.
86
+ */
87
+ readonly bodies: readonly SkillText[]
88
+ }
89
+
90
+ interface SkillText {
91
+ readonly rel: string
92
+ readonly text: string
72
93
  }
73
94
 
74
95
  /**
@@ -110,6 +131,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
110
131
  nameMismatch: sources.flatMap(nameFindings),
111
132
  longDescription: sources.flatMap(lengthFindings),
112
133
  requirementSections: sources.flatMap(sectionFindings),
134
+ datedProvenance: sources.flatMap((source) =>
135
+ source.bodies.flatMap(dateFindings),
136
+ ),
113
137
  }
114
138
  }
115
139
 
@@ -138,23 +162,41 @@ async function readSkill(
138
162
  const skillDir = join(dir, folder)
139
163
  const requirementPath = join(skillDir, 'REQUIREMENT.md')
140
164
 
141
- const [body, requirement] = await Promise.all([
165
+ const references = [
166
+ ...new Bun.Glob('references/**/*.md').scanSync({
167
+ cwd: skillDir,
168
+ onlyFiles: true,
169
+ }),
170
+ ].sort()
171
+
172
+ const [body, requirement, referenceTexts] = await Promise.all([
142
173
  Bun.file(join(skillDir, 'SKILL.md')).text(),
143
174
  existsSync(requirementPath)
144
175
  ? Bun.file(requirementPath).text()
145
176
  : Promise.resolve(undefined),
177
+ Promise.all(
178
+ references.map((path) => Bun.file(join(skillDir, path)).text()),
179
+ ),
146
180
  ])
147
181
 
148
182
  const fields = parseFrontmatter(body)
183
+ const skillRel = join(rel, folder)
149
184
 
150
185
  return {
151
- rel: join(rel, folder),
186
+ rel: skillRel,
152
187
  folder,
153
188
  hasReadme: existsSync(join(skillDir, 'README.md')),
154
189
  name: declared(readField(fields, 'name')),
155
190
  description: declared(readField(fields, 'description')),
156
191
  requirementHeadings:
157
192
  requirement === undefined ? undefined : headings(requirement),
193
+ bodies: [
194
+ { rel: join(skillRel, 'SKILL.md'), text: body },
195
+ ...references.map((path, index) => ({
196
+ rel: join(skillRel, path),
197
+ text: referenceTexts[index] ?? '',
198
+ })),
199
+ ],
158
200
  }
159
201
  }
160
202
 
@@ -209,3 +251,18 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
209
251
  },
210
252
  ]
211
253
  }
254
+
255
+ /**
256
+ * A date inside a fence or a code span is example data a reader is shown, such
257
+ * as a sample frontmatter block, so only prose outside both is read.
258
+ */
259
+ function dateFindings(source: SkillText): SkillFinding[] {
260
+ return bodyLines(source.text)
261
+ .filter((line) => !line.fenced)
262
+ .flatMap((line) =>
263
+ [...maskDisplayed(line.text).matchAll(ISO_DATE)].map((match) => ({
264
+ rel: source.rel,
265
+ detail: `line ${line.number}: ${match[0]}`,
266
+ })),
267
+ )
268
+ }
@@ -1397,6 +1397,7 @@ async function runSkillsAudit(
1397
1397
  reportFrontmatter(report)
1398
1398
  reportFolder(report)
1399
1399
  reportRequirementShape(report)
1400
+ reportProvenance(report)
1400
1401
  reportUnmeasured()
1401
1402
  outro()
1402
1403
  }
@@ -1418,6 +1419,7 @@ async function runSkillsAudit(
1418
1419
  readme: report.readme,
1419
1420
  folderName: report.folderName,
1420
1421
  requirementSections: report.requirementSections,
1422
+ datedProvenance: report.datedProvenance,
1421
1423
  },
1422
1424
  checkpoints: {
1423
1425
  descriptionLimit: DESCRIPTION_LIMIT,
@@ -1588,6 +1590,31 @@ function reportRequirementShape(report: SkillsAudit): void {
1588
1590
  reportFindings(report.requirementSections)
1589
1591
  }
1590
1592
 
1593
+ /**
1594
+ * Names where the date belongs, since the finding is a date to move rather
1595
+ * than one to delete, and reports without failing the run for the same reason
1596
+ * the frontmatter measures do.
1597
+ */
1598
+ function reportProvenance(report: SkillsAudit): void {
1599
+ logStep('Provenance')
1600
+ logInfo(
1601
+ 'A SKILL.md and its references state the current rule, with no ISO date outside a fence or code span.',
1602
+ )
1603
+ logInfo(
1604
+ 'The incident and date that earned a rule go in REQUIREMENT.md under Gap, or stay in git.',
1605
+ )
1606
+
1607
+ if (report.datedProvenance.length === 0) {
1608
+ logInfo('No body or reference carries a date.')
1609
+ return
1610
+ }
1611
+
1612
+ logWarn(
1613
+ `${plural(report.datedProvenance.length, 'date')} in a body or reference`,
1614
+ )
1615
+ reportFindings(report.datedProvenance)
1616
+ }
1617
+
1591
1618
  /**
1592
1619
  * Stated on every run, including the run where everything above passed. A
1593
1620
  * report that lists only what it measured reads as a verdict on the standard
@@ -1604,4 +1631,7 @@ function reportUnmeasured(): void {
1604
1631
  logInfo(
1605
1632
  'The 150-line body checkpoint is mechanical and still absent here, and it would print a count rather than a defect.',
1606
1633
  )
1634
+ logInfo(
1635
+ 'Undated provenance, such as "a pull request dropped...", and a date written in words or without its day are not read.',
1636
+ )
1607
1637
  }
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
3
3
  import { dirname, relative, resolve } from 'node:path'
4
4
  import type { Command } from 'commander'
5
5
  import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
6
+ import { lengthExemption } from '@/markdown/ceiling'
6
7
  import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
7
8
  import { isGating } from '@/markdown/gate'
8
9
  import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
@@ -16,6 +17,7 @@ import {
16
17
  BASELINE,
17
18
  CHECKPOINTS,
18
19
  type Checkpoints,
20
+ documentHeight,
19
21
  measureStructure,
20
22
  type StructureReport,
21
23
  } from '@/markdown/structure'
@@ -51,6 +53,9 @@ interface FileReport {
51
53
  readonly bans: readonly BanFinding[]
52
54
  readonly links: readonly LinkFinding[]
53
55
  readonly structure: StructureReport
56
+ readonly renderedLines: number
57
+ /** The stated reason, or null where the document is not exempt. */
58
+ readonly exempt: string | null
54
59
  }
55
60
 
56
61
  export function register(program: Command): void {
@@ -62,7 +67,7 @@ export function register(program: Command): void {
62
67
  markdown
63
68
  .command('audit')
64
69
  .description(
65
- 'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and depth',
70
+ 'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, depth, and length',
66
71
  )
67
72
  .argument(
68
73
  '[path...]',
@@ -85,6 +90,11 @@ export function register(program: Command): void {
85
90
  'Bullet, paragraph, and depth weight are judgments a reader settles,',
86
91
  'so all three report and none of them fails a run.',
87
92
  '',
93
+ 'Length reports a whole document past the ceiling and never exits 2.',
94
+ 'The Document ceiling gate stage owns that verdict, since this audit also',
95
+ 'runs on a record kept out of version control, where a long plan is not',
96
+ 'a defect.',
97
+ '',
88
98
  'Cadence reports the same way and carries one more caveat. Its range is',
89
99
  'drawn from prose a person reads, so terse reference prose sits below',
90
100
  'it correctly and a flat paragraph there is not a defect.',
@@ -148,7 +158,8 @@ async function runAudit(
148
158
  const reports: FileReport[] = await Promise.all(
149
159
  scope.files.map(async (rel) => {
150
160
  const abs = resolve(root, rel)
151
- const lines = bodyLines(await readFile(abs, 'utf8'))
161
+ const source = await readFile(abs, 'utf8')
162
+ const lines = bodyLines(source)
152
163
  return {
153
164
  rel,
154
165
  bans: scanBans(lines, bans),
@@ -156,6 +167,8 @@ async function runAudit(
156
167
  existsSync(resolve(dirname(abs), path)),
157
168
  ),
158
169
  structure: measureStructure(rel, lines, checkpoints),
170
+ renderedLines: documentHeight(source, checkpoints.renderWidth),
171
+ exempt: lengthExemption(rel, source),
159
172
  }
160
173
  }),
161
174
  )
@@ -168,6 +181,7 @@ async function runAudit(
168
181
  reportParagraphs(reports, checkpoints)
169
182
  reportCadence(reports, checkpoints)
170
183
  reportDepth(reports, checkpoints)
184
+ reportLength(reports, checkpoints)
171
185
  outro()
172
186
 
173
187
  if (opts.json) {
@@ -192,6 +206,7 @@ async function runAudit(
192
206
  cadence: checkpoints.cadence,
193
207
  spread: checkpoints.spread,
194
208
  opener: checkpoints.opener,
209
+ ceiling: checkpoints.ceiling,
195
210
  },
196
211
  entries: reports.map((report) => ({
197
212
  path: report.rel,
@@ -202,6 +217,8 @@ async function runAudit(
202
217
  heavyBullets: report.structure.heavyBullets,
203
218
  heavyParagraphs: report.structure.heavyParagraphs,
204
219
  cadence: report.structure.cadence,
220
+ renderedLines: report.renderedLines,
221
+ exempt: report.exempt,
205
222
  })),
206
223
  })}\n`,
207
224
  )
@@ -606,3 +623,47 @@ function reportDepth(
606
623
  .join('\n'),
607
624
  )
608
625
  }
626
+
627
+ /**
628
+ * Lists every document past the ceiling and counts the exempt ones rather than
629
+ * naming them, since an exemption is settled where its marker sits.
630
+ */
631
+ function reportLength(
632
+ reports: readonly FileReport[],
633
+ checkpoints: Checkpoints,
634
+ ): void {
635
+ logStep('Length')
636
+ logInfo(
637
+ `Whole documents measure rendered lines at ${checkpoints.renderWidth} columns, frontmatter and fenced blocks included.`,
638
+ )
639
+ logInfo(
640
+ 'A changelog is exempt by name, and so is a document carrying a canon-length-exempt marker that names a reason.',
641
+ )
642
+
643
+ const past = reports.filter(
644
+ (report) => report.renderedLines > checkpoints.ceiling,
645
+ )
646
+ const over = past
647
+ .filter((report) => report.exempt === null)
648
+ .sort((a, b) => b.renderedLines - a.renderedLines)
649
+ const exempt = past.length - over.length
650
+
651
+ if (over.length === 0) {
652
+ logInfo(`No document past the ${checkpoints.ceiling}-line ceiling.`)
653
+ } else {
654
+ logWarn(
655
+ `${plural(over.length, 'document')} past the ${checkpoints.ceiling}-line ceiling`,
656
+ )
657
+ pipeOutput(
658
+ over
659
+ .map(
660
+ (report) => `${report.rel} ${report.renderedLines} rendered lines`,
661
+ )
662
+ .join('\n'),
663
+ )
664
+ }
665
+
666
+ if (exempt > 0) {
667
+ logInfo(`${plural(exempt, 'exempt document')} past the ceiling.`)
668
+ }
669
+ }
@@ -4,7 +4,7 @@ import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
4
4
  import type { AuditedFolder } from '@/context/folders'
5
5
  import type { NarrationTerms } from '@/context/narration'
6
6
  import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
7
- import { renderedHeight } from '@/markdown/structure'
7
+ import { documentHeight } from '@/markdown/structure'
8
8
  import { isStubSeed } from '@/seed-marker'
9
9
 
10
10
  /**
@@ -214,9 +214,10 @@ export interface EntryReport {
214
214
  readonly rel: string
215
215
  /**
216
216
  * Rendered lines across the whole file, counting frontmatter and fenced
217
- * blocks. It shares `renderedHeight` with the depth checkpoint in
218
- * `src/markdown/structure.ts`, since the two sit in one section of the
219
- * standard and a reader compares them.
217
+ * blocks. It counts through `documentHeight` in `src/markdown/structure.ts`,
218
+ * which shares `renderedHeight` with the depth checkpoint and with the
219
+ * document ceiling, since all three sit beside each other in the standard
220
+ * and a reader compares them.
220
221
  *
221
222
  * Fences are counted here and excluded there. The depth measure skips one so
222
223
  * an example cannot break the run around it, and a file measure has no run to
@@ -646,10 +647,7 @@ export function measureEntry(
646
647
 
647
648
  return {
648
649
  rel,
649
- lines: source
650
- .replace(/\n$/, '')
651
- .split('\n')
652
- .reduce((sum, text) => sum + renderedHeight(text), 0),
650
+ lines: documentHeight(source),
653
651
  catalogTables: catalogTables(lines),
654
652
  provenance: governsContent ? provenance(lines) : [],
655
653
  narration: governsContent && terms ? narration(lines, terms) : [],
@@ -13,6 +13,9 @@ import {
13
13
  measureArchitecture,
14
14
  } from '@/context/architecture'
15
15
  import { listRepositoryFiles } from '@/git-files'
16
+ import { ceilingFindings } from '@/markdown/ceiling'
17
+ import { resolveMarkdown } from '@/markdown/files'
18
+ import { CHECKPOINTS } from '@/markdown/structure'
16
19
  import {
17
20
  isShippedCorpus,
18
21
  REFERENCE_MARKER,
@@ -21,6 +24,7 @@ import {
21
24
  type ShippedReference,
22
25
  } from '@/shipped/references'
23
26
  import { surfaceDir } from '@/surface-root'
27
+ import { plural } from '@/ui'
24
28
  import {
25
29
  README_PARAPHRASE_MARKER,
26
30
  readmeCitationsIn,
@@ -273,6 +277,62 @@ export const architectureRecord: Measure = async (ctx) => {
273
277
  }
274
278
  }
275
279
 
280
+ /**
281
+ * Every tracked markdown document against the whole-document ceiling.
282
+ *
283
+ * Report-only for now: a document past the ceiling warns and the stage still
284
+ * passes, since the corpus carried dozens past it when the stage landed. The
285
+ * flip to a failure is a separate change once the count reads zero, and it
286
+ * replaces the warn path rather than sitting beside it.
287
+ *
288
+ * Read in-process rather than through `canon markdown audit`, whose exit code
289
+ * stays bans and dead links only. The plan and groundwork skills run that audit
290
+ * on a gitignored record, and a gating length there would fail every long plan.
291
+ */
292
+ export const documentCeiling: Measure = async (ctx) => {
293
+ const scope = await resolveMarkdown(ctx.root, [])
294
+ if (scope.kind === 'unavailable') {
295
+ return {
296
+ emissions: [],
297
+ unmeasured: 'git could not list the tree, so no document was measured.',
298
+ }
299
+ }
300
+
301
+ const documents = scope.files.flatMap((rel) => {
302
+ try {
303
+ return [{ rel, source: readFileSync(join(ctx.root, rel), 'utf8') }]
304
+ } catch {
305
+ return []
306
+ }
307
+ })
308
+ const findings = ceilingFindings(documents)
309
+ const over = findings.filter((finding) => finding.exempt === null)
310
+ const exempt = findings.length - over.length
311
+ const ceiling = CHECKPOINTS.ceiling
312
+
313
+ if (over.length === 0) {
314
+ const exemptNote = exempt > 0 ? `, ${exempt} exempt` : ''
315
+ return {
316
+ emissions: [
317
+ info(
318
+ `No document past the ${ceiling}-line ceiling across ${plural(documents.length, 'markdown file')}${exemptNote}`,
319
+ ),
320
+ ],
321
+ }
322
+ }
323
+
324
+ return {
325
+ emissions: [
326
+ ...over.map((finding) =>
327
+ warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
328
+ ),
329
+ info(
330
+ `${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
331
+ ),
332
+ ],
333
+ }
334
+ }
335
+
276
336
  /**
277
337
  * A second run of the records move should rewrite nothing, and the count is
278
338
  * only knowable once the folders themselves have landed.
@@ -3,6 +3,7 @@ import {
3
3
  auditSet,
4
4
  captureStamps,
5
5
  clientCommandCitations,
6
+ documentCeiling,
6
7
  markdownBans,
7
8
  type Measure,
8
9
  pluginManifests,
@@ -333,9 +334,11 @@ export const STAGES: readonly Stage[] = [
333
334
  checks: [{ kind: 'measure', measure: unreferencedRules }],
334
335
  },
335
336
  {
336
- // Only the citation half of the audit gates. Length, depth, table, and
337
+ // Only the citation half of the audit gates. Its length, depth, table, and
337
338
  // index findings are judgment thresholds, and failing a push on one would
338
- // make the stage something to route around.
339
+ // make the stage something to route around. The one length reading that
340
+ // will gate is the whole-document ceiling, which the Document ceiling stage
341
+ // below owns rather than this audit.
339
342
  id: 'context-citations',
340
343
  label: 'Context citations',
341
344
  checks: [
@@ -355,6 +358,13 @@ export const STAGES: readonly Stage[] = [
355
358
  label: 'Architecture record',
356
359
  checks: [{ kind: 'measure', measure: architectureRecord }],
357
360
  },
361
+ {
362
+ // Unscoped, since any branch can grow any document. It warns and passes
363
+ // until the corpus sits under the ceiling, then flips to a failure.
364
+ id: 'document-ceiling',
365
+ label: 'Document ceiling',
366
+ checks: [{ kind: 'measure', measure: documentCeiling }],
367
+ },
358
368
  {
359
369
  // A rule citing a file that moved fails silently. The consumed-copy drift
360
370
  // stage passes an authored rule and its copy that are wrong together, and
@@ -0,0 +1,70 @@
1
+ import { basename } from 'node:path'
2
+ import { bodyLines } from '@/markdown/scan'
3
+ import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
4
+
5
+ /**
6
+ * The marker exempting one document from the ceiling, written as a whole line
7
+ * outside a fence and naming a reason.
8
+ *
9
+ * The whole line rather than anywhere on it, so a standard quoting the marker
10
+ * inside a code span to document it does not exempt itself. A marker with no
11
+ * reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
12
+ * gives: a bare token reads as a line that meant to say something and did not.
13
+ */
14
+ const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
15
+
16
+ /**
17
+ * A changelog is exempt by name because the release tool rewrites it and would
18
+ * not keep a marker in place. A committed list of exempt paths was the
19
+ * alternative, and it turns every rename into a second edit with nothing to
20
+ * check the list against the tree.
21
+ */
22
+ const CHANGELOG = 'CHANGELOG.md'
23
+ const CHANGELOG_REASON = 'CHANGELOG.md is written by the release tool'
24
+
25
+ export interface CeilingDocument {
26
+ readonly rel: string
27
+ readonly source: string
28
+ }
29
+
30
+ export interface CeilingFinding {
31
+ readonly rel: string
32
+ readonly renderedLines: number
33
+ /** The stated reason, or null where the document is not exempt. */
34
+ readonly exempt: string | null
35
+ }
36
+
37
+ /** The reason a document is exempt from the ceiling, or null where it is not. */
38
+ export function lengthExemption(rel: string, source: string): string | null {
39
+ if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
40
+
41
+ for (const line of bodyLines(source)) {
42
+ if (line.fenced) continue
43
+ const match = EXEMPT_MARKER.exec(line.text.trim())
44
+ if (match) return match[1]
45
+ }
46
+
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Every document past the ceiling, longest first, exempt ones included with
52
+ * their reason so a report can count them rather than hide them.
53
+ */
54
+ export function ceilingFindings(
55
+ documents: readonly CeilingDocument[],
56
+ ): CeilingFinding[] {
57
+ return documents
58
+ .map(({ rel, source }) => ({
59
+ rel,
60
+ renderedLines: documentHeight(source),
61
+ source,
62
+ }))
63
+ .filter(({ renderedLines }) => renderedLines > CHECKPOINTS.ceiling)
64
+ .map(({ rel, renderedLines, source }) => ({
65
+ rel,
66
+ renderedLines,
67
+ exempt: lengthExemption(rel, source),
68
+ }))
69
+ .sort((a, b) => b.renderedLines - a.renderedLines)
70
+ }
@@ -116,6 +116,7 @@ export const CHECKPOINTS = {
116
116
  cadence: 3,
117
117
  spread: 5,
118
118
  opener: 2,
119
+ ceiling: 300,
119
120
  } as const
120
121
 
121
122
  /**
@@ -167,6 +168,11 @@ export interface Checkpoints {
167
168
  readonly spread: number
168
169
  /** Times one opening word may open a sentence in a paragraph before it is a pattern. */
169
170
  readonly opener: number
171
+ /**
172
+ * Rendered lines a whole document may reach, read by the Document ceiling
173
+ * gate stage rather than by any exit code of the audit.
174
+ */
175
+ readonly ceiling: number
170
176
  }
171
177
 
172
178
  export interface BulletFinding {
@@ -247,6 +253,22 @@ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
247
253
  return Math.max(1, Math.ceil(visibleText(text).length / width))
248
254
  }
249
255
 
256
+ /**
257
+ * Height a whole source occupies once wrapped, frontmatter and fenced blocks
258
+ * included.
259
+ *
260
+ * Nothing is excluded because a session pays for every line it loads, which is
261
+ * what separates this from the depth measure. The context audit's length
262
+ * reading and the document ceiling both count through here, so the two report
263
+ * one unit.
264
+ */
265
+ export function documentHeight(source: string, width = RENDER_WIDTH): number {
266
+ return source
267
+ .replace(/\n$/, '')
268
+ .split('\n')
269
+ .reduce((sum, text) => sum + renderedHeight(text, width), 0)
270
+ }
271
+
250
272
  /**
251
273
  * Reports whether a run is the peer list the standard exempts.
252
274
  *
@@ -6,7 +6,12 @@ import { copyPreservingMode } from '@/copy'
6
6
  import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
7
7
  import { resolveSurfacePath } from '@/surface-root'
8
8
  import { mergeSections, pruneSections } from '@/tooling/gitignore'
9
- import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
9
+ import {
10
+ ancestorsFirst,
11
+ configPaths,
12
+ listFiles,
13
+ type Manifest,
14
+ } from '@/tooling/manifest'
10
15
  import {
11
16
  applyScripts,
12
17
  collectDeps,
@@ -97,9 +102,14 @@ export async function injectSeeds(
97
102
  target: string,
98
103
  ): Promise<string[]> {
99
104
  const applied: string[] = []
105
+ const isSubfolder = isSubfolderTarget(target)
106
+ const shadowed = configPaths(chain)
100
107
 
101
108
  for (const manifest of ancestorsFirst(chain)) {
102
- const files = listFiles(manifest.seedsDir)
109
+ const files = listFiles(manifest.seedsDir).filter(
110
+ (rel) =>
111
+ !shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
112
+ )
103
113
  if (files.length === 0) continue
104
114
 
105
115
  logStep(`Applying ${manifest.name} seeds`)
@@ -111,9 +121,7 @@ export async function injectSeeds(
111
121
  }
112
122
  }
113
123
 
114
- const spellConfig = isSubfolderTarget(target)
115
- ? subfolderSpellConfig(chain)
116
- : undefined
124
+ const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
117
125
  if (spellConfig !== undefined && !hasSpellConfig(target)) {
118
126
  logStep('Applying subfolder spell config')
119
127
  await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
@@ -44,6 +44,15 @@ export function listFiles(dir: string): string[] {
44
44
  ].sort()
45
45
  }
46
46
 
47
+ /**
48
+ * Every path any stack in the chain ships as a config. A seed at one of these
49
+ * paths is shadowed, since the config overwrites it on every sync. `scan` and
50
+ * `injectSeeds` both read this, so the report and the write cannot disagree.
51
+ */
52
+ export function configPaths(chain: readonly Manifest[]): Set<string> {
53
+ return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
54
+ }
55
+
47
56
  /**
48
57
  * Reverses a chain so the furthest ancestor is applied first and nearer
49
58
  * stacks overwrite it.