@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.
- package/claude/.claude-plugin/plugin.json +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 +13 -1
- package/claude/skills/session-worktree/SKILL.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/markdown-audit.md +5 -2
- package/docs/target/migrations.md +7 -0
- package/docs/target/projects.md +1 -1
- 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/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/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/tasks/validate.ts +15 -12
- 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/claude.ts
CHANGED
|
@@ -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.
|
package/src/commands/gate.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/commands/markdown.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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.
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
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:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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',
|
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/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
|
: []
|