@erclx/canon 4.117.0 → 4.118.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 (38) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-operator/SKILL.md +1 -1
  3. package/claude/skills/draft-ready/references/assembly.md +8 -0
  4. package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
  5. package/claude/skills/plan-feature/SKILL.md +7 -0
  6. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  7. package/claude/skills/review-pr/SKILL.md +3 -1
  8. package/claude/skills/target-check/references/domains.md +1 -1
  9. package/docs/agents/commands.md +1 -1
  10. package/docs/agents/context-audit-checks.md +2 -28
  11. package/docs/agents/context-audit.md +1 -1
  12. package/docs/agents/install-and-sync.md +23 -17
  13. package/docs/target-projects.md +2 -2
  14. package/docs/workflow/operating-model.md +1 -1
  15. package/docs/workflow/visual-design-workflow.md +1 -1
  16. package/governance/rules/claude/510-context.md +0 -12
  17. package/governance/rules/core/005-behavior.md +1 -2
  18. package/governance/rules/core/015-output.md +1 -3
  19. package/governance/rules/core/035-tasks.md +1 -3
  20. package/governance/rules/core/045-memory.md +0 -1
  21. package/governance/rules/core/055-scratch.md +2 -4
  22. package/governance/rules/core/070-planning.md +1 -4
  23. package/governance/rules/core/087-git.md +2 -2
  24. package/governance/rules/core/090-code-comments.md +1 -1
  25. package/governance/rules/ui/460-design-taste.md +0 -15
  26. package/package.json +1 -1
  27. package/src/commands/context.ts +11 -111
  28. package/src/commands/sync.ts +2 -2
  29. package/src/context/audit.ts +3 -145
  30. package/src/gate/measures.ts +37 -0
  31. package/src/gate/stages.ts +9 -0
  32. package/src/gov/adapter.ts +8 -6
  33. package/src/records/validate.ts +5 -1
  34. package/src/sync/check.ts +7 -1
  35. package/src/sync/engine.ts +90 -10
  36. package/src/sync/stamp.ts +44 -0
  37. package/standards/plan.md +24 -1
  38. package/src/context/narration.ts +0 -99
@@ -56,12 +56,6 @@ import {
56
56
  } from '@/context/folders'
57
57
  import { isGating } from '@/context/gate'
58
58
  import { auditIndexes, type FolderDrift } from '@/context/index-drift'
59
- import {
60
- loadNarration,
61
- type Narration,
62
- PRONOUN_HEADING,
63
- VERB_HEADING,
64
- } from '@/context/narration'
65
59
  import {
66
60
  measureWireframeFolder,
67
61
  type WireframeStatesReport,
@@ -101,7 +95,7 @@ export function register(program: Command): void {
101
95
  context
102
96
  .command('audit')
103
97
  .description(
104
- 'Report required sections, entry length, citations, reference form, catalog tables, provenance, superseded-decision narration, index drift, the architecture record against its own ceiling, its own entry cap, and its word weight, and wireframe states against their evidence folders',
98
+ 'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, its own entry cap, and its word weight, and wireframe states against their evidence folders',
105
99
  )
106
100
  .argument('[path]', 'Project root, defaulting to the current directory')
107
101
  .helpOption('-h, --help', 'Show this help message')
@@ -133,8 +127,8 @@ export function register(program: Command): void {
133
127
  'findings that are facts rather than judgments: a missing required',
134
128
  'section and index drift. A context entry requires Overview and',
135
129
  'Layout, and a wireframe requires Regions, States, Copy, and Not on',
136
- 'this surface. Entry length, reference form, table, provenance, narration,',
137
- 'and the record claim classification are judgments under both.',
130
+ 'this surface. Entry length, reference form, table, provenance, and the',
131
+ 'record claim classification are judgments under both.',
138
132
  '',
139
133
  'Depth and bullet weight are stated over every markdown file rather',
140
134
  'than over a context entry, so `canon markdown audit` measures them.',
@@ -653,24 +647,13 @@ async function runAudit(
653
647
  )
654
648
  }
655
649
 
656
- const narration: Narration = gateOnly
657
- ? { kind: 'absent' }
658
- : await loadNarration(root)
659
- const entries = gateOnly
660
- ? []
661
- : await measureFolders(
662
- root,
663
- folders,
664
- narration.kind === 'loaded' ? narration : undefined,
665
- )
650
+ const entries = gateOnly ? [] : await measureFolders(root, folders)
666
651
  const drift = gateOnly ? [] : await auditIndexes(folders)
667
652
  const sections = gateOnly ? [] : missingSections(root, folders, entries)
668
653
  const length = gateOnly ? undefined : lengthFindings(entries)
669
654
  // Absent under `--citations-only` and null when the project carries no
670
- // record, for the reason `checkpoints.narration` states about its own two
671
- // absences. A run that never looked and a project with nothing to look at
672
- // are different answers, and one value for both reports the second as the
673
- // first.
655
+ // record. A run that never looked and a project with nothing to look at are
656
+ // different answers, and one value for both reports the second as the first.
674
657
  const record = gateOnly ? undefined : await measureArchitecture(root)
675
658
  const wireframes = gateOnly
676
659
  ? []
@@ -693,7 +676,6 @@ async function runAudit(
693
676
  reportLength(length ?? [])
694
677
  reportTables(entries)
695
678
  reportProvenance(entries, folders)
696
- reportNarration(entries, folders, narration)
697
679
  reportDrift(drift)
698
680
  reportRecord(record, root)
699
681
  reportWireframeStates(wireframes)
@@ -722,10 +704,11 @@ async function runAudit(
722
704
  // answers, and one wrong restatement is a cause reported against the
723
705
  // wrong entry.
724
706
  //
725
- // Absent rather than empty under `--citations-only`, for the reason
726
- // `checkpoints.narration` below carries. That mode measures no entry,
727
- // so an empty array here reads as a corpus with nothing past the
728
- // checkpoint rather than as a run that never looked.
707
+ // Absent rather than empty under `--citations-only`. That mode
708
+ // measures no entry, so an empty array here reads as a corpus with
709
+ // nothing past the checkpoint rather than as a run that never looked,
710
+ // and collapsing the two reports a measured answer for a run that
711
+ // never opened an entry.
729
712
  length,
730
713
  missingSections: sections,
731
714
  indexDrift: drift,
@@ -747,20 +730,6 @@ async function runAudit(
747
730
  // caller that read it before wireframes owed a section, since turning
748
731
  // it into this map would break that caller without an error.
749
732
  requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
750
- // Three states rather than two, so a record showing no finding says
751
- // which terms were looked for. The key is absent when the run never
752
- // scanned, which is `--citations-only`, and null when it scanned and
753
- // no rule published both headings. Collapsing the first into null
754
- // reports an absent rule against a run that never opened one.
755
- narration: gateOnly
756
- ? undefined
757
- : narration.kind === 'loaded'
758
- ? {
759
- source: narration.source,
760
- pronouns: narration.pronouns,
761
- verbs: narration.verbs,
762
- }
763
- : null,
764
733
  },
765
734
  })}\n`,
766
735
  )
@@ -1144,75 +1113,6 @@ function reportProvenance(
1144
1113
  )
1145
1114
  }
1146
1115
 
1147
- /**
1148
- * Reports the bullets narrating a decision the bullet above them replaced.
1149
- *
1150
- * The reach line names the rule the sets were read from, since the check is
1151
- * silent when no rule publishes them and a run that scanned nothing otherwise
1152
- * prints the same clean line as a run that scanned everything.
1153
- *
1154
- * The legitimate-hit line is here for the same reason the provenance section
1155
- * says a marker is a judgment. The standard keeps a rejected alternative and
1156
- * why it lost, which is a back-reference in the past tense by construction, and
1157
- * no measure separates one from the shape the rule bans.
1158
- */
1159
- function reportNarration(
1160
- entries: readonly EntryReport[],
1161
- folders: readonly AuditedFolder[],
1162
- narration: Narration,
1163
- ): void {
1164
- logStep('Narration')
1165
-
1166
- const governed = folders.filter(governsContent)
1167
- if (governed.length === 0) {
1168
- logInfo(
1169
- `Out of scope. The rule is stated in the standard governing canon/${PROVENANCE_FOLDER}/, and no audited folder is that one.`,
1170
- )
1171
- return
1172
- }
1173
-
1174
- if (narration.kind === 'absent') {
1175
- logWarn(
1176
- `Not scanned. No rule under .claude/rules/ or governance/rules/ publishes both ${PRONOUN_HEADING} and ${VERB_HEADING}.`,
1177
- )
1178
- return
1179
- }
1180
-
1181
- logInfo(
1182
- `Covers canon/${PROVENANCE_FOLDER}/ alone, reading ${plural(narration.pronouns.length, 'pronoun')} and ${plural(narration.verbs.length, 'verb')} from ${narration.source}.`,
1183
- )
1184
- logInfo(
1185
- 'A rejected alternative is a legitimate hit, since the standard keeps what was tried and why it lost.',
1186
- )
1187
-
1188
- const carrying = entries
1189
- .filter((entry) => entry.narration.length > 0)
1190
- .sort((a, b) => b.narration.length - a.narration.length)
1191
-
1192
- if (carrying.length === 0) {
1193
- logInfo('No bullet narrates a decision the bullet above it replaced.')
1194
- return
1195
- }
1196
-
1197
- const total = carrying.reduce((sum, entry) => sum + entry.narration.length, 0)
1198
- logWarn(
1199
- `${plural(total, 'bullet')} to read across ${carrying.length} ${carrying.length === 1 ? 'entry' : 'entries'}`,
1200
- )
1201
- pipeOutput(
1202
- carrying
1203
- .map(
1204
- (entry) =>
1205
- `${entry.rel} ${plural(entry.narration.length, 'bullet')}\n${entry.narration
1206
- .map(
1207
- (found) =>
1208
- ` :${found.line} ${found.pronoun} with ${found.verb}`,
1209
- )
1210
- .join('\n')}`,
1211
- )
1212
- .join('\n'),
1213
- )
1214
- }
1215
-
1216
1116
  /** How each classification reads in the report. */
1217
1117
  const CLAIM_LABEL: Record<string, string> = {
1218
1118
  countable: 'countable claim',
@@ -133,9 +133,9 @@ function renderCheck(report: CheckReport): void {
133
133
  logWarn(entry.notice ?? `${entry.rel} (${entry.state})`)
134
134
  }
135
135
 
136
- const { stale, customized, drifted, stranded, orphaned, missing } =
136
+ const { stale, customized, drifted, stranded, retired, orphaned, missing } =
137
137
  domain.counts
138
- if (stale + customized + drifted + stranded === 0) {
138
+ if (stale + customized + drifted + stranded + retired === 0) {
139
139
  logInfo(orphaned === 0 ? 'up to date' : `up to date (${orphaned} local)`)
140
140
  }
141
141
  // A local file is not a deficiency and a missing rule is, so this never
@@ -2,7 +2,6 @@ import { readFile } from 'node:fs/promises'
2
2
  import { basename, relative } from 'node:path'
3
3
  import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
4
4
  import type { AuditedFolder } from '@/context/folders'
5
- import type { NarrationTerms } from '@/context/narration'
6
5
  import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
7
6
  import { documentHeight } from '@/markdown/structure'
8
7
  import { isStubSeed } from '@/seed-marker'
@@ -80,10 +79,6 @@ const TABLE_ROW = /^\s*\|/
80
79
  const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
81
80
  const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
82
81
 
83
- const TOP_BULLET = /^-\s+(.+)$/
84
- /** Any indented line, which is a nested bullet or a wrapped continuation. */
85
- const INSIDE_LIST = /^\s+\S/
86
-
87
82
  /**
88
83
  * Spellings of how the domain reached its shape rather than what it is now.
89
84
  *
@@ -156,9 +151,9 @@ const SENTENCE_END = /[.!?:;]\s(?=[^.!?:;]*$)/
156
151
  /**
157
152
  * The folder whose standard carries the exclusion above.
158
153
  *
159
- * Three measures narrow here rather than one: provenance, the required
160
- * sections, and the superseded-decision narration. The name is the first of
161
- * them because it was the first, and it stays because renaming a constant the
154
+ * Two measures narrow here rather than one: provenance and the required
155
+ * sections. The name is the first of them because it was the first, and it
156
+ * stays because renaming a constant the
162
157
  * JSON record publishes as `checkpoints.provenanceFolder` breaks every consumer
163
158
  * reading that field for a gain of one word.
164
159
  *
@@ -202,14 +197,6 @@ export interface BareReferenceFinding {
202
197
  readonly name: string
203
198
  }
204
199
 
205
- export interface NarrationFinding {
206
- readonly line: number
207
- /** The opening that points back at the bullet above. */
208
- readonly pronoun: string
209
- /** The past-tense verb that turns the back-reference into a narration. */
210
- readonly verb: string
211
- }
212
-
213
200
  export interface EntryReport {
214
201
  readonly rel: string
215
202
  /**
@@ -229,12 +216,6 @@ export interface EntryReport {
229
216
  readonly catalogTables: readonly TableFinding[]
230
217
  /** Empty for an entry no standard bans a change narrative in. */
231
218
  readonly provenance: readonly ProvenanceFinding[]
232
- /**
233
- * Empty outside the governed folder, and empty on a run whose caller loaded
234
- * no term sets. The report distinguishes the two from the vocabulary itself,
235
- * since an empty list here is silent about which one produced it.
236
- */
237
- readonly narration: readonly NarrationFinding[]
238
219
  /**
239
220
  * References naming a sibling entry by bare filename, and empty for a caller
240
221
  * that passed no sibling names. Which entries have siblings worth matching is
@@ -434,125 +415,6 @@ function stampsMeasurement(text: string, index: number): boolean {
434
415
  return STAMPING.test(clause) || STAMPING_RUN.test(clause)
435
416
  }
436
417
 
437
- function escape(term: string): string {
438
- return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
439
- }
440
-
441
- /**
442
- * Copulas that turn the verb behind them into a present-tense passive.
443
- *
444
- * `used to` is the term this exists for, since `is used to resolve the folder`
445
- * is the passive of `use` rather than the past habitual the set means. This
446
- * list is English grammar rather than corpus vocabulary, so it stays in code
447
- * while the two tunable sets stay in the rule, and a rule publishing two of
448
- * three headings would be a fourth absent state to carry for no tuning anyone
449
- * wants.
450
- *
451
- * What it costs is the perfect passive. `has been superseded` narrates a
452
- * supersession and is rejected with the rest, which is a recall gap taken
453
- * knowingly on the trade this check already makes everywhere else.
454
- */
455
- const COPULA = ['is', 'are', 'be', 'been', 'being', 'was', 'were']
456
-
457
- /**
458
- * Finds the bullets narrating a decision the bullet above them replaced.
459
- *
460
- * The signal is structural rather than lexical, and the corpus is what decides
461
- * that. The terms carrying clean signal for a supersession are too rare to
462
- * catch anything, and the one term that would have caught the known case is
463
- * `now`, which appears 57 times across 24 entries in correct present-tense
464
- * prose. What separates the shape instead is a bullet pointing back at its
465
- * sibling and putting the sibling's design in the past: an opening pronoun with
466
- * no antecedent of its own, plus a past-tense verb, plus a bullet above it to
467
- * refer to. Precision is the whole value, so recall is the accepted exposure
468
- * and a narration written as a single bullet is not reached.
469
- *
470
- * The pronoun is matched cased and anchored, since a mid-sentence `this` is a
471
- * determiner rather than a back-reference. The verb is matched uncased anywhere
472
- * in the bullet, since the tense is what carries the signal wherever it sits,
473
- * and rejected behind a `COPULA` for the reason stated there. Displayed spans
474
- * are masked so a term quoted in backticks is not read as prose the entry
475
- * writes, and fenced blocks are skipped for the reason the scans above skip
476
- * them.
477
- *
478
- * A blank line does not end the run. Markdown reads two bullets around one as a
479
- * single loose list, so breaking there would leave the shape reachable by
480
- * anyone who spaced their bullets out. What ends a run is content that is
481
- * neither a bullet nor indented under one, which the fenced branch below has to
482
- * answer for itself because a fenced line is skipped before that test.
483
- */
484
- function narration(
485
- lines: readonly BodyLine[],
486
- terms: NarrationTerms,
487
- ): NarrationFinding[] {
488
- if (terms.pronouns.length === 0 || terms.verbs.length === 0) return []
489
-
490
- const findings: NarrationFinding[] = []
491
- let following = false
492
- let fenceBlock: number | undefined
493
- let fenceInsideList = false
494
-
495
- for (const line of lines) {
496
- // A fenced line is never a bullet, but an unindented block still ends the
497
- // run. CommonMark reads a fence at column zero as interrupting the list, so
498
- // the bullets around it are two lists and the second has no antecedent
499
- // above it. A block indented under its bullet stays inside the item.
500
- //
501
- // The opening delimiter decides for every line of its own block. Reading
502
- // each line instead ends the run on a blank line inside an indented fence,
503
- // which has no indentation to read, and on a content line at column zero,
504
- // which CommonMark permits since only the fence's own indent is stripped.
505
- //
506
- // The block index is what re-opens that decision, so two blocks written
507
- // with nothing between them are answered separately rather than the second
508
- // inheriting the first. Indentation stays the test here because what
509
- // interrupts a list is this check's own judgment, and the walker only says
510
- // which lines share a block.
511
- if (line.fenced) {
512
- if (line.fenceBlock !== fenceBlock) {
513
- fenceBlock = line.fenceBlock
514
- fenceInsideList = INSIDE_LIST.test(line.text)
515
- }
516
- if (!fenceInsideList) following = false
517
- continue
518
- }
519
-
520
- if (line.text.trim() === '') continue
521
-
522
- const bullet = line.text.match(TOP_BULLET)
523
- if (!bullet) {
524
- // A nested bullet and a wrapped continuation both sit inside the list, so
525
- // neither ends the run. Anything else does, which is what keeps a bullet
526
- // opening the list under a heading from reading as a reply to the last
527
- // bullet of the list before it.
528
- if (!INSIDE_LIST.test(line.text)) following = false
529
- continue
530
- }
531
-
532
- const text = maskDisplayed(bullet[1])
533
-
534
- if (following) {
535
- const pronoun = terms.pronouns.find((term) =>
536
- new RegExp(`^${escape(term)}\\b`).test(text),
537
- )
538
- const verb = pronoun
539
- ? terms.verbs.find((term) =>
540
- new RegExp(
541
- `(?<!\\b(?:${COPULA.join('|')})\\s+)\\b${escape(term)}\\b`,
542
- 'i',
543
- ).test(text),
544
- )
545
- : undefined
546
-
547
- if (pronoun && verb) findings.push({ line: line.number, pronoun, verb })
548
- }
549
-
550
- following = true
551
- }
552
-
553
- return findings
554
- }
555
-
556
418
  /**
557
419
  * Finds which required sections the entry declares.
558
420
  *
@@ -639,7 +501,6 @@ export function measureEntry(
639
501
  rel: string,
640
502
  source: string,
641
503
  governsContent = true,
642
- terms?: NarrationTerms,
643
504
  siblings: readonly string[] = [],
644
505
  required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
645
506
  ): EntryReport {
@@ -650,7 +511,6 @@ export function measureEntry(
650
511
  lines: documentHeight(source),
651
512
  catalogTables: catalogTables(lines),
652
513
  provenance: governsContent ? provenance(lines) : [],
653
- narration: governsContent && terms ? narration(lines, terms) : [],
654
514
  bareReferences: bareReferences(lines, siblings),
655
515
  sections: declaredSections(lines, required),
656
516
  stub: isStubSeed(source),
@@ -714,7 +574,6 @@ function historyCause(entry: EntryReport): LengthCause {
714
574
  export async function measureFolders(
715
575
  root: string,
716
576
  folders: readonly AuditedFolder[],
717
- terms?: NarrationTerms,
718
577
  ): Promise<EntryReport[]> {
719
578
  const reports: EntryReport[] = []
720
579
 
@@ -731,7 +590,6 @@ export async function measureFolders(
731
590
  relative(root, path),
732
591
  await readFile(path, 'utf8'),
733
592
  governsContent(folder),
734
- terms,
735
593
  names.filter((name) => name !== self),
736
594
  requiredSections(folder),
737
595
  ),
@@ -12,6 +12,7 @@ import {
12
12
  isOverLength,
13
13
  measureArchitecture,
14
14
  } from '@/context/architecture'
15
+ import { auditSkills } from '@/claude/skills-audit'
15
16
  import { listRepositoryFiles } from '@/git-files'
16
17
  import { ceilingFindings } from '@/markdown/ceiling'
17
18
  import { resolveMarkdown } from '@/markdown/files'
@@ -277,6 +278,42 @@ export const architectureRecord: Measure = async (ctx) => {
277
278
  }
278
279
  }
279
280
 
281
+ /**
282
+ * Every skill body and reference against the no-dated-provenance rule, across
283
+ * both corpora `auditSkills` reads.
284
+ *
285
+ * Read in-process rather than through `claude skills audit`, whose exit code
286
+ * stays a missing requirement only, so a target running the verb is told about
287
+ * a date without failing on one. This repository holds the corpus at zero,
288
+ * which is what lets the same finding fail here.
289
+ */
290
+ export const skillProvenance: Measure = async (ctx) => {
291
+ const report = await auditSkills(ctx.root)
292
+ if (report.corpora.length === 0) {
293
+ return {
294
+ emissions: [],
295
+ unmeasured: 'no skill corpus resolved, so no body was read.',
296
+ }
297
+ }
298
+
299
+ const found = report.datedProvenance
300
+ if (found.length > 0) {
301
+ const where = found
302
+ .map((finding) => `${finding.rel} ${finding.detail}`)
303
+ .join(', ')
304
+ return {
305
+ emissions: [],
306
+ failure: `${plural(found.length, 'date')} in skill prose: ${where}. Move the incident to REQUIREMENT.md under Gap or leave it to git, or fence the date or put it in a code span when it is an example.`,
307
+ }
308
+ }
309
+
310
+ return {
311
+ emissions: [
312
+ info(`No dated provenance across ${plural(report.skills, 'skill')}`),
313
+ ],
314
+ }
315
+ }
316
+
280
317
  /**
281
318
  * Every tracked markdown document against the whole-document ceiling.
282
319
  *
@@ -13,6 +13,7 @@ import {
13
13
  seedStandards,
14
14
  rawFieldFileReference,
15
15
  shippedReferences,
16
+ skillProvenance,
16
17
  standardCriteria,
17
18
  unreferencedRules,
18
19
  visualPathGlobs,
@@ -468,6 +469,14 @@ export const STAGES: readonly Stage[] = [
468
469
  ],
469
470
  success: 'Skill requirements present',
470
471
  },
472
+ {
473
+ // The audit verb only reports a dated line, so a target is told without
474
+ // failing. This stage makes the same finding fail here, where the corpus is
475
+ // held at zero. Unscoped, since any branch can add a line to any body.
476
+ id: 'skill-provenance',
477
+ label: 'Skill provenance',
478
+ checks: [{ kind: 'measure', measure: skillProvenance }],
479
+ },
471
480
  {
472
481
  id: 'standard-criteria',
473
482
  label: 'Standard success criteria',
@@ -44,12 +44,13 @@ export function indexSourceRules(root: string): Map<string, string> {
44
44
  * path, so a rule that moved between subdirectories in the toolkit still
45
45
  * syncs into the subdirectory the target already uses.
46
46
  *
47
- * Declares no `projectSubdir`. That exemption existed to keep a project's
48
- * `.claude/rules/project/` folder out of the walk by location before any
49
- * name lookup ran, and `installedRoot` narrowing to `.claude/rules/canon/`
50
- * already leaves `project/` outside the walked root, so a second exemption
51
- * computing a stale `canon/project/` destination would be wrong rather than
52
- * merely redundant.
47
+ * Declares `ownsInstalledRoot` and no `projectSubdir`. A project keeps its own
48
+ * rules under `.claude/rules/project/`, which `installedRoot` narrowing to
49
+ * `.claude/rules/canon/` leaves outside the walk, so everything the walk does
50
+ * reach is the toolkit's. A rule there with no source is one the toolkit
51
+ * retired or renamed, and the sync deletes it rather than leaving it loaded.
52
+ * A `projectSubdir` would compute a stale `canon/project/` destination, which
53
+ * is wrong rather than merely redundant.
53
54
  */
54
55
  export function createGovAdapter(root: string): SyncAdapter {
55
56
  const index = indexSourceRules(root)
@@ -65,6 +66,7 @@ export function createGovAdapter(root: string): SyncAdapter {
65
66
  index.get(basename(file.path, '.md')),
66
67
  collectRetired: (target: string) => collectRetiredGov(target),
67
68
  collectMissing: (target: string) => collectMissingGov(root, target),
69
+ ownsInstalledRoot: true,
68
70
  stamp: { domain: 'governance', toolkitRoot: root },
69
71
  }
70
72
  }
@@ -271,7 +271,9 @@ const PLAN_SECTIONS = [
271
271
  'Summary',
272
272
  'Constraints',
273
273
  'Files to touch',
274
+ 'Verification',
274
275
  'Risks',
276
+ 'Review focus',
275
277
  'Questions',
276
278
  ] as const
277
279
 
@@ -280,7 +282,9 @@ type PlanSection = (typeof PLAN_SECTIONS)[number]
280
282
  const PLAN_REQUIRED: readonly PlanSection[] = [
281
283
  'Summary',
282
284
  'Files to touch',
285
+ 'Verification',
283
286
  'Risks',
287
+ 'Review focus',
284
288
  'Questions',
285
289
  ]
286
290
 
@@ -293,7 +297,7 @@ const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
293
297
 
294
298
  /**
295
299
  * A section opens as a bold label or as an H2 and both count. The corpus writes
296
- * `Summary` as a heading and the other four as bold labels, and roughly a fifth
300
+ * `Summary` as a heading and the others as bold labels, and roughly a fifth
297
301
  * of it swaps one for the other. Reporting the variant would fail nearly every
298
302
  * plan present on the rule a reader is least served by, which is what teaches
299
303
  * them to skip the output.
package/src/sync/check.ts CHANGED
@@ -79,6 +79,7 @@ export interface StateCounts {
79
79
  readonly orphaned: number
80
80
  readonly stranded: number
81
81
  readonly missing: number
82
+ readonly retired: number
82
83
  }
83
84
 
84
85
  export interface DomainReport {
@@ -314,6 +315,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
314
315
  orphaned: count(entries, 'orphaned'),
315
316
  stranded: count(entries, 'stranded'),
316
317
  missing: count(entries, 'missing'),
318
+ retired: count(entries, 'retired'),
317
319
  }
318
320
  }
319
321
 
@@ -342,6 +344,9 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
342
344
  * sync that adds a rule silently changes what a project is governed by, and
343
345
  * nobody chose that, so gating CI on the count would pressure a target into
344
346
  * adopting a rule nobody picked.
347
+ *
348
+ * `retired` counts, since a sync deletes it mechanically. A target's CI goes
349
+ * red the day a release retires a rule it holds, and one sync clears it.
345
350
  */
346
351
  export function hasDrift(report: CheckReport): boolean {
347
352
  if (report.unmigrated.length > 0) return true
@@ -351,7 +356,8 @@ export function hasDrift(report: CheckReport): boolean {
351
356
  domain.counts.stale +
352
357
  domain.counts.customized +
353
358
  domain.counts.drifted +
354
- domain.counts.stranded >
359
+ domain.counts.stranded +
360
+ domain.counts.retired >
355
361
  0,
356
362
  )
357
363
  }