@erclx/canon 5.74.0 → 5.76.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/CHANGELOG.md +29 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-rule/SKILL.md +5 -1
- package/claude/skills/create-rule/references/ledger.md +73 -0
- package/claude/skills/create-rule/references/rule.md +1 -1
- package/claude/skills/create-skill/references/skill-practice.md +1 -1
- package/claude/skills/design-identity/REQUIREMENT.md +1 -1
- package/claude/skills/design-identity/SKILL.md +13 -4
- package/claude/skills/design-identity/references/card-route.md +11 -4
- package/claude/skills/git-issue/REQUIREMENT.md +4 -2
- package/claude/skills/git-issue/SKILL.md +32 -3
- package/claude/skills/git-issue/references/issue.md +7 -0
- package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
- package/claude/skills/issue-triage/SKILL.md +130 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -9
- package/claude/skills/proposal-critique/references/adopted.md +44 -0
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
- package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
- package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
- package/claude/skills/ui-film/SKILL.md +8 -7
- package/docs/agents/audits.md +24 -24
- package/docs/agents/migrate.md +10 -10
- package/docs/migrations/by-verb.md +10 -4
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +4 -0
- package/governance/ledger/claude/rule.md +16 -0
- package/governance/rules/claude/rule.md +13 -0
- package/package.json +1 -1
- package/scripts/ci/refresh-frames-pr.sh +11 -12
- package/src/audits/catalog.ts +7 -2
- package/src/capture/render.ts +30 -5
- package/src/capture/stamp.ts +3 -5
- package/src/claude/cases/git.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -0
- package/src/claude/skills-audit.ts +65 -0
- package/src/cli/help.ts +1 -1
- package/src/commands/capture.ts +38 -1
- package/src/commands/claude.ts +10 -2
- package/src/commands/gov.ts +12 -7
- package/src/commands/migrate.ts +6 -2
- package/src/commands/take.ts +36 -7
- package/src/counts/catalogs.ts +3 -5
- package/src/gate/measures.ts +1 -144
- package/src/gate/stages.ts +0 -23
- package/src/gov/audit.ts +59 -13
- package/src/markdown/scope.ts +4 -0
- package/src/migrate/session-defaults.ts +146 -8
- package/src/take/encode.ts +9 -1
- package/src/take/film.ts +17 -2
- package/src/take/fixtures/busy.html +107 -0
- package/src/take/perform.ts +99 -25
- package/src/take/screencast.ts +4 -3
- package/src/take/stepper.ts +33 -20
- package/tooling/claude/reference.md +4 -2
- package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
- package/tooling/claude/seeds/.claude/settings.json +12 -0
- package/scripts/core/regen-hero.ts +0 -113
- package/src/capture/frame-values.ts +0 -326
package/src/commands/claude.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
SKILL_LEDGERS,
|
|
35
35
|
PRACTICE_SECTIONS,
|
|
36
36
|
PRACTICE_SKILLS,
|
|
37
|
+
PROJECT_SKILL_LEDGERS,
|
|
37
38
|
REQUIREMENT_LIMITS,
|
|
38
39
|
REQUIREMENT_SECTIONS,
|
|
39
40
|
RETIRED_REQUIREMENT_SECTION,
|
|
@@ -712,6 +713,10 @@ async function runSync(target: string): Promise<number> {
|
|
|
712
713
|
logWarn(
|
|
713
714
|
'settings.json lacks the merge deny entries. Run `canon migrate session-defaults --write`',
|
|
714
715
|
)
|
|
716
|
+
} else if (defaults.kind === 'planned' && defaults.plan.lacksPushAllow) {
|
|
717
|
+
logWarn(
|
|
718
|
+
'settings.json lacks the lease push allow. Run `canon migrate session-defaults --write`',
|
|
719
|
+
)
|
|
715
720
|
}
|
|
716
721
|
|
|
717
722
|
logStep('Scanning .gitignore')
|
|
@@ -1772,7 +1777,7 @@ function reportProvenance(report: SkillsAudit): void {
|
|
|
1772
1777
|
/**
|
|
1773
1778
|
* Reports without failing the run for the reason provenance does. The list
|
|
1774
1779
|
* names shipped folders alone, which the step says so a target reading a pass
|
|
1775
|
-
* knows its own skills were
|
|
1780
|
+
* knows its own skills were read only against a ledger it wrote.
|
|
1776
1781
|
*/
|
|
1777
1782
|
function reportPracticeShape(report: SkillsAudit): void {
|
|
1778
1783
|
logStep('Practice shape')
|
|
@@ -1783,7 +1788,10 @@ function reportPracticeShape(report: SkillsAudit): void {
|
|
|
1783
1788
|
`A skill on the per-bullet list, ${plural(Object.keys(BULLET_LEDGER_SKILLS).length, 'skill')} here, also matches each reference it does not exempt, and a listed body, to a ledger under ${SKILL_LEDGERS}, one row per bullet under each H2.`,
|
|
1784
1789
|
)
|
|
1785
1790
|
logInfo(
|
|
1786
|
-
`Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read.`,
|
|
1791
|
+
`Both lists are kept here and name ${plural(PRACTICE_SKILLS.length, 'practice skill')} and ${plural(LEDGER_SKILLS.length, 'ledger skill')}, so a skill outside ${SHIPPED_CORPUS} is never read against them.`,
|
|
1792
|
+
)
|
|
1793
|
+
logInfo(
|
|
1794
|
+
`A tree with no governance/rules/ has each file under ${PROJECT_SKILL_LEDGERS} matched to the skill file of its name, one row per bullet under each H2, and owes none.`,
|
|
1787
1795
|
)
|
|
1788
1796
|
|
|
1789
1797
|
if (report.practiceShape.length === 0) {
|
package/src/commands/gov.ts
CHANGED
|
@@ -646,7 +646,9 @@ export function register(program: Command): void {
|
|
|
646
646
|
` A rule under ${CORE_FOLDERS.join(', ')} has its ledger checked where`,
|
|
647
647
|
' one exists, and under --touched owes one once the branch changed it.',
|
|
648
648
|
' With no baseline against main, --touched reads no rule as touched.',
|
|
649
|
-
' Any other tree reads .claude/rules/project/
|
|
649
|
+
' Any other tree reads .claude/rules/project/ and checks the ledger of',
|
|
650
|
+
' each rule that has one at canon/ledger/rules/<subfolder>/<slug>.md.',
|
|
651
|
+
' A ledger there with no rule is a finding on a whole-corpus read.',
|
|
650
652
|
' .claude/rules/canon/ is fixed at the toolkit source and never read',
|
|
651
653
|
' unless named. A named path resolves against --root and must sit',
|
|
652
654
|
' inside it.',
|
|
@@ -757,7 +759,10 @@ function reportAudit(
|
|
|
757
759
|
intro('canon gov audit')
|
|
758
760
|
|
|
759
761
|
const shaped = report.rules.filter((reading) => reading.findings.length > 0)
|
|
760
|
-
const ledgers =
|
|
762
|
+
const ledgers = [
|
|
763
|
+
...report.rules.flatMap(describeLedger),
|
|
764
|
+
...report.orphans.map((path) => `${path}: no rule for this ledger`),
|
|
765
|
+
]
|
|
761
766
|
|
|
762
767
|
logStep('Corpus')
|
|
763
768
|
logInfo(
|
|
@@ -791,12 +796,12 @@ function reportAudit(
|
|
|
791
796
|
'no baseline against main, so --touched reads no core rule as touched',
|
|
792
797
|
)
|
|
793
798
|
}
|
|
794
|
-
if (
|
|
795
|
-
logInfo('not read, since ledgers live in the toolkit and never ship')
|
|
796
|
-
} else if (ledgers.length === 0) {
|
|
797
|
-
logInfo('every rule owing a ledger has one, with a row per bullet')
|
|
798
|
-
} else {
|
|
799
|
+
if (ledgers.length > 0) {
|
|
799
800
|
for (const line of ledgers) logWarn(line)
|
|
801
|
+
} else if (report.scope === 'target') {
|
|
802
|
+
logInfo('every project ledger has its rule and a row per bullet')
|
|
803
|
+
} else {
|
|
804
|
+
logInfo('every rule owing a ledger has one, with a row per bullet')
|
|
800
805
|
}
|
|
801
806
|
|
|
802
807
|
const failing = report.rules.filter(hasFinding).length
|
package/src/commands/migrate.ts
CHANGED
|
@@ -1489,9 +1489,13 @@ export function register(program: Command): void {
|
|
|
1489
1489
|
'false only when the key is absent. A deny entry is never removed and',
|
|
1490
1490
|
'an existing autoMemoryEnabled is never changed.',
|
|
1491
1491
|
'',
|
|
1492
|
+
'Where .claude/hooks/plan-guard.sh exists, also wires it into',
|
|
1493
|
+
'hooks.PreToolUse for Bash and Write|Edit, skipping a tool whose',
|
|
1494
|
+
'matcher already runs it, so a hand wiring is never doubled.',
|
|
1495
|
+
'',
|
|
1492
1496
|
'A missing settings file is nothing to do. A file that is not valid',
|
|
1493
|
-
'JSON, or whose permissions or
|
|
1494
|
-
'refused by name and never partly written.',
|
|
1497
|
+
'JSON, or whose permissions, deny, hooks, or PreToolUse has an',
|
|
1498
|
+
'unexpected shape, is refused by name and never partly written.',
|
|
1495
1499
|
'',
|
|
1496
1500
|
'Exit codes:',
|
|
1497
1501
|
' 0 nothing to add, or --write applied the plan',
|
package/src/commands/take.ts
CHANGED
|
@@ -28,6 +28,7 @@ type Take = typeof import('@/take/film')
|
|
|
28
28
|
interface RunOptions {
|
|
29
29
|
readonly out?: string
|
|
30
30
|
readonly reel?: string
|
|
31
|
+
readonly maxSeconds?: string
|
|
31
32
|
readonly json?: boolean
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -58,6 +59,10 @@ const TAKE_REFUSALS: Record<TakeRefusal, string> = {
|
|
|
58
59
|
'browser-missing': 'The headless shell is not installed in this project.',
|
|
59
60
|
'server-unreachable': 'Nothing answered at the URL, so no beat was filmed.',
|
|
60
61
|
'target-missing': 'A step names a target the page does not carry.',
|
|
62
|
+
'target-offscreen':
|
|
63
|
+
'A step names a target outside the window, so a setup step must bring it into view first.',
|
|
64
|
+
'over-cap':
|
|
65
|
+
'The filmed beats ran past --max-seconds, so the take stopped at the beat that crossed it.',
|
|
61
66
|
'take-failed': 'The take failed against a reachable page.',
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -80,6 +85,10 @@ export function register(program: Command): void {
|
|
|
80
85
|
'--reel <file>',
|
|
81
86
|
'Also join the beats into one animated WebP with the pointer drawn',
|
|
82
87
|
)
|
|
88
|
+
.option(
|
|
89
|
+
'--max-seconds <n>',
|
|
90
|
+
'Refuse once the filmed beats run past n seconds in total',
|
|
91
|
+
)
|
|
83
92
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
84
93
|
.addHelpText(
|
|
85
94
|
'after',
|
|
@@ -153,6 +162,7 @@ export function register(program: Command): void {
|
|
|
153
162
|
` ${INSTALL_BROWSER}`,
|
|
154
163
|
'',
|
|
155
164
|
'Reasons:',
|
|
165
|
+
' bad-max-seconds --max-seconds is not a positive number',
|
|
156
166
|
' no-plan-file nothing readable at the plan path',
|
|
157
167
|
' unreadable-plan the plan file is not valid JSON',
|
|
158
168
|
' no-beats the plan names no beat',
|
|
@@ -166,15 +176,18 @@ export function register(program: Command): void {
|
|
|
166
176
|
' browser-missing the headless shell or browser binary is not installed',
|
|
167
177
|
' server-unreachable nothing answered at the URL',
|
|
168
178
|
' target-missing a step names a target the page does not carry',
|
|
179
|
+
' target-offscreen a step names a target outside the window, so a setup step must bring it into view first',
|
|
180
|
+
' over-cap the filmed beats ran past --max-seconds, naming the',
|
|
181
|
+
' beat that crossed it',
|
|
169
182
|
' take-failed the take failed against a reachable page',
|
|
170
183
|
' reel-failed every beat was filmed and the reel did not encode',
|
|
171
184
|
'',
|
|
172
|
-
'A refusal in a beat carries beat. target-missing
|
|
173
|
-
"page answered a capture, the beat folder's
|
|
174
|
-
'beat that fell back to the screencast
|
|
175
|
-
'the reason it fell back. The
|
|
176
|
-
'so a single-use link is
|
|
177
|
-
'selector.',
|
|
185
|
+
'A refusal in a beat carries beat. target-missing and target-offscreen',
|
|
186
|
+
"carry still when the page answered a capture, the beat folder's",
|
|
187
|
+
'still.jpg as the step met it. A beat that fell back to the screencast',
|
|
188
|
+
'before refusing carries fallback, the reason it fell back. The',
|
|
189
|
+
'screencast opens the address a second time, so a single-use link is',
|
|
190
|
+
'spent by then: check the access before the selector.',
|
|
178
191
|
'',
|
|
179
192
|
'Exit codes:',
|
|
180
193
|
' 0 every beat was filmed, and the reel written when asked for',
|
|
@@ -300,6 +313,17 @@ async function runTake(
|
|
|
300
313
|
|
|
301
314
|
intro(`canon take ${url}`)
|
|
302
315
|
|
|
316
|
+
const maxSeconds =
|
|
317
|
+
opts.maxSeconds === undefined ? undefined : readRate(opts.maxSeconds)
|
|
318
|
+
if (opts.maxSeconds !== undefined && maxSeconds === undefined) {
|
|
319
|
+
return refuse(
|
|
320
|
+
emitJson,
|
|
321
|
+
url,
|
|
322
|
+
'bad-max-seconds',
|
|
323
|
+
`--max-seconds ${opts.maxSeconds} is not a positive number, such as 12 or 7.5.`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
303
327
|
let source: string
|
|
304
328
|
try {
|
|
305
329
|
source = readFileSync(path, 'utf8')
|
|
@@ -344,7 +368,12 @@ async function runTake(
|
|
|
344
368
|
}
|
|
345
369
|
|
|
346
370
|
logStep('Filming')
|
|
347
|
-
const result = await taker.take({
|
|
371
|
+
const result = await taker.take({
|
|
372
|
+
url,
|
|
373
|
+
plan,
|
|
374
|
+
out: opts.out,
|
|
375
|
+
...(maxSeconds === undefined ? {} : { maxSeconds }),
|
|
376
|
+
})
|
|
348
377
|
|
|
349
378
|
if (result.status === 'failed') {
|
|
350
379
|
logStep('Refused')
|
package/src/counts/catalogs.ts
CHANGED
|
@@ -24,12 +24,10 @@ const REGISTER_IMPORT = /^import \{ register as /gm
|
|
|
24
24
|
/**
|
|
25
25
|
* Every top-level `canon` command, read off the CLI entry point rather than a
|
|
26
26
|
* catalog verb, because a command carries no `--json` listing of its own
|
|
27
|
-
* siblings.
|
|
28
|
-
*
|
|
29
|
-
* pattern is written.
|
|
27
|
+
* siblings. Callers import this reader rather than a second regex on
|
|
28
|
+
* `src/cli.ts`, so this is the one place that pattern is written.
|
|
30
29
|
*
|
|
31
|
-
* This is the one catalog with no meaning outside this repository
|
|
32
|
-
* `regen-hero.ts` documents itself as clone-only for the same reason.
|
|
30
|
+
* This is the one catalog with no meaning outside this repository.
|
|
33
31
|
*/
|
|
34
32
|
export function countCommands(root: string): number | undefined {
|
|
35
33
|
const cli = join(root, 'src', 'cli.ts')
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
2
|
import { join, relative } from 'node:path'
|
|
4
3
|
import {
|
|
5
4
|
CLIENT_COMMAND_MARKER,
|
|
@@ -144,9 +143,6 @@ export function auditsBaselineRel(root: string): string {
|
|
|
144
143
|
return relative(root, surfaceDir(root, 'canon', 'baseline.json'))
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
export const CAPTURE_STAMP_FAILURE =
|
|
148
|
-
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/frames and commit each frame with its image and its stamp.'
|
|
149
|
-
|
|
150
146
|
function parseJson(payload: string): unknown {
|
|
151
147
|
try {
|
|
152
148
|
return JSON.parse(payload)
|
|
@@ -1478,142 +1474,3 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
|
1478
1474
|
)
|
|
1479
1475
|
return [...seen].sort()
|
|
1480
1476
|
}
|
|
1481
|
-
|
|
1482
|
-
/**
|
|
1483
|
-
* A capture set spans two folders. The markup and the template beside it are
|
|
1484
|
-
* the authored half and sit under `assets/captures/`, while the image a
|
|
1485
|
-
* document points at and the stamp answering for it sit under
|
|
1486
|
-
* `assets/frames/`, which is where `--out assets/frames` sends them. They sit
|
|
1487
|
-
* outside `assets/evidence/` so a changed marketing image is not read as dev
|
|
1488
|
-
* evidence.
|
|
1489
|
-
*/
|
|
1490
|
-
const CAPTURE_MARKUP_DIR = 'assets/captures'
|
|
1491
|
-
const CAPTURE_OUTPUT_DIR = 'assets/frames'
|
|
1492
|
-
|
|
1493
|
-
/**
|
|
1494
|
-
* Every capture under `assets/captures/`, named by the base its three files
|
|
1495
|
-
* share.
|
|
1496
|
-
*
|
|
1497
|
-
* Read off the folder rather than listed, and off the markup specifically,
|
|
1498
|
-
* because that is how `resolveCaptureSources` decides what `canon capture
|
|
1499
|
-
* assets/captures` renders. A list would fail open on the frame somebody adds
|
|
1500
|
-
* next, which is the one nobody thinks to add here, and driving off the PNGs
|
|
1501
|
-
* instead would report a missing set for any image in `assets/` that is not a
|
|
1502
|
-
* capture.
|
|
1503
|
-
*/
|
|
1504
|
-
function captureBases(root: string): string[] {
|
|
1505
|
-
const dir = join(root, CAPTURE_MARKUP_DIR)
|
|
1506
|
-
if (!existsSync(dir)) return []
|
|
1507
|
-
|
|
1508
|
-
return readdirSync(dir)
|
|
1509
|
-
.filter((name) => name.endsWith('.html'))
|
|
1510
|
-
.map((name) => name.slice(0, -'.html'.length))
|
|
1511
|
-
.sort()
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
/**
|
|
1515
|
-
* The drift assert on the Hero stage covers the markup because the image beside
|
|
1516
|
-
* it is a chromium render whose bytes move with the browser. That leaves the
|
|
1517
|
-
* artifact a visitor actually sees asserted nowhere, so a branch regenerating
|
|
1518
|
-
* the markup and never running the capture would pass every stage while
|
|
1519
|
-
* shipping an image with the old counts.
|
|
1520
|
-
*
|
|
1521
|
-
* `canon capture` records a digest of the markup it rendered and one of the image
|
|
1522
|
-
* it wrote, so this reads provenance rather than timing. Comparing the commit
|
|
1523
|
-
* that last touched each file passes any pair that moved together whatever the
|
|
1524
|
-
* two files hold, which is what a binary conflict resolved by taking either
|
|
1525
|
-
* side produces.
|
|
1526
|
-
*
|
|
1527
|
-
* Both digests are checked because either file can move alone. The markup side
|
|
1528
|
-
* catches an edit committed with no capture, and the image side catches an
|
|
1529
|
-
* image replaced under markup that never changed. A tree carrying no markup
|
|
1530
|
-
* under `assets/captures/` has no set to read and passes, which is correct.
|
|
1531
|
-
*/
|
|
1532
|
-
export const captureStamps: Measure = async (ctx) => {
|
|
1533
|
-
const lines = captureBases(ctx.root).flatMap((base) =>
|
|
1534
|
-
readCaptureSet(ctx.root, base),
|
|
1535
|
-
)
|
|
1536
|
-
if (lines.length === 0) return { emissions: [] }
|
|
1537
|
-
|
|
1538
|
-
return {
|
|
1539
|
-
emissions: [output(lines.join('\n'))],
|
|
1540
|
-
failure: CAPTURE_STAMP_FAILURE,
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
/**
|
|
1545
|
-
* One capture set, as the lines it has to report and none where it agrees.
|
|
1546
|
-
*
|
|
1547
|
-
* The markup resolves against the source folder and the two files a render
|
|
1548
|
-
* writes resolve against the output folder, so the set spans both rather than
|
|
1549
|
-
* hanging off one base directory. Sending any of the three to the wrong folder
|
|
1550
|
-
* reports it as missing rather than failing loudly, since a missing file reads
|
|
1551
|
-
* the same way whether the path is wrong or the file is.
|
|
1552
|
-
*/
|
|
1553
|
-
function readCaptureSet(root: string, base: string): string[] {
|
|
1554
|
-
const set = [
|
|
1555
|
-
`${CAPTURE_MARKUP_DIR}/${base}.html`,
|
|
1556
|
-
`${CAPTURE_OUTPUT_DIR}/${base}.png`,
|
|
1557
|
-
`${CAPTURE_OUTPUT_DIR}/${base}.stamp`,
|
|
1558
|
-
]
|
|
1559
|
-
|
|
1560
|
-
const missing = set.filter((rel) => !existsSync(join(root, rel)))
|
|
1561
|
-
if (missing.length > 0) {
|
|
1562
|
-
return [`Missing from the ${base} set: ${missing.join(' ')}`]
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
const [html, png, stamp] = set.map((rel) => join(root, rel))
|
|
1566
|
-
return [
|
|
1567
|
-
...assertStampField(root, stamp, 'source-sha256', html),
|
|
1568
|
-
...assertStampField(root, stamp, 'image-sha256', png),
|
|
1569
|
-
]
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/**
|
|
1573
|
-
* One digest the stamp recorded against the file it was taken over, returning
|
|
1574
|
-
* the lines to report and nothing when the two agree.
|
|
1575
|
-
*
|
|
1576
|
-
* An absent field reports itself rather than comparing against an empty string,
|
|
1577
|
-
* so a stamp predating the current format is distinguishable from a file that
|
|
1578
|
-
* moved. Both names in the message come off the paths rather than from
|
|
1579
|
-
* arguments, which is what keeps a name from disagreeing with the file it
|
|
1580
|
-
* labels once a second capture source calls this.
|
|
1581
|
-
*/
|
|
1582
|
-
export function assertStampField(
|
|
1583
|
-
root: string,
|
|
1584
|
-
stamp: string,
|
|
1585
|
-
field: string,
|
|
1586
|
-
file: string,
|
|
1587
|
-
): string[] {
|
|
1588
|
-
const stampLabel = relativeTo(root, stamp)
|
|
1589
|
-
const fileLabel = relativeTo(root, file)
|
|
1590
|
-
|
|
1591
|
-
const recorded = readStampField(stamp, field)
|
|
1592
|
-
if (recorded === undefined) {
|
|
1593
|
-
return [
|
|
1594
|
-
`${stampLabel} carries no ${field} line, so it predates the capture that writes one.`,
|
|
1595
|
-
]
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
const actual = createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
1599
|
-
if (recorded !== actual) {
|
|
1600
|
-
return [
|
|
1601
|
-
`${stampLabel} records ${field} ${recorded}`,
|
|
1602
|
-
`${fileLabel} hashes to ${actual}`,
|
|
1603
|
-
]
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
return []
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function readStampField(stamp: string, field: string): string | undefined {
|
|
1610
|
-
for (const line of readFileSync(stamp, 'utf8').split('\n')) {
|
|
1611
|
-
const [key, value] = line.trim().split(/\s+/)
|
|
1612
|
-
if (key === `${field}:` && value !== undefined) return value
|
|
1613
|
-
}
|
|
1614
|
-
return undefined
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
function relativeTo(root: string, path: string): string {
|
|
1618
|
-
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
|
|
1619
|
-
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
canonicalRecords,
|
|
10
10
|
auditSet,
|
|
11
|
-
captureStamps,
|
|
12
11
|
clientCommandCitations,
|
|
13
12
|
designProse,
|
|
14
13
|
documentCeiling,
|
|
@@ -191,28 +190,6 @@ export const STAGES: readonly Stage[] = [
|
|
|
191
190
|
],
|
|
192
191
|
success: 'Consumed copies clean',
|
|
193
192
|
},
|
|
194
|
-
{
|
|
195
|
-
// Only the markup is asserted for drift. The image beside it is a chromium
|
|
196
|
-
// render whose bytes move with the browser version, so a drift check over it
|
|
197
|
-
// would fail on a machine whose chromium differs rather than on a stale
|
|
198
|
-
// count. The stamp measure below is what covers the image instead.
|
|
199
|
-
//
|
|
200
|
-
// The stage covers every frame under `assets/captures/` and keeps the
|
|
201
|
-
// narrower name, because the label is spelled across the context entries and
|
|
202
|
-
// the CI table and renaming it buys nothing the pathspec below does not
|
|
203
|
-
// already say.
|
|
204
|
-
id: 'hero',
|
|
205
|
-
label: 'Hero',
|
|
206
|
-
checks: [
|
|
207
|
-
{
|
|
208
|
-
kind: 'command',
|
|
209
|
-
argv: ['bun', 'scripts/core/regen-hero.ts', '--check'],
|
|
210
|
-
failure: 'Hero regen health check failed',
|
|
211
|
-
},
|
|
212
|
-
{ kind: 'measure', measure: captureStamps },
|
|
213
|
-
],
|
|
214
|
-
success: 'Hero clean',
|
|
215
|
-
},
|
|
216
193
|
{
|
|
217
194
|
// `canon/DESIGN.md`, the web stylesheet, and the tab icon are written
|
|
218
195
|
// from `src/design/tokens.ts`, and none is edited by hand. Three
|
package/src/gov/audit.ts
CHANGED
|
@@ -72,6 +72,13 @@ export const CORE_FOLDERS: readonly string[] = [
|
|
|
72
72
|
|
|
73
73
|
const SHIPPED_RULES = join('governance', 'rules')
|
|
74
74
|
const LEDGERS = join('governance', 'ledger')
|
|
75
|
+
const TARGET_RULES = join('.claude', 'rules', 'project')
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where a target keeps the ledgers behind its own rules. It sits outside
|
|
79
|
+
* `.claude/rules/`, where every `.md` loads as a rule, and nothing loads it.
|
|
80
|
+
*/
|
|
81
|
+
export const TARGET_RULE_LEDGERS = join('canon', 'ledger', 'rules')
|
|
75
82
|
|
|
76
83
|
/** Both corpora authored here, read when the tree is a canon checkout. */
|
|
77
84
|
const CANON_CORPUS: readonly string[] = [
|
|
@@ -83,7 +90,7 @@ const CANON_CORPUS: readonly string[] = [
|
|
|
83
90
|
* What a target authors. `.claude/rules/canon/` is installed from the toolkit
|
|
84
91
|
* and fixed at its source, so a finding there is one the target cannot act on.
|
|
85
92
|
*/
|
|
86
|
-
const TARGET_CORPUS: readonly string[] = [
|
|
93
|
+
const TARGET_CORPUS: readonly string[] = [TARGET_RULES]
|
|
87
94
|
|
|
88
95
|
export interface ShapeFinding {
|
|
89
96
|
readonly limit: RuleLimit
|
|
@@ -135,10 +142,18 @@ export type RuleAuditRefusal = 'no-rules' | 'no-path' | 'outside-root'
|
|
|
135
142
|
export type RuleAuditReport =
|
|
136
143
|
| {
|
|
137
144
|
readonly kind: 'measured'
|
|
138
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* `canon` reads both authored corpora and the toolkit's ledgers,
|
|
147
|
+
* `target` its project rules and any ledger the project wrote.
|
|
148
|
+
*/
|
|
139
149
|
readonly scope: 'canon' | 'target'
|
|
140
150
|
readonly corpus: readonly string[]
|
|
141
151
|
readonly rules: readonly RuleReading[]
|
|
152
|
+
/**
|
|
153
|
+
* Target ledgers whose rule is gone, read on a whole-corpus read alone,
|
|
154
|
+
* since a named path scopes the read to the files named.
|
|
155
|
+
*/
|
|
156
|
+
readonly orphans: readonly string[]
|
|
142
157
|
}
|
|
143
158
|
| {
|
|
144
159
|
readonly kind: 'unreadable'
|
|
@@ -336,7 +351,18 @@ export function compareLedger(
|
|
|
336
351
|
function ledgerFor(
|
|
337
352
|
file: string,
|
|
338
353
|
touched: ReadonlySet<string>,
|
|
354
|
+
scope: 'canon' | 'target',
|
|
339
355
|
): { path: string; owed: boolean } | undefined {
|
|
356
|
+
// A project has no practice-folder list to owe a ledger against, so its
|
|
357
|
+
// ledger is checked where it exists and never owed.
|
|
358
|
+
if (scope === 'target') {
|
|
359
|
+
const prefix = `${TARGET_RULES}${sep}`
|
|
360
|
+
if (!file.startsWith(prefix)) return undefined
|
|
361
|
+
return {
|
|
362
|
+
path: join(TARGET_RULE_LEDGERS, file.slice(prefix.length)),
|
|
363
|
+
owed: false,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
340
366
|
const prefix = `${SHIPPED_RULES}${sep}`
|
|
341
367
|
if (!file.startsWith(prefix)) return undefined
|
|
342
368
|
const rest = file.slice(prefix.length)
|
|
@@ -352,8 +378,9 @@ async function readLedger(
|
|
|
352
378
|
file: string,
|
|
353
379
|
groups: readonly HeadingCount[],
|
|
354
380
|
touched: ReadonlySet<string>,
|
|
381
|
+
scope: 'canon' | 'target',
|
|
355
382
|
): Promise<LedgerReading> {
|
|
356
|
-
const ledger = ledgerFor(file, touched)
|
|
383
|
+
const ledger = ledgerFor(file, touched, scope)
|
|
357
384
|
if (ledger === undefined) return { status: 'not-required' }
|
|
358
385
|
const { path } = ledger
|
|
359
386
|
|
|
@@ -373,14 +400,29 @@ async function readLedger(
|
|
|
373
400
|
}
|
|
374
401
|
}
|
|
375
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Target ledgers with no rule at the matching `.claude/rules/project/` path,
|
|
405
|
+
* so a rule moved or renamed away from its ledger is reported rather than
|
|
406
|
+
* dropping its check silently.
|
|
407
|
+
*/
|
|
408
|
+
function findOrphanLedgers(root: string): string[] {
|
|
409
|
+
const dir = resolve(root, TARGET_RULE_LEDGERS)
|
|
410
|
+
if (!existsSync(dir)) return []
|
|
411
|
+
return listRuleFiles(dir)
|
|
412
|
+
.map((abs) => relative(dir, abs))
|
|
413
|
+
.filter((rest) => !existsSync(resolve(root, TARGET_RULES, rest)))
|
|
414
|
+
.map((rest) => join(TARGET_RULE_LEDGERS, rest))
|
|
415
|
+
}
|
|
416
|
+
|
|
376
417
|
/**
|
|
377
418
|
* Measures every rule this tree authors, or the files named.
|
|
378
419
|
*
|
|
379
420
|
* A tree holding `governance/rules/` is a canon checkout, read across both
|
|
380
421
|
* authored corpora with a ledger asked of each outside-practice rule and of
|
|
381
422
|
* each core rule in `touched`, and checked for any core rule carrying one.
|
|
382
|
-
* Any other tree is a target, read at `.claude/rules/project/` alone with
|
|
383
|
-
* ledger
|
|
423
|
+
* Any other tree is a target, read at `.claude/rules/project/` alone, with
|
|
424
|
+
* each rule's ledger under `canon/ledger/rules/` checked where one exists and
|
|
425
|
+
* none owed, since the toolkit's ledgers never ship.
|
|
384
426
|
*
|
|
385
427
|
* `touched` holds paths relative to the root, and leaving it out reads no core
|
|
386
428
|
* rule as touched.
|
|
@@ -457,11 +499,14 @@ export async function readRuleAudit(
|
|
|
457
499
|
rules.push({
|
|
458
500
|
file,
|
|
459
501
|
...shape,
|
|
460
|
-
ledger: await readLedger(root, file, groups, touched),
|
|
502
|
+
ledger: await readLedger(root, file, groups, touched, scope),
|
|
461
503
|
})
|
|
462
504
|
}
|
|
463
505
|
|
|
464
|
-
|
|
506
|
+
const orphans =
|
|
507
|
+
scope === 'target' && paths.length === 0 ? findOrphanLedgers(root) : []
|
|
508
|
+
|
|
509
|
+
return { kind: 'measured', scope, corpus, rules, orphans }
|
|
465
510
|
}
|
|
466
511
|
|
|
467
512
|
export function hasFinding(reading: RuleReading): boolean {
|
|
@@ -487,12 +532,13 @@ export function auditExit(
|
|
|
487
532
|
readonly exempt?: readonly string[]
|
|
488
533
|
},
|
|
489
534
|
): number {
|
|
535
|
+
const counts = (file: string) =>
|
|
536
|
+
!options.gate ||
|
|
537
|
+
(options.gated.some((prefix) => file.startsWith(prefix)) &&
|
|
538
|
+
!options.exempt?.includes(file))
|
|
490
539
|
const failing = report.rules.filter(
|
|
491
|
-
(reading) =>
|
|
492
|
-
hasFinding(reading) &&
|
|
493
|
-
(!options.gate ||
|
|
494
|
-
(options.gated.some((prefix) => reading.file.startsWith(prefix)) &&
|
|
495
|
-
!options.exempt?.includes(reading.file))),
|
|
540
|
+
(reading) => hasFinding(reading) && counts(reading.file),
|
|
496
541
|
)
|
|
497
|
-
|
|
542
|
+
const orphaned = report.orphans.filter(counts)
|
|
543
|
+
return failing.length + orphaned.length > 0 ? 2 : 0
|
|
498
544
|
}
|
package/src/markdown/scope.ts
CHANGED
|
@@ -158,6 +158,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
158
158
|
'claude/skills/context-fold/references/unusable-baseline.md',
|
|
159
159
|
'claude/skills/create-rule/REQUIREMENT.md',
|
|
160
160
|
'claude/skills/create-rule/SKILL.md',
|
|
161
|
+
'claude/skills/create-rule/references/ledger.md',
|
|
161
162
|
'claude/skills/create-rule/references/rule.md',
|
|
162
163
|
'claude/skills/create-skill/REQUIREMENT.md',
|
|
163
164
|
'claude/skills/create-skill/SKILL.md',
|
|
@@ -259,6 +260,8 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
259
260
|
'claude/skills/git-worktree/REQUIREMENT.md',
|
|
260
261
|
'claude/skills/git-worktree/SKILL.md',
|
|
261
262
|
'claude/skills/git-worktree/references/cleanup.md',
|
|
263
|
+
'claude/skills/issue-triage/REQUIREMENT.md',
|
|
264
|
+
'claude/skills/issue-triage/SKILL.md',
|
|
262
265
|
'claude/skills/markdown-craft/REQUIREMENT.md',
|
|
263
266
|
'claude/skills/markdown-craft/SKILL.md',
|
|
264
267
|
'claude/skills/markdown-craft/references/markdown.md',
|
|
@@ -282,6 +285,7 @@ export const GATED_PATHS: readonly string[] = [
|
|
|
282
285
|
'claude/skills/pr-evidence/references/evidence.md',
|
|
283
286
|
'claude/skills/proposal-critique/REQUIREMENT.md',
|
|
284
287
|
'claude/skills/proposal-critique/SKILL.md',
|
|
288
|
+
'claude/skills/proposal-critique/references/adopted.md',
|
|
285
289
|
'claude/skills/review-address/REQUIREMENT.md',
|
|
286
290
|
'claude/skills/review-address/SKILL.md',
|
|
287
291
|
'claude/skills/review-address/references/post-review-findings.md',
|