@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/create-rule/SKILL.md +5 -1
  4. package/claude/skills/create-rule/references/ledger.md +73 -0
  5. package/claude/skills/create-rule/references/rule.md +1 -1
  6. package/claude/skills/create-skill/references/skill-practice.md +1 -1
  7. package/claude/skills/design-identity/REQUIREMENT.md +1 -1
  8. package/claude/skills/design-identity/SKILL.md +13 -4
  9. package/claude/skills/design-identity/references/card-route.md +11 -4
  10. package/claude/skills/git-issue/REQUIREMENT.md +4 -2
  11. package/claude/skills/git-issue/SKILL.md +32 -3
  12. package/claude/skills/git-issue/references/issue.md +7 -0
  13. package/claude/skills/issue-triage/REQUIREMENT.md +35 -0
  14. package/claude/skills/issue-triage/SKILL.md +130 -0
  15. package/claude/skills/pr-evidence/references/reel.md +9 -9
  16. package/claude/skills/proposal-critique/references/adopted.md +44 -0
  17. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
  19. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +15 -15
  20. package/claude/skills/role-orchestrator/scripts/poll.ts +211 -96
  21. package/claude/skills/role-orchestrator/scripts/repo.ts +53 -1
  22. package/claude/skills/ui-film/SKILL.md +8 -7
  23. package/docs/agents/audits.md +24 -24
  24. package/docs/agents/migrate.md +10 -10
  25. package/docs/migrations/by-verb.md +10 -4
  26. package/docs/workflow/ai-workflow.md +1 -1
  27. package/docs/workflow/skill-map.md +1 -0
  28. package/governance/detect.toml +4 -0
  29. package/governance/ledger/claude/rule.md +16 -0
  30. package/governance/rules/claude/rule.md +13 -0
  31. package/package.json +1 -1
  32. package/scripts/ci/refresh-frames-pr.sh +11 -12
  33. package/src/audits/catalog.ts +7 -2
  34. package/src/capture/render.ts +30 -5
  35. package/src/capture/stamp.ts +3 -5
  36. package/src/claude/cases/git.ts +5 -0
  37. package/src/claude/cases/workflow.ts +4 -0
  38. package/src/claude/skills-audit.ts +65 -0
  39. package/src/cli/help.ts +1 -1
  40. package/src/commands/capture.ts +38 -1
  41. package/src/commands/claude.ts +10 -2
  42. package/src/commands/gov.ts +12 -7
  43. package/src/commands/migrate.ts +6 -2
  44. package/src/commands/take.ts +36 -7
  45. package/src/counts/catalogs.ts +3 -5
  46. package/src/gate/measures.ts +1 -144
  47. package/src/gate/stages.ts +0 -23
  48. package/src/gov/audit.ts +59 -13
  49. package/src/markdown/scope.ts +4 -0
  50. package/src/migrate/session-defaults.ts +146 -8
  51. package/src/take/encode.ts +9 -1
  52. package/src/take/film.ts +17 -2
  53. package/src/take/fixtures/busy.html +107 -0
  54. package/src/take/perform.ts +99 -25
  55. package/src/take/screencast.ts +4 -3
  56. package/src/take/stepper.ts +33 -20
  57. package/tooling/claude/reference.md +4 -2
  58. package/tooling/claude/seeds/.claude/hooks/plan-guard.sh +239 -0
  59. package/tooling/claude/seeds/.claude/settings.json +12 -0
  60. package/scripts/core/regen-hero.ts +0 -113
  61. package/src/capture/frame-values.ts +0 -326
@@ -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 never in scope.
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) {
@@ -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/ alone and asks no ledger.',
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 = report.rules.flatMap(describeLedger)
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 (report.scope === 'target') {
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
@@ -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 deny has an unexpected shape, is',
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',
@@ -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 carries still when the',
173
- "page answered a capture, the beat folder's still.jpg as the step met it. A",
174
- 'beat that fell back to the screencast before refusing carries fallback,',
175
- 'the reason it fell back. The screencast opens the address a second time,',
176
- 'so a single-use link is spent by then: check the access before the',
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({ url, plan, out: opts.out })
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')
@@ -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. `regen-hero.ts` imports this reader for its `COMMAND_COUNT`
28
- * rather than a second regex on `src/cli.ts`, so this is the one place that
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, the way
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')
@@ -1,5 +1,4 @@
1
- import { createHash } from 'node:crypto'
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
- }
@@ -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[] = [join('.claude', 'rules', 'project')]
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
- /** `canon` reads both authored corpora and ledgers, `target` neither. */
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 no
383
- * ledger asked, since ledgers never ship.
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
- return { kind: 'measured', scope, corpus, rules }
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
- return failing.length > 0 ? 2 : 0
542
+ const orphaned = report.orphans.filter(counts)
543
+ return failing.length + orphaned.length > 0 ? 2 : 0
498
544
  }
@@ -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',