@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/canvas-board/REQUIREMENT.md +4 -3
- package/claude/skills/canvas-board/SKILL.md +8 -9
- package/claude/skills/context-fold/references/context.md +4 -3
- package/claude/skills/design-record/REQUIREMENT.md +33 -0
- package/claude/skills/design-record/SKILL.md +84 -0
- package/claude/skills/design-record/references/folder.md +69 -0
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/markdown-craft/REQUIREMENT.md +1 -0
- package/claude/skills/markdown-craft/SKILL.md +6 -1
- package/claude/skills/markdown-craft/references/markdown.md +3 -3
- package/claude/skills/markdown-craft/references/tables.md +30 -0
- package/claude/skills/pick-by-eye/SKILL.md +10 -8
- package/claude/skills/plan-feature/SKILL.md +31 -16
- package/claude/skills/plan-feature/references/batches.md +5 -5
- package/claude/skills/plan-feature/references/plan.md +82 -33
- package/claude/skills/plan-groundwork/SKILL.md +8 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +2 -1
- package/claude/skills/plan-intake/SKILL.md +8 -0
- package/claude/skills/plan-intake/references/intake.md +9 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +3 -3
- package/claude/skills/plan-ready/SKILL.md +3 -3
- package/claude/skills/plan-ready/references/assembly.md +7 -7
- package/claude/skills/plan-ready/references/ready.md +8 -6
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -11
- package/claude/skills/review-ui/references/close-out.md +1 -1
- package/claude/skills/review-ui/references/drive-copy.md +38 -0
- package/claude/skills/review-ui/references/post.md +24 -3
- package/claude/skills/review-ui/references/verb-absent.md +18 -0
- package/claude/skills/role-orchestrator/SKILL.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +60 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +4 -2
- package/claude/skills/session-compact/references/handoff-note.md +3 -1
- package/claude/skills/session-map/references/session.md +1 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/claude/skills/task-board/references/board.md +2 -2
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/agents/records.md +1 -1
- package/docs/target/migrations.md +7 -0
- package/docs/target/projects.md +1 -1
- package/docs/workflow/parallel-features.md +2 -2
- package/docs/workflow/skill-map.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/ui/design-reference.md +1 -0
- package/governance/rules/ui/design-reference.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/claude.ts +26 -1
- package/src/commands/gate.ts +57 -16
- package/src/commands/markdown.ts +98 -19
- package/src/commands/pr.ts +411 -23
- package/src/commands/records.ts +45 -8
- package/src/commands/tasks.ts +12 -0
- package/src/context/audit.ts +6 -5
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/gate/hand-suite.ts +52 -0
- package/src/gate/measures.ts +9 -2
- package/src/gate/sequencer.ts +17 -8
- package/src/hooks/post-merge.ts +11 -0
- package/src/markdown/gated.ts +5 -7
- package/src/markdown/scope.ts +60 -10
- package/src/markdown/structure.ts +1 -1
- package/src/markdown/tables.ts +150 -0
- package/src/pr/drive-copy.ts +218 -0
- package/src/pr/local.ts +26 -0
- package/src/pr/rest.ts +2 -0
- package/src/records/structure.ts +147 -0
- package/src/records/validate.ts +113 -12
- package/src/tasks/archive.ts +137 -4
- package/src/tasks/board.ts +68 -13
- package/src/tasks/validate.ts +54 -14
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +1 -1
- package/tooling/claude/reference.md +1 -0
- package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +11 -1
- package/tooling/claude/seeds/CLAUDE.md +9 -0
- /package/claude/skills/{canvas-board → design-record}/references/design.md +0 -0
package/src/commands/tasks.ts
CHANGED
|
@@ -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
|
|
package/src/context/audit.ts
CHANGED
|
@@ -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
|
|
112
|
-
*
|
|
113
|
-
* `local-loop`
|
|
114
|
-
* `Scripts` H3 for that entry alone, so the path is what
|
|
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(?:\/
|
|
118
|
+
return /(?:^|\/)development(?:\/[^/]+)?\.md$/.test(rel)
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
/**
|
package/src/design/budget.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { canonicalHeading } from '@/design/parse'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The prose budget `claude/skills/
|
|
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
|
package/src/design/components.ts
CHANGED
|
@@ -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/
|
|
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
|
|
package/src/design/document.ts
CHANGED
|
@@ -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/
|
|
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
|
|
package/src/design/regen.ts
CHANGED
|
@@ -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/
|
|
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
|
/**
|
package/src/design/render.ts
CHANGED
|
@@ -105,7 +105,7 @@ interface Confidence {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* The columns the confidence ratio reads, fixed by `claude/skills/
|
|
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
|
+
}
|
package/src/gate/measures.ts
CHANGED
|
@@ -56,7 +56,14 @@ export interface CommandResult {
|
|
|
56
56
|
readonly spawnError?: string
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
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/
|
|
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)
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/hooks/post-merge.ts
CHANGED
|
@@ -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
|
}
|
package/src/markdown/gated.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
202
|
-
|
|
203
|
-
|
|
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
|
: []
|
package/src/markdown/scope.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
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
|
|
25
|
-
* `CLAUDE.md
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
42
|
-
* `claude/skills/` and `tooling/`
|
|
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
|
+
}
|