@erclx/canon 5.48.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 (89) 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/canvas-board/REQUIREMENT.md +4 -3
  4. package/claude/skills/canvas-board/SKILL.md +8 -9
  5. package/claude/skills/context-fold/references/context.md +4 -3
  6. package/claude/skills/design-record/REQUIREMENT.md +33 -0
  7. package/claude/skills/design-record/SKILL.md +84 -0
  8. package/claude/skills/design-record/references/folder.md +69 -0
  9. package/claude/skills/design-taste/SKILL.md +1 -1
  10. package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
  11. package/claude/skills/markdown-craft/SKILL.md +6 -1
  12. package/claude/skills/markdown-craft/references/markdown.md +3 -3
  13. package/claude/skills/markdown-craft/references/tables.md +30 -0
  14. package/claude/skills/pick-by-eye/SKILL.md +10 -8
  15. package/claude/skills/plan-feature/SKILL.md +31 -16
  16. package/claude/skills/plan-feature/references/batches.md +5 -5
  17. package/claude/skills/plan-feature/references/plan.md +82 -33
  18. package/claude/skills/plan-groundwork/SKILL.md +8 -0
  19. package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
  20. package/claude/skills/plan-intake/SKILL.md +8 -0
  21. package/claude/skills/plan-intake/references/intake.md +9 -1
  22. package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
  23. package/claude/skills/plan-ready/SKILL.md +3 -3
  24. package/claude/skills/plan-ready/references/assembly.md +7 -7
  25. package/claude/skills/plan-ready/references/ready.md +8 -6
  26. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  27. package/claude/skills/review-ui/SKILL.md +4 -11
  28. package/claude/skills/review-ui/references/close-out.md +1 -1
  29. package/claude/skills/review-ui/references/drive-copy.md +38 -0
  30. package/claude/skills/review-ui/references/post.md +24 -3
  31. package/claude/skills/review-ui/references/verb-absent.md +18 -0
  32. package/claude/skills/role-orchestrator/SKILL.md +3 -2
  33. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
  34. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
  35. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
  36. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
  37. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  38. package/claude/skills/role-planner/SKILL.md +1 -1
  39. package/claude/skills/role-worker/SKILL.md +4 -2
  40. package/claude/skills/session-compact/references/handoff-note.md +3 -1
  41. package/claude/skills/session-map/references/session.md +1 -1
  42. package/claude/skills/session-worktree/SKILL.md +1 -1
  43. package/claude/skills/task-board/references/board.md +2 -2
  44. package/claude/skills/task-board/references/tasks.md +1 -1
  45. package/docs/agents/index.md +1 -1
  46. package/docs/agents/markdown-audit.md +5 -2
  47. package/docs/agents/records.md +1 -1
  48. package/docs/target/migrations.md +7 -0
  49. package/docs/target/projects.md +1 -1
  50. package/docs/workflow/parallel-features.md +2 -2
  51. package/docs/workflow/skill-map.md +2 -1
  52. package/docs/workflow/visual-design-workflow.md +2 -1
  53. package/governance/ledger/ui/design-reference.md +1 -0
  54. package/governance/rules/ui/design-reference.md +1 -0
  55. package/package.json +1 -1
  56. package/src/claude/cases/misc.ts +5 -0
  57. package/src/commands/claude.ts +26 -1
  58. package/src/commands/gate.ts +57 -16
  59. package/src/commands/markdown.ts +98 -19
  60. package/src/commands/pr.ts +411 -23
  61. package/src/commands/records.ts +45 -8
  62. package/src/commands/tasks.ts +12 -0
  63. package/src/context/audit.ts +6 -5
  64. package/src/design/budget.ts +1 -1
  65. package/src/design/components.ts +1 -1
  66. package/src/design/document.ts +1 -1
  67. package/src/design/regen.ts +1 -1
  68. package/src/design/render.ts +1 -1
  69. package/src/gate/hand-suite.ts +52 -0
  70. package/src/gate/measures.ts +9 -2
  71. package/src/gate/sequencer.ts +17 -8
  72. package/src/hooks/post-merge.ts +11 -0
  73. package/src/markdown/gated.ts +5 -7
  74. package/src/markdown/scope.ts +60 -10
  75. package/src/markdown/structure.ts +1 -1
  76. package/src/markdown/tables.ts +150 -0
  77. package/src/pr/drive-copy.ts +218 -0
  78. package/src/pr/local.ts +26 -0
  79. package/src/pr/rest.ts +2 -0
  80. package/src/records/structure.ts +147 -0
  81. package/src/records/validate.ts +113 -12
  82. package/src/tasks/archive.ts +137 -4
  83. package/src/tasks/board.ts +68 -13
  84. package/src/tasks/validate.ts +54 -14
  85. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
  86. package/tooling/claude/reference.md +1 -0
  87. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
  88. package/tooling/claude/seeds/CLAUDE.md +9 -0
  89. /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
@@ -222,6 +222,11 @@ export function register(program: Command): void {
222
222
  'listed, and pending-branch while a Pending branch: line names a branch',
223
223
  'whose own pull request is not recorded yet. A stem skips both.',
224
224
  '',
225
+ 'On an earlier-slice or open-outcomes refusal from --pull-request, the',
226
+ 'slice plan whose derived branch is the pull request head, read through',
227
+ 'gh, moves to the plans archive and the task stays open. The record',
228
+ 'names it under `slice`.',
229
+ '',
225
230
  'Refusals, read off `reason`:',
226
231
  ' no-board the repository holds no `.canon/tasks/` board',
227
232
  ' no-match no task matches the selector',
@@ -2187,6 +2192,10 @@ function report(
2187
2192
  logStep('Refused')
2188
2193
  logError(outcome.message)
2189
2194
  if (outcome.detail.length > 0) pipeOutput(outcome.detail.join('\n'))
2195
+ if (outcome.slice) {
2196
+ logStep('Slice plan archived')
2197
+ logPlanMoves([outcome.slice], root)
2198
+ }
2190
2199
  outro()
2191
2200
  return 1
2192
2201
  }
@@ -2276,6 +2285,9 @@ function recordFor(
2276
2285
  reason: outcome.reason,
2277
2286
  message: outcome.message,
2278
2287
  detail: outcome.detail,
2288
+ ...(outcome.slice
2289
+ ? { slice: planMovesFor([outcome.slice], root)[0] }
2290
+ : {}),
2279
2291
  }
2280
2292
  }
2281
2293
 
@@ -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
 
@@ -161,8 +161,19 @@ function archiveLines(
161
161
  if (reason === undefined || reason === 'no-match' || reason === 'no-board') {
162
162
  return undefined
163
163
  }
164
+ // A merged slice moves its own plan while the task stays open, so the line
165
+ // names the plan that dropped off the board row.
166
+ const slice =
167
+ fields.slice !== null && typeof fields.slice === 'object'
168
+ ? text(fields.slice as Fields, 'from')
169
+ : undefined
170
+ const moved =
171
+ slice === undefined
172
+ ? ''
173
+ : `🗂️ Archived the slice plan ${slice}, and the task stays open.\n`
164
174
  return (
165
175
  `\n📋 Task not archived for #${number}: ${text(fields, 'message') ?? reason}\n` +
176
+ moved +
166
177
  `Run /task-complete archive #${number} to resolve it by hand.\n\n`
167
178
  )
168
179
  }
@@ -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
  : []
@@ -1,4 +1,9 @@
1
- import type { DensityKind } from '@/markdown/density'
1
+ import {
2
+ type DensityFinding,
3
+ type DensityKind,
4
+ densityFindings,
5
+ } from '@/markdown/density'
6
+ import type { BodyLine } from '@/markdown/scan'
2
7
 
3
8
  /** The surfaces the retired bullet-stacks stage held, per `ALWAYS_GATED`. */
4
9
  const BULLET_RUN_SCOPE = [
@@ -21,11 +26,12 @@ const REQUIREMENT_SCOPE =
21
26
  * pages, the skill bodies, references, and requirements of both skill trees,
22
27
  * the tooling seeds and stack references, the wiki, and the two root guides.
23
28
  *
24
- * Rules stay out, since a rule is one imperative per bullet. So do
25
- * `CLAUDE.md`, the project requirement and design records, a seed carrying one
26
- * of those three names, the changelog, the examples, and every `.canon/`
27
- * record, each with a shape and cap of its own. Named here and nowhere else, so
28
- * the audit, the gate stage reading its exit, and their tests read one scope.
29
+ * Rules stay out, since a rule is one imperative per bullet, and so does every
30
+ * `CLAUDE.md` section but Key paths, which `KEY_PATHS_SCOPE` holds. So do the
31
+ * project requirement and design records, a seed carrying one of those three
32
+ * names, the changelog, the examples, and every `.canon/` record, each with a
33
+ * shape and cap of its own. Named here and nowhere else, so the audit, the gate
34
+ * stage reading its exit, and their tests read one scope.
29
35
  */
30
36
  const DENSITY_SCOPE = [
31
37
  ...BULLET_RUN_SCOPE,
@@ -36,10 +42,49 @@ const DENSITY_SCOPE = [
36
42
  /^(?:README|CONTRIBUTING)\.md$/,
37
43
  ]
38
44
 
45
+ /**
46
+ * The root `CLAUDE.md` and its seed, measured inside `## Key paths` alone. That
47
+ * section is a catalog rather than rules, so it takes the run limit and the H3
48
+ * opening a grouped catalog needs, and nothing else a rule would breach.
49
+ */
50
+ const KEY_PATHS_SCOPE = /^(?:tooling\/claude\/seeds\/)?CLAUDE\.md$/
51
+ const KEY_PATHS_HEADING = /^## Key paths\s*$/
52
+ const KEY_PATHS_KINDS: readonly DensityKind[] = ['bullet-run', 'h3-opening']
53
+
54
+ /** The lines under `## Key paths` up to the next H1 or H2, keeping their numbers. */
55
+ function keyPathsSection(lines: readonly BodyLine[]): readonly BodyLine[] {
56
+ const start = lines.findIndex(
57
+ (line) => !line.fenced && KEY_PATHS_HEADING.test(line.text),
58
+ )
59
+ if (start === -1) return []
60
+ const rest = lines.slice(start + 1)
61
+ const end = rest.findIndex(
62
+ (line) => !line.fenced && /^#{1,2}\s/.test(line.text),
63
+ )
64
+ return end === -1 ? rest : rest.slice(0, end)
65
+ }
66
+
67
+ /**
68
+ * The density findings a path takes, being every finding on a density-scope
69
+ * file and the Key paths findings on a `CLAUDE.md`. The audit, the init probe,
70
+ * and `canon claude sync` all read this, so the three agree on what counts.
71
+ */
72
+ export function scopedDensityFindings(
73
+ rel: string,
74
+ lines: readonly BodyLine[],
75
+ ): DensityFinding[] {
76
+ if (inDensityScope(rel)) return densityFindings(lines)
77
+ if (!KEY_PATHS_SCOPE.test(rel)) return []
78
+ return densityFindings(keyPathsSection(lines)).filter((finding) =>
79
+ KEY_PATHS_KINDS.includes(finding.kind),
80
+ )
81
+ }
82
+
39
83
  /**
40
84
  * The roots `canon init` gates in a new project when each passes at init time:
41
- * every density surface a target authors itself, leaving out the toolkit's own
42
- * `claude/skills/` and `tooling/` trees.
85
+ * every density surface a target authors itself and the Key paths of its
86
+ * `CLAUDE.md`, leaving out the toolkit's own `claude/skills/` and `tooling/`
87
+ * trees.
43
88
  */
44
89
  export const GATED_SCOPE_ROOTS: readonly string[] = [
45
90
  'canon/context/',
@@ -48,6 +93,7 @@ export const GATED_SCOPE_ROOTS: readonly string[] = [
48
93
  'wiki/',
49
94
  'README.md',
50
95
  'CONTRIBUTING.md',
96
+ 'CLAUDE.md',
51
97
  ]
52
98
 
53
99
  /**
@@ -93,7 +139,6 @@ export const GATED_PATHS: readonly string[] = [
93
139
  'claude/skills/canvas-board/REQUIREMENT.md',
94
140
  'claude/skills/canvas-board/SKILL.md',
95
141
  'claude/skills/canvas-board/references/canvas.md',
96
- 'claude/skills/canvas-board/references/design.md',
97
142
  'claude/skills/ci-workflow/REQUIREMENT.md',
98
143
  'claude/skills/ci-workflow/SKILL.md',
99
144
  'claude/skills/ci-workflow/references/workflows.md',
@@ -133,6 +178,10 @@ export const GATED_PATHS: readonly string[] = [
133
178
  'claude/skills/deprecation-migration/REQUIREMENT.md',
134
179
  'claude/skills/deprecation-migration/SKILL.md',
135
180
  'claude/skills/deprecation-migration/references/adopted.md',
181
+ 'claude/skills/design-record/REQUIREMENT.md',
182
+ 'claude/skills/design-record/SKILL.md',
183
+ 'claude/skills/design-record/references/design.md',
184
+ 'claude/skills/design-record/references/folder.md',
136
185
  'claude/skills/design-taste/REQUIREMENT.md',
137
186
  'claude/skills/design-taste/SKILL.md',
138
187
  'claude/skills/design-taste/references/adopted.md',
@@ -394,6 +443,7 @@ export const GATED_PATHS: readonly string[] = [
394
443
  'wiki/',
395
444
  'README.md',
396
445
  'CONTRIBUTING.md',
446
+ 'CLAUDE.md',
397
447
  ]
398
448
 
399
449
  /**
@@ -425,7 +475,7 @@ export function densityGates(
425
475
  gated: readonly string[] = GATED_PATHS,
426
476
  alwaysGated = true,
427
477
  ): boolean {
428
- if (!inDensityScope(rel)) return false
478
+ if (!inDensityScope(rel) && !KEY_PATHS_SCOPE.test(rel)) return false
429
479
  if (REQUIREMENT_SCOPE.test(rel)) return gated.includes(rel)
430
480
  if (
431
481
  alwaysGated &&
@@ -67,7 +67,7 @@ const MARKER_WIDTH = 20
67
67
 
68
68
  export const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
69
69
  export const TABLE_ROW = /^\s*\|/
70
- const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
70
+ export const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
71
71
  export const BLOCKQUOTE = /^\s*>/
72
72
 
73
73
  /**
@@ -0,0 +1,150 @@
1
+ import { type BodyLine, visibleText } from '@/markdown/scan'
2
+ import { LIST_ITEM, TABLE_ROW, TABLE_SEPARATOR } from '@/markdown/structure'
3
+
4
+ /** The most visible characters one table cell may hold. */
5
+ export const TABLE_CELL = 120
6
+
7
+ /** The most body rows a table may carry before it splits into groups. */
8
+ export const TABLE_ROWS = 8
9
+
10
+ /** The longest unbroken run a code span in a table cell may carry. */
11
+ export const TABLE_TOKEN = 30
12
+
13
+ export type TableKind =
14
+ | 'table-cell'
15
+ | 'table-rows'
16
+ | 'table-then-list'
17
+ | 'table-token'
18
+
19
+ export interface TableFinding {
20
+ readonly kind: TableKind
21
+ /** The row holding the cell or span, the header for a row count, or the list's first item. */
22
+ readonly line: number
23
+ /** What was measured, or null for a trailing list, which is a shape rather than a count. */
24
+ readonly value: number | null
25
+ readonly limit: number | null
26
+ }
27
+
28
+ /**
29
+ * The body of every code span in a cell, read after link destinations are
30
+ * dropped, since only a link's label renders.
31
+ */
32
+ export function codeSpans(cell: string): string[] {
33
+ return (cell.replace(/\]\([^)]*\)/g, ']').match(/`[^`]+`/g) ?? []).map(
34
+ (span) => span.slice(1, -1),
35
+ )
36
+ }
37
+
38
+ /**
39
+ * The longest whitespace-free run in a code span. A preview also breaks a span
40
+ * after a hyphen or a slash, but a run this long still splits mid-token or
41
+ * widens its column, so whitespace is the break this counts.
42
+ */
43
+ export function unbrokenRun(span: string): number {
44
+ return Math.max(...span.split(/\s+/).map((part) => part.length))
45
+ }
46
+
47
+ /** Splits a row on its unescaped pipes, dropping the outer two. */
48
+ function cellsOf(row: string): string[] {
49
+ return row
50
+ .trim()
51
+ .replace(/^\|/, '')
52
+ .replace(/(?<!\\)\|$/, '')
53
+ .split(/(?<!\\)\|/)
54
+ .map((cell) => cell.trim().replaceAll('\\|', '|'))
55
+ }
56
+
57
+ /**
58
+ * Every breach of the four table limits, in line order.
59
+ *
60
+ * A table is a run of pipe rows whose second row is a delimiter, the shape a
61
+ * renderer draws. Pipe lines with no delimiter render as paragraph text, and a
62
+ * fenced line is an example, so neither is measured. The limits are a
63
+ * 120-character cell, 8 body rows, a list as the next block, and a code span
64
+ * holding a run past 30 characters. Every finding reports and none gates.
65
+ */
66
+ export function tableFindings(lines: readonly BodyLine[]): TableFinding[] {
67
+ const found: TableFinding[] = []
68
+ let index = 0
69
+
70
+ while (index < lines.length) {
71
+ const header = lines[index]
72
+ const delimiter = lines[index + 1]
73
+ if (
74
+ header.fenced ||
75
+ !TABLE_ROW.test(header.text) ||
76
+ delimiter === undefined ||
77
+ delimiter.fenced ||
78
+ !TABLE_SEPARATOR.test(delimiter.text)
79
+ ) {
80
+ index++
81
+ continue
82
+ }
83
+
84
+ const tableRows = [header]
85
+ let end = index + 2
86
+ while (
87
+ end < lines.length &&
88
+ !lines[end].fenced &&
89
+ TABLE_ROW.test(lines[end].text)
90
+ ) {
91
+ tableRows.push(lines[end])
92
+ end++
93
+ }
94
+
95
+ for (const row of tableRows) {
96
+ for (const cell of cellsOf(row.text)) {
97
+ const length = visibleText(cell).length
98
+ if (length > TABLE_CELL) {
99
+ found.push({
100
+ kind: 'table-cell',
101
+ line: row.number,
102
+ value: length,
103
+ limit: TABLE_CELL,
104
+ })
105
+ }
106
+ for (const span of codeSpans(cell)) {
107
+ const run = unbrokenRun(span)
108
+ if (run > TABLE_TOKEN) {
109
+ found.push({
110
+ kind: 'table-token',
111
+ line: row.number,
112
+ value: run,
113
+ limit: TABLE_TOKEN,
114
+ })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ const bodyRows = tableRows.length - 1
121
+ if (bodyRows > TABLE_ROWS) {
122
+ found.push({
123
+ kind: 'table-rows',
124
+ line: header.number,
125
+ value: bodyRows,
126
+ limit: TABLE_ROWS,
127
+ })
128
+ }
129
+
130
+ let next = end
131
+ while (next < lines.length && lines[next].text.trim() === '') next++
132
+ const following = lines[next]
133
+ if (
134
+ following !== undefined &&
135
+ !following.fenced &&
136
+ LIST_ITEM.test(following.text)
137
+ ) {
138
+ found.push({
139
+ kind: 'table-then-list',
140
+ line: following.number,
141
+ value: null,
142
+ limit: null,
143
+ })
144
+ }
145
+
146
+ index = end
147
+ }
148
+
149
+ return found.sort((a, b) => a.line - b.line)
150
+ }