@erclx/canon 5.49.0 → 5.50.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 (46) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
  3. package/claude/skills/canvas-board/SKILL.md +8 -9
  4. package/claude/skills/context-fold/references/context.md +4 -3
  5. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  6. package/claude/skills/design-record/SKILL.md +84 -0
  7. package/claude/skills/design-record/references/folder.md +69 -0
  8. package/claude/skills/design-taste/SKILL.md +1 -1
  9. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  10. package/claude/skills/markdown-craft/SKILL.md +6 -1
  11. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  12. package/claude/skills/markdown-craft/references/tables.md +30 -0
  13. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  14. package/claude/skills/plan-feature/SKILL.md +13 -1
  15. package/claude/skills/session-worktree/SKILL.md +1 -1
  16. package/docs/agents/index.md +1 -1
  17. package/docs/agents/markdown-audit.md +5 -2
  18. package/docs/target/migrations.md +7 -0
  19. package/docs/target/projects.md +1 -1
  20. package/docs/workflow/skill-map.md +2 -1
  21. package/docs/workflow/visual-design-workflow.md +2 -1
  22. package/governance/ledger/ui/design-reference.md +1 -0
  23. package/governance/rules/ui/design-reference.md +1 -0
  24. package/package.json +1 -1
  25. package/src/claude/cases/misc.ts +5 -0
  26. package/src/commands/claude.ts +26 -1
  27. package/src/commands/gate.ts +57 -16
  28. package/src/commands/markdown.ts +98 -19
  29. package/src/context/audit.ts +6 -5
  30. package/src/design/budget.ts +1 -1
  31. package/src/design/components.ts +1 -1
  32. package/src/design/document.ts +1 -1
  33. package/src/design/regen.ts +1 -1
  34. package/src/design/render.ts +1 -1
  35. package/src/gate/hand-suite.ts +52 -0
  36. package/src/gate/measures.ts +9 -2
  37. package/src/gate/sequencer.ts +17 -8
  38. package/src/markdown/gated.ts +5 -7
  39. package/src/markdown/scope.ts +60 -10
  40. package/src/markdown/structure.ts +1 -1
  41. package/src/markdown/tables.ts +150 -0
  42. package/src/tasks/validate.ts +15 -12
  43. package/tooling/claude/reference.md +1 -0
  44. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  45. package/tooling/claude/seeds/CLAUDE.md +9 -0
  46. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -71,6 +71,9 @@ import {
71
71
  serializeSettings,
72
72
  writeSettings,
73
73
  } from '@/claude/settings'
74
+ import { bodyLines } from '@/markdown/scan'
75
+ import { scopedDensityFindings } from '@/markdown/scope'
76
+ import { CAP } from '@/markdown/stacks'
74
77
  import { readSessionDefaults } from '@/migrate/session-defaults'
75
78
  import { copyPreservingMode } from '@/sync/copy'
76
79
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
@@ -176,6 +179,22 @@ export function seededDirPath(resolved: string, name: string): string {
176
179
  ? surfaceDir(resolved, name)
177
180
  : recordDir(resolved, name)
178
181
  }
182
+
183
+ /**
184
+ * Each bullet run past the limit in the target's `CLAUDE.md` Key paths, read
185
+ * through the scope the audit gates on. Sync warns rather than gates, since a
186
+ * target gates only the prefixes its own config lists.
187
+ */
188
+ export async function longKeyPathsRuns(
189
+ resolved: string,
190
+ ): Promise<{ line: number; count: number }[]> {
191
+ const path = join(resolved, 'CLAUDE.md')
192
+ if (!existsSync(path)) return []
193
+ const lines = bodyLines(await readFile(path, 'utf8'))
194
+ return scopedDensityFindings('CLAUDE.md', lines)
195
+ .filter((finding) => finding.kind === 'bullet-run')
196
+ .map((finding) => ({ line: finding.line, count: finding.value ?? 0 }))
197
+ }
179
198
  const USER_DIR = join('tooling', 'claude', 'user')
180
199
  const STATUSLINE = 'statusline-command.sh'
181
200
  const PLUGIN_MANIFEST = join('claude', '.claude-plugin', 'plugin.json')
@@ -244,7 +263,8 @@ export function register(program: Command): void {
244
263
  'Writes .gitignore alone: adds the entries the claude manifest lists',
245
264
  'and drops ones under its headers that the manifest no longer lists.',
246
265
  'A seeded file is reported, never rewritten, and a missing one points',
247
- 'at `canon claude init`.',
266
+ 'at `canon claude init`. A CLAUDE.md Key paths running past 5 bullets',
267
+ 'is reported with the H3 grouping that fixes it.',
248
268
  '',
249
269
  'Exit codes:',
250
270
  ' 0 synced, already up to date, or the prompt was cancelled',
@@ -665,6 +685,11 @@ async function runSync(target: string): Promise<number> {
665
685
  if (isDirectory(seededDirPath(resolved, name))) logInfo(`${name}/`)
666
686
  else logWarn(`${name}/ missing. Run \`canon claude init\``)
667
687
  }
688
+ for (const run of await longKeyPathsRuns(resolved)) {
689
+ logWarn(
690
+ `CLAUDE.md:${run.line} runs ${run.count} Key paths bullets past ${CAP}. Group them under H3s, each opening on a lead sentence`,
691
+ )
692
+ }
668
693
 
669
694
  // Sync never rewrites an existing settings file, so a target seeded before
670
695
  // the session defaults learns of them only here.
@@ -130,22 +130,9 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
130
130
  process.stderr.write(`${failed.label}: ${failed.failure}\n`)
131
131
  }
132
132
  process.stdout.write(
133
- `${JSON.stringify({
134
- ok: code === 0,
135
- root,
136
- scoped: changed.scoped,
137
- changed: changed.files.length,
138
- summary,
139
- ms: results.reduce((total, result) => total + result.ms, 0),
140
- stages: results.map(({ id, label, status, failure, ms, queuedMs }) => ({
141
- id,
142
- label,
143
- status,
144
- failure,
145
- ms,
146
- queuedMs,
147
- })),
148
- })}\n`,
133
+ `${JSON.stringify(
134
+ gateRecord({ ok: code === 0, root, changed, summary, results }),
135
+ )}\n`,
149
136
  )
150
137
  return code
151
138
  }
@@ -154,6 +141,60 @@ async function runGate(opts: RunCommandOptions): Promise<number> {
154
141
  return code
155
142
  }
156
143
 
144
+ /** Lines of a failed stage's output the record keeps, counted from the end. */
145
+ const OUTPUT_TAIL_LINES = 200
146
+
147
+ interface GateRecordInput {
148
+ readonly ok: boolean
149
+ readonly root: string
150
+ readonly changed: {
151
+ readonly scoped: boolean
152
+ readonly files: readonly string[]
153
+ }
154
+ readonly summary: Summary
155
+ readonly results: readonly StageResult[]
156
+ }
157
+
158
+ /**
159
+ * The `--json` record. A failed stage carries the tail of the output its
160
+ * checks printed, since a caller reading the record alone otherwise reruns
161
+ * the stage by hand to learn which test failed. The tail rather than a parse
162
+ * of the `FAIL` lines, so a generic stage stays free of one runner's format,
163
+ * and a dot-reporter run prints its failures and summary last.
164
+ */
165
+ export function gateRecord(input: GateRecordInput) {
166
+ const { ok, root, changed, summary, results } = input
167
+ return {
168
+ ok,
169
+ root,
170
+ scoped: changed.scoped,
171
+ changed: changed.files.length,
172
+ summary,
173
+ ms: results.reduce((total, result) => total + result.ms, 0),
174
+ stages: results.map(
175
+ ({ id, label, status, failure, ms, queuedMs, emissions }) => ({
176
+ id,
177
+ label,
178
+ status,
179
+ failure,
180
+ ms,
181
+ queuedMs,
182
+ output: status === 'failed' ? outputTail(emissions) : undefined,
183
+ }),
184
+ ),
185
+ }
186
+ }
187
+
188
+ function outputTail(emissions: StageResult['emissions']): string {
189
+ const lines = emissions
190
+ .filter((emission) => emission.kind === 'output')
191
+ .map((emission) => emission.text.trimEnd())
192
+ .filter((text) => text !== '')
193
+ .join('\n')
194
+ .split('\n')
195
+ return lines.slice(-OUTPUT_TAIL_LINES).join('\n')
196
+ }
197
+
157
198
  function report(result: StageResult): void {
158
199
  logStep(result.label)
159
200
  for (const emission of result.emissions) {
@@ -6,7 +6,7 @@ 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 { densityFindings, type DensityKind } from '@/markdown/density'
9
+ import type { DensityKind } from '@/markdown/density'
10
10
  import {
11
11
  type GatedDensityFinding,
12
12
  type GatedReferenceShapeFinding,
@@ -38,7 +38,19 @@ import {
38
38
  readGatedConfig,
39
39
  resolveGatedList,
40
40
  } from '@/markdown/gated'
41
- import { densityGates, inDensityScope } from '@/markdown/scope'
41
+ import {
42
+ densityGates,
43
+ inDensityScope,
44
+ scopedDensityFindings,
45
+ } from '@/markdown/scope'
46
+ import {
47
+ TABLE_CELL,
48
+ TABLE_ROWS,
49
+ TABLE_TOKEN,
50
+ type TableFinding,
51
+ tableFindings,
52
+ type TableKind,
53
+ } from '@/markdown/tables'
42
54
  import {
43
55
  BASELINE,
44
56
  CHECKPOINTS,
@@ -86,6 +98,8 @@ interface FileReport {
86
98
  readonly headingCase: readonly HeadingCaseFinding[]
87
99
  /** Empty on a path outside the density scope, so a plan never carries one. */
88
100
  readonly density: readonly GatedDensityFinding[]
101
+ /** Empty outside the density scope, and never gating anywhere. */
102
+ readonly tables: readonly TableFinding[]
89
103
  /** Empty on a page whose frontmatter does not carry `kind: reference`. */
90
104
  readonly referenceShape: readonly GatedReferenceShapeFinding[]
91
105
  readonly structure: StructureReport
@@ -131,13 +145,20 @@ export function register(program: Command): void {
131
145
  'a row, a 200-character bullet, 5 plain bullets in a row, no H4, and a',
132
146
  'paragraph or table opening every H3. Only context, docs, skills, tooling',
133
147
  'seeds and references, the wiki, README.md, and CONTRIBUTING.md take a',
134
- 'finding. In a canon checkout one gates under a prefix in GATED_PATHS in',
135
- 'src/markdown/scope.ts, and a bullet run gates in context, docs, and',
136
- 'skills from the start. Elsewhere one gates only under a prefix',
137
- 'canon/config/markdown.toml lists, which canon markdown gated add writes,',
138
- 'and with no such file every density finding reports and none gates.',
148
+ 'finding. CLAUDE.md and its seed take only the bullet run and the H3',
149
+ 'opening, inside Key paths. In a canon checkout one gates under a',
150
+ 'prefix in GATED_PATHS in src/markdown/scope.ts, and a bullet run gates',
151
+ 'in context, docs, and skills from the start. Elsewhere one gates only',
152
+ 'under a prefix canon/config/markdown.toml lists, which canon markdown',
153
+ 'gated add writes, and with no such file every density finding reports',
154
+ 'and none gates.',
139
155
  'A config that does not parse refuses with its path named.',
140
156
  '',
157
+ 'Tables take four limits on the same paths, CLAUDE.md aside: a',
158
+ '120-character cell, 8 body rows, a list as the next block, and a code',
159
+ 'span with a run past 30 characters. A table finding reports and never',
160
+ 'fails a run.',
161
+ '',
141
162
  'A page whose frontmatter carries kind: reference takes the reference',
142
163
  'shape: no H2 beyond Output, Refusals, and See also, each at most once',
143
164
  'and in that order, no H3, and no table whose first header cell names',
@@ -361,17 +382,16 @@ async function runAudit(
361
382
  bans: scanBans(lines, bans),
362
383
  colons: advisory ? findColons(lines) : [],
363
384
  headingCase: advisory ? findHeadingCase(source) : [],
364
- density: inDensityScope(rel)
365
- ? densityFindings(lines).map((finding) => ({
366
- ...finding,
367
- gating: densityGates(
368
- rel,
369
- finding.kind,
370
- gated.prefixes,
371
- gated.alwaysGated,
372
- ),
373
- }))
374
- : [],
385
+ density: scopedDensityFindings(rel, lines).map((finding) => ({
386
+ ...finding,
387
+ gating: densityGates(
388
+ rel,
389
+ finding.kind,
390
+ gated.prefixes,
391
+ gated.alwaysGated,
392
+ ),
393
+ })),
394
+ tables: inDensityScope(rel) ? tableFindings(lines) : [],
375
395
  referenceShape: referenceShapeFindings(source).map((finding) => ({
376
396
  ...finding,
377
397
  gating: referenceShapeGates(rel, gated.prefixes),
@@ -393,6 +413,7 @@ async function runAudit(
393
413
  reportColons(reports)
394
414
  reportHeadingCase(reports)
395
415
  reportDensity(reports, gated, resolution.source)
416
+ reportTables(reports)
396
417
  reportReferenceShape(reports, gated)
397
418
  reportBullets(reports, checkpoints)
398
419
  reportParagraphs(reports, checkpoints)
@@ -430,6 +451,7 @@ async function runAudit(
430
451
  colons: report.colons,
431
452
  headingCase: report.headingCase,
432
453
  density: report.density,
454
+ tables: report.tables,
433
455
  referenceShape: report.referenceShape,
434
456
  longestRun: report.structure.longestRun,
435
457
  longestRunLine: report.structure.longestRunLine,
@@ -643,7 +665,7 @@ function reportDensity(
643
665
  `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.`,
644
666
  )
645
667
  logInfo(
646
- 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. Every other path takes no density finding.',
668
+ 'Measured on context entries, docs, skill bodies, references, and requirements, tooling seeds and stack references, the wiki, README.md, and CONTRIBUTING.md. CLAUDE.md and its seed take the bullet run and the H3 opening inside Key paths alone. Every other path takes no density finding.',
647
669
  )
648
670
  logInfo(densityGatingLine(gated, source))
649
671
 
@@ -695,6 +717,63 @@ function densityGatingLine(
695
717
  : `A finding gates under a prefix ${GATED_CONFIG_REL} lists (${listed}). Every other finding reports.`
696
718
  }
697
719
 
720
+ const TABLE_LABELS: Record<TableKind, string> = {
721
+ 'table-cell': 'cell length',
722
+ 'table-rows': 'rows',
723
+ 'table-then-list': 'list after table',
724
+ 'table-token': 'wide token',
725
+ }
726
+
727
+ function describeTable(finding: TableFinding): string {
728
+ const measured =
729
+ finding.kind === 'table-then-list'
730
+ ? 'follows the table with no lead sentence'
731
+ : `${finding.value} past ${finding.limit}`
732
+ return ` :${finding.line} ${TABLE_LABELS[finding.kind]} ${measured}`
733
+ }
734
+
735
+ /**
736
+ * Lists every table finding on the density scope's paths. None gates yet,
737
+ * since a gate is decided per path rather than per kind, and a new density
738
+ * kind would fail every gated folder carrying a table hit at once.
739
+ */
740
+ function reportTables(reports: readonly FileReport[]): void {
741
+ logStep('Tables')
742
+ logInfo(
743
+ `Cells past ${TABLE_CELL} characters, tables past ${TABLE_ROWS} body rows, a list directly after a table, and a code span in a cell holding a run past ${TABLE_TOKEN} characters.`,
744
+ )
745
+ logInfo(
746
+ 'Measured on the paths density measures in full, so never on CLAUDE.md, outside fenced blocks. This never fails a run.',
747
+ )
748
+
749
+ const carrying = reports
750
+ .filter((report) => report.tables.length > 0)
751
+ .sort((a, b) => b.tables.length - a.tables.length)
752
+
753
+ if (carrying.length === 0) {
754
+ logInfo('No table finding.')
755
+ return
756
+ }
757
+
758
+ const total = carrying.reduce((sum, report) => sum + report.tables.length, 0)
759
+ logWarn(
760
+ `${plural(total, 'finding')} across ${plural(carrying.length, 'file')}`,
761
+ )
762
+ logInfo(
763
+ 'Move a long cell into a bullet under the lead, split a long table into groups each under a lead sentence, give a trailing list its own lead, and move a wide token out of the table.',
764
+ )
765
+ pipeOutput(
766
+ carrying
767
+ .map(
768
+ (report) =>
769
+ `${report.rel} ${plural(report.tables.length, 'finding')}\n${report.tables
770
+ .map(describeTable)
771
+ .join('\n')}`,
772
+ )
773
+ .join('\n'),
774
+ )
775
+ }
776
+
698
777
  const REFERENCE_SHAPE_LABELS: Record<ReferenceShapeKind, string> = {
699
778
  'extra-section': 'section outside the shape',
700
779
  'section-order': 'section out of order or repeated',
@@ -108,13 +108,14 @@ export const OTHER_FOLDERS = 'Other folders'
108
108
  export const SCRIPTS_HEADING = 'Scripts'
109
109
 
110
110
  /**
111
- * Whether an entry path is the development entry, being the flat entry, the
112
- * overview of a split one a target may still carry, or this repository's
113
- * `local-loop` entry. The standard exempts the
114
- * `Scripts` H3 for that entry alone, so the path is what tells.
111
+ * Whether an entry path is the development entry, being the flat entry or any
112
+ * entry directly under a `development/` folder, since a split folder names its
113
+ * scripts entry by project, as `local-loop` here and `local` in another. The
114
+ * standard exempts the `Scripts` H3 for that entry alone, so the path is what
115
+ * tells.
115
116
  */
116
117
  function isDevelopmentEntry(rel: string): boolean {
117
- return /(?:^|\/)development(?:\/(?:overview|local-loop))?\.md$/.test(rel)
118
+ return /(?:^|\/)development(?:\/[^/]+)?\.md$/.test(rel)
118
119
  }
119
120
 
120
121
  /**
@@ -1,7 +1,7 @@
1
1
  import { canonicalHeading } from '@/design/parse'
2
2
 
3
3
  /**
4
- * The prose budget `claude/skills/canvas-board/references/design.md` sets for a design record, and the
4
+ * The prose budget `claude/skills/design-record/references/design.md` sets for a design record, and the
5
5
  * counter the `design` gate stage reads it through.
6
6
  *
7
7
  * The limits live here and in the standard, never in the stage, so the stage
@@ -15,7 +15,7 @@
15
15
  * `buildDesignCss`'s `components` option, which is what widening that option
16
16
  * from a boolean to `boolean | readonly Component[]` bought.
17
17
  *
18
- * It does not live in `canon/DESIGN.md`. `claude/skills/canvas-board/references/design.md` keeps CSS class
18
+ * It does not live in `canon/DESIGN.md`. `claude/skills/design-record/references/design.md` keeps CSS class
19
19
  * names out of that record and says they live in code, which is here.
20
20
  */
21
21
 
@@ -94,7 +94,7 @@ function borderRows(tokens: readonly BorderToken[]): string[][] {
94
94
  * and every reason behind them lives in the entry.
95
95
  */
96
96
  const LEAD = [
97
- 'Authoring guidance: `claude/skills/canvas-board/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
97
+ 'Authoring guidance: `claude/skills/design-record/references/design.md`. Rendered from `src/design/tokens.ts` by `canon design regen`, so edit the module and never this file.',
98
98
  'Why each value was chosen lives in `canon/context/features/design.md`.',
99
99
  ].join(' ')
100
100
 
@@ -16,7 +16,7 @@ import { NEUTRAL_TOKENS } from '@/design/neutral'
16
16
  * gate stage is what keeps silently out of it.
17
17
  */
18
18
 
19
- /** The rendered record, at the path `claude/skills/canvas-board/references/design.md` fixes for it. */
19
+ /** The rendered record, at the path `claude/skills/design-record/references/design.md` fixes for it. */
20
20
  export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
21
21
 
22
22
  /**
@@ -105,7 +105,7 @@ interface Confidence {
105
105
  }
106
106
 
107
107
  /**
108
- * The columns the confidence ratio reads, fixed by `claude/skills/canvas-board/references/design.md`. The
108
+ * The columns the confidence ratio reads, fixed by `claude/skills/design-record/references/design.md`. The
109
109
  * first column of each table names its row, and `Multiplier` and `When used`
110
110
  * restate what the row already carries, so none of them is something a source
111
111
  * could anchor and none belongs in the denominator.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Names the machine lock a gate holds while a stage's commands run, so the
3
+ * suite it spawns knows not to wait on its own parent.
4
+ */
5
+ export const HELD_LOCK_VARIABLE = 'CANON_HELD_LOCK'
6
+
7
+ /**
8
+ * The vitest invocation a hand-run lock decision reads.
9
+ */
10
+ export interface VitestRun {
11
+ /** The CLI arguments after the vitest entry, as `process.argv.slice(2)`. */
12
+ readonly args: readonly string[]
13
+ readonly ci: boolean
14
+ readonly watch: boolean
15
+ /**
16
+ * The lock a parent gate already holds for this run, from `HELD_LOCK_VARIABLE`.
17
+ * A gate's own suite waiting on that lock would wait on its parent forever.
18
+ */
19
+ readonly heldLock: string | undefined
20
+ }
21
+
22
+ const SUBCOMMANDS = new Set(['run'])
23
+
24
+ /** `-t` and its long forms filter by test name, which scopes the run. */
25
+ function isNameFilter(arg: string): boolean {
26
+ return (
27
+ arg === '-t' ||
28
+ arg.startsWith('-t=') ||
29
+ arg === '--testNamePattern' ||
30
+ arg.startsWith('--testNamePattern=') ||
31
+ arg === '--test-name-pattern' ||
32
+ arg.startsWith('--test-name-pattern=')
33
+ )
34
+ }
35
+
36
+ /**
37
+ * Whether a hand-run vitest invocation runs the whole suite and so queues on
38
+ * the machine lock the gate's Tests stage holds.
39
+ *
40
+ * A scoped run is seconds of work, and queueing it behind a full suite costs
41
+ * more than any collision it causes. Vitest exposes no setup-time API naming
42
+ * the CLI filters, so any bare argument other than the subcommand reads as a
43
+ * file filter. That includes the value of a flag spelled `--flag value`, which
44
+ * misreads such a run as scoped. The misread skips a lock rather than queueing
45
+ * a scoped run, which is the side to err on.
46
+ */
47
+ export function locksHandSuite(run: VitestRun): boolean {
48
+ if (run.ci || run.watch || run.heldLock !== undefined) return false
49
+
50
+ const args = SUBCOMMANDS.has(run.args[0] ?? '') ? run.args.slice(1) : run.args
51
+ return !args.some((arg) => isNameFilter(arg) || !arg.startsWith('-'))
52
+ }
@@ -56,7 +56,14 @@ export interface CommandResult {
56
56
  readonly spawnError?: string
57
57
  }
58
58
 
59
- export type RunCommand = (argv: readonly string[]) => Promise<CommandResult>
59
+ /**
60
+ * Spawns one command. `env` adds variables to the child's environment, which a
61
+ * locked stage uses to tell its child which lock the gate already holds.
62
+ */
63
+ export type RunCommand = (
64
+ argv: readonly string[],
65
+ env?: Readonly<Record<string, string>>,
66
+ ) => Promise<CommandResult>
60
67
 
61
68
  export interface MeasureContext {
62
69
  readonly root: string
@@ -331,7 +338,7 @@ export const designProse: Measure = async (ctx) => {
331
338
  if (failures.length > 0) {
332
339
  return {
333
340
  emissions: [],
334
- failure: `${failures.join(' ')} Cut to the budget in claude/skills/canvas-board/references/design.md, moving the why to the context entry.`,
341
+ failure: `${failures.join(' ')} Cut to the budget in claude/skills/design-record/references/design.md, moving the why to the context entry.`,
335
342
  }
336
343
  }
337
344
  if (emissions.length === 0)
@@ -8,6 +8,7 @@ import type {
8
8
  MeasureContext,
9
9
  RunCommand,
10
10
  } from '@/gate/measures'
11
+ import { HELD_LOCK_VARIABLE } from '@/gate/hand-suite'
11
12
  import type { Check, Stage } from '@/gate/stages'
12
13
  import {
13
14
  acquireSuiteLock,
@@ -248,7 +249,7 @@ async function executeStage(
248
249
  }
249
250
 
250
251
  // CI runs one suite per runner, so a lock there queues behind nothing.
251
- if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
252
+ if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [], {})
252
253
 
253
254
  const held = await acquireSuiteLock({
254
255
  path: machineLockPath(stage.lock),
@@ -266,8 +267,10 @@ async function executeStage(
266
267
  ]
267
268
 
268
269
  try {
270
+ // The suite's own global setup takes this lock for a hand run, so the
271
+ // child is told its parent holds it rather than waiting on the parent.
269
272
  const outcome = withLoadLine(
270
- await runChecks(stage, ctx, queued),
273
+ await runChecks(stage, ctx, queued, { [HELD_LOCK_VARIABLE]: stage.lock }),
271
274
  stage,
272
275
  ctx,
273
276
  )
@@ -310,11 +313,12 @@ async function runChecks(
310
313
  stage: Stage,
311
314
  ctx: GateContext,
312
315
  leading: readonly Emission[],
316
+ env: Readonly<Record<string, string>>,
313
317
  ): Promise<StageOutcome> {
314
318
  const emissions: Emission[] = [...leading]
315
319
 
316
320
  for (const check of stage.checks) {
317
- const outcome = await runCheck(check, ctx)
321
+ const outcome = await runCheck(check, ctx, env)
318
322
  emissions.push(...outcome.emissions)
319
323
 
320
324
  if (outcome.failure !== undefined) {
@@ -363,7 +367,11 @@ interface CheckOutcome {
363
367
  readonly unmeasured?: string
364
368
  }
365
369
 
366
- async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
370
+ async function runCheck(
371
+ check: Check,
372
+ ctx: GateContext,
373
+ env: Readonly<Record<string, string>>,
374
+ ): Promise<CheckOutcome> {
367
375
  if (check.kind === 'measure') {
368
376
  const report = await check.measure(ctx)
369
377
  return {
@@ -378,7 +386,7 @@ async function runCheck(check: Check, ctx: GateContext): Promise<CheckOutcome> {
378
386
  }
379
387
 
380
388
  const run = check.kind === 'cli' ? ctx.cli : ctx.run
381
- const result = await run(check.argv)
389
+ const result = await run(check.argv, env)
382
390
  return {
383
391
  emissions: [{ kind: 'output', text: result.all }],
384
392
  failure: result.exitCode === 0 ? undefined : check.failure,
@@ -492,7 +500,7 @@ export function exitCodeFor(results: readonly StageResult[]): number {
492
500
  * repository the hook points at rather than the tree being verified.
493
501
  */
494
502
  export function commandRunner(root: string): RunCommand {
495
- return async (argv) => spawn(argv[0], argv.slice(1), root)
503
+ return async (argv, env) => spawn(argv[0], argv.slice(1), root, env)
496
504
  }
497
505
 
498
506
  /**
@@ -504,19 +512,20 @@ export function commandRunner(root: string): RunCommand {
504
512
  */
505
513
  export function cliRunner(root: string): RunCommand {
506
514
  const cli = join(PROJECT_ROOT, 'src', 'cli.ts')
507
- return async (argv) => spawn(process.execPath, [cli, ...argv], root)
515
+ return async (argv, env) => spawn(process.execPath, [cli, ...argv], root, env)
508
516
  }
509
517
 
510
518
  async function spawn(
511
519
  file: string,
512
520
  args: readonly string[],
513
521
  cwd: string,
522
+ env: Readonly<Record<string, string>> = {},
514
523
  ): Promise<CommandResult> {
515
524
  const result = await execa(file, [...args], {
516
525
  cwd,
517
526
  reject: false,
518
527
  all: true,
519
- env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1' },
528
+ env: { ...gitEnv(), CANON_NON_INTERACTIVE: '1', ...env },
520
529
  extendEnv: false,
521
530
  })
522
531
 
@@ -9,7 +9,7 @@ import {
9
9
  import { readFile } from 'node:fs/promises'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { listRepositoryFiles } from '@/git/files'
12
- import { densityFindings, type DensityKind } from '@/markdown/density'
12
+ import type { DensityKind } from '@/markdown/density'
13
13
  import { referenceShapeGates } from '@/markdown/gate'
14
14
  import {
15
15
  type ReferenceShapeKind,
@@ -20,7 +20,7 @@ import {
20
20
  densityGates,
21
21
  GATED_PATHS,
22
22
  GATED_SCOPE_ROOTS,
23
- inDensityScope,
23
+ scopedDensityFindings,
24
24
  } from '@/markdown/scope'
25
25
  import { isOwnCheckout } from '@/roots/project'
26
26
 
@@ -198,11 +198,9 @@ export async function findingsUnder(
198
198
  const found = await Promise.all(
199
199
  under.map(async (rel) => {
200
200
  const source = await readFile(join(root, rel), 'utf8')
201
- const density = inDensityScope(rel)
202
- ? densityFindings(bodyLines(source)).filter((finding) =>
203
- densityGates(rel, finding.kind, [prefix], false),
204
- )
205
- : []
201
+ const density = scopedDensityFindings(rel, bodyLines(source)).filter(
202
+ (finding) => densityGates(rel, finding.kind, [prefix], false),
203
+ )
206
204
  const shape = referenceShapeGates(rel, [prefix])
207
205
  ? referenceShapeFindings(source)
208
206
  : []