@erclx/canon 4.117.0 → 4.119.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 (84) 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/canon-rollout/SKILL.md +2 -2
  4. package/claude/skills/design-extract/SKILL.md +1 -1
  5. package/claude/skills/docs-fold/SKILL.md +2 -2
  6. package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
  7. package/claude/skills/draft-ready/SKILL.md +1 -1
  8. package/claude/skills/draft-ready/references/assembly.md +8 -0
  9. package/claude/skills/draft-screencast/SKILL.md +1 -1
  10. package/claude/skills/git-pr/SKILL.md +5 -3
  11. package/claude/skills/memory-capture/SKILL.md +3 -3
  12. package/claude/skills/memory-review/SKILL.md +3 -3
  13. package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
  14. package/claude/skills/plan-feature/SKILL.md +8 -1
  15. package/claude/skills/review-branch/SKILL.md +1 -1
  16. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  17. package/claude/skills/review-pr/SKILL.md +4 -2
  18. package/claude/skills/role-planner/SKILL.md +1 -1
  19. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  20. package/claude/skills/role-worker/SKILL.md +10 -41
  21. package/claude/skills/seed-sync/SKILL.md +1 -1
  22. package/claude/skills/session-compact/SKILL.md +1 -1
  23. package/claude/skills/session-map/SKILL.md +1 -1
  24. package/claude/skills/session-relay/REQUIREMENT.md +7 -1
  25. package/claude/skills/session-relay/SKILL.md +4 -7
  26. package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
  27. package/claude/skills/session-worktree/SKILL.md +7 -1
  28. package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
  29. package/claude/skills/target-check/references/domains.md +1 -1
  30. package/claude/skills/task-board/SKILL.md +4 -3
  31. package/claude/skills/teach-workspace/SKILL.md +4 -4
  32. package/claude/skills/ui-checklist/SKILL.md +1 -1
  33. package/claude/skills/ux-audit/SKILL.md +1 -1
  34. package/claude/skills/ux-measure/SKILL.md +1 -1
  35. package/claude/skills/ux-walkthrough/references/relay.md +1 -1
  36. package/docs/agents/commands.md +3 -3
  37. package/docs/agents/context-audit-checks.md +2 -28
  38. package/docs/agents/context-audit.md +1 -1
  39. package/docs/agents/install-and-sync.md +28 -17
  40. package/docs/agents/tasks.md +4 -4
  41. package/docs/target-projects.md +2 -2
  42. package/docs/workflow/ai-workflow.md +9 -9
  43. package/docs/workflow/operating-model.md +1 -1
  44. package/docs/workflow/visual-design-workflow.md +2 -2
  45. package/governance/renames.toml +13 -0
  46. package/governance/rules/claude/510-context.md +0 -12
  47. package/governance/rules/core/005-behavior.md +1 -2
  48. package/governance/rules/core/015-output.md +1 -3
  49. package/governance/rules/core/035-tasks.md +1 -3
  50. package/governance/rules/core/045-memory.md +0 -1
  51. package/governance/rules/core/055-scratch.md +2 -4
  52. package/governance/rules/core/070-planning.md +1 -4
  53. package/governance/rules/core/085-worktrees.md +2 -8
  54. package/governance/rules/core/087-git.md +2 -2
  55. package/governance/rules/core/090-code-comments.md +1 -1
  56. package/governance/rules/ui/460-design-taste.md +0 -15
  57. package/package.json +1 -1
  58. package/src/claude/cases/workflow.ts +10 -0
  59. package/src/commands/context.ts +11 -111
  60. package/src/commands/sync.ts +11 -3
  61. package/src/commands/tasks.ts +9 -8
  62. package/src/context/audit.ts +3 -145
  63. package/src/design/base.css +19 -19
  64. package/src/design/board.ts +18 -7
  65. package/src/design/neutral.ts +127 -0
  66. package/src/design/regen.ts +6 -4
  67. package/src/design/tokens.ts +5 -2
  68. package/src/gate/measures.ts +37 -0
  69. package/src/gate/stages.ts +13 -4
  70. package/src/gov/adapter.ts +59 -7
  71. package/src/gov/install.ts +13 -3
  72. package/src/gov/renames.ts +76 -0
  73. package/src/gov/stacks.ts +5 -1
  74. package/src/records/validate.ts +5 -1
  75. package/src/sync/check.ts +25 -4
  76. package/src/sync/engine.ts +154 -10
  77. package/src/sync/stamp.ts +44 -0
  78. package/src/tasks/archive.ts +28 -7
  79. package/src/tasks/record.ts +16 -9
  80. package/src/tasks/validate.ts +3 -1
  81. package/standards/plan.md +24 -1
  82. package/standards/tasks.md +5 -3
  83. package/governance/rules/core/091-channel.md +0 -13
  84. package/src/context/narration.ts +0 -99
@@ -0,0 +1,127 @@
1
+ import type { ColorToken, DesignTokens } from '@/design/tokens'
2
+ import { TOKENS } from '@/design/tokens'
3
+
4
+ /**
5
+ * The token set `canon design install` hands a target, achromatic so the only
6
+ * palette a target renders is one it wrote under `.claude/design/project/`.
7
+ * Type, spacing, and radii are this repository's by spread, and only the color
8
+ * list is a second copy, held to the same roles by `neutral.test.ts`.
9
+ *
10
+ * Grounds, surface, chrome, and border take the OKLCH lightness of the toolkit
11
+ * role they replace at chroma 0. Text roles are solved against `surface` for
12
+ * the contrast targets `TOKENS` records (13.5, 8.6, 5.6, 4.6), and the accent
13
+ * for 15.5, past `text` so it still reads apart from body copy. The state
14
+ * roles keep the toolkit's values, since a state color is a meaning rather
15
+ * than a palette.
16
+ */
17
+
18
+ const DARK_GROUNDS = ['background', 'surface'] as const
19
+ const LIGHT_GROUNDS = ['light-background', 'light-surface'] as const
20
+
21
+ function toolkitRole(role: string): ColorToken {
22
+ const token = TOKENS.color.find((candidate) => candidate.role === role)
23
+ if (token === undefined) throw new Error(`TOKENS declares no ${role} role`)
24
+ return token
25
+ }
26
+
27
+ const NEUTRAL_COLOR: readonly ColorToken[] = [
28
+ { role: 'background', intent: 'page canvas', value: '#0e0e0e' },
29
+ {
30
+ role: 'surface',
31
+ intent: 'cards, panels, raised blocks',
32
+ value: '#141414',
33
+ },
34
+ {
35
+ role: 'chrome',
36
+ intent: 'the window titlebar, one step above the canvas',
37
+ value: '#1a1a1a',
38
+ },
39
+ { role: 'border', intent: 'every rule and panel edge', value: '#292929' },
40
+ {
41
+ role: 'text',
42
+ intent: 'headings, counts, emphasized runs',
43
+ value: '#dddddd',
44
+ grounds: DARK_GROUNDS,
45
+ },
46
+ {
47
+ role: 'text-body',
48
+ intent: 'default body copy',
49
+ value: '#b2b2b2',
50
+ grounds: DARK_GROUNDS,
51
+ },
52
+ {
53
+ role: 'text-secondary',
54
+ intent: 'labels, captions, supporting copy',
55
+ value: '#8e8e8e',
56
+ grounds: DARK_GROUNDS,
57
+ },
58
+ {
59
+ role: 'muted',
60
+ intent: 'the faintest step, trailing notes',
61
+ value: '#7f7f7f',
62
+ grounds: DARK_GROUNDS,
63
+ },
64
+ {
65
+ role: 'accent',
66
+ intent: 'install command, mark, primary action',
67
+ value: '#ececec',
68
+ grounds: DARK_GROUNDS,
69
+ },
70
+ toolkitRole('success'),
71
+ toolkitRole('warning'),
72
+ toolkitRole('error'),
73
+ {
74
+ role: 'light-background',
75
+ intent: 'page canvas on a light ground',
76
+ value: '#fafafa',
77
+ },
78
+ {
79
+ role: 'light-surface',
80
+ intent: 'cards and panels on a light ground',
81
+ value: '#f1f1f1',
82
+ },
83
+ {
84
+ role: 'light-chrome',
85
+ intent: 'the window titlebar, one step above the canvas',
86
+ value: '#e8e8e8',
87
+ },
88
+ {
89
+ role: 'light-text',
90
+ intent: 'primary text on a light ground',
91
+ value: '#252525',
92
+ grounds: LIGHT_GROUNDS,
93
+ },
94
+ {
95
+ role: 'light-text-body',
96
+ intent: 'default body copy on a light ground',
97
+ value: '#444444',
98
+ grounds: LIGHT_GROUNDS,
99
+ },
100
+ {
101
+ role: 'light-text-secondary',
102
+ intent: 'labels, captions, supporting copy on a light ground',
103
+ value: '#5f5f5f',
104
+ grounds: LIGHT_GROUNDS,
105
+ },
106
+ {
107
+ role: 'light-muted',
108
+ intent: 'secondary text on a light ground',
109
+ value: '#6c6c6c',
110
+ grounds: LIGHT_GROUNDS,
111
+ },
112
+ {
113
+ role: 'light-accent',
114
+ intent: 'links and primary action on light',
115
+ value: '#191919',
116
+ grounds: LIGHT_GROUNDS,
117
+ },
118
+ toolkitRole('light-success'),
119
+ {
120
+ role: 'light-border',
121
+ intent: 'rules and panel edges on light',
122
+ value: '#d4d4d4',
123
+ verify: true,
124
+ },
125
+ ]
126
+
127
+ export const NEUTRAL_TOKENS: DesignTokens = { ...TOKENS, color: NEUTRAL_COLOR }
@@ -2,10 +2,12 @@ import { mkdirSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { buildDesignCss } from '@/design/css'
4
4
  import { renderDesignDocument } from '@/design/document'
5
+ import { NEUTRAL_TOKENS } from '@/design/neutral'
5
6
 
6
7
  /**
7
- * The two artifacts the token source produces, written by `canon design regen`
8
- * and asserted for drift by the `design` stage of `bun run check`.
8
+ * The two artifacts the token sources produce, written by `canon design regen`
9
+ * and asserted for drift by the `design` stage of `bun run check`. The
10
+ * document renders from `TOKENS` and the stylesheet from `NEUTRAL_TOKENS`.
9
11
  *
10
12
  * The document is what a person reads and the stylesheet is what a target
11
13
  * installs, and neither is edited by hand. That is the cost the operator took
@@ -26,7 +28,7 @@ export const DESIGN_DOCUMENT = join('canon', 'DESIGN.md')
26
28
  export const DESIGN_BASE_CSS = join('src', 'design', 'base.css')
27
29
 
28
30
  const BASE_BANNER = [
29
- 'Generated by `canon design regen` from src/design/tokens.ts. Do not edit.',
31
+ 'Generated by `canon design regen` from src/design/neutral.ts. Do not edit.',
30
32
  'This is the base half of a design install. A target overrides a value in',
31
33
  '.claude/design/project/, which the three-way merge leaves alone, so this',
32
34
  'file changes freely and nothing a project wrote lives in it.',
@@ -50,7 +52,7 @@ export function regenDesign(root: string): RegenResult {
50
52
  cssPath: write(
51
53
  root,
52
54
  DESIGN_BASE_CSS,
53
- buildDesignCss(undefined, { banner: BASE_BANNER }),
55
+ buildDesignCss(NEUTRAL_TOKENS, { banner: BASE_BANNER }),
54
56
  ),
55
57
  }
56
58
  }
@@ -386,8 +386,11 @@ export const TOKENS: DesignTokens = {
386
386
  }
387
387
 
388
388
  /** A role's value, or `undefined` where the record declares no such role. */
389
- export function colorValue(role: string): string | undefined {
390
- return TOKENS.color.find((token) => token.role === role)?.value
389
+ export function colorValue(
390
+ role: string,
391
+ tokens: DesignTokens = TOKENS,
392
+ ): string | undefined {
393
+ return tokens.color.find((token) => token.role === role)?.value
391
394
  }
392
395
 
393
396
  /**
@@ -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,
@@ -208,10 +209,10 @@ export const STAGES: readonly Stage[] = [
208
209
  success: 'Hero clean',
209
210
  },
210
211
  {
211
- // `canon/DESIGN.md`, the base stylesheet, the web stylesheet, and the
212
- // tab icon are all written from `src/design/tokens.ts` and none is edited
213
- // by hand. Four artifacts from one source is the cost of the token move,
214
- // and a render step that has to run is only safe while something fails
212
+ // `canon/DESIGN.md`, the web stylesheet, and the tab icon are written
213
+ // from `src/design/tokens.ts`, the base stylesheet from
214
+ // `src/design/neutral.ts`, and none is edited by hand. Four artifacts
215
+ // from two token sources is the cost of the token move, and a render step that has to run is only safe while something fails
215
216
  // when it did not, which is this.
216
217
  id: 'design',
217
218
  label: 'Design',
@@ -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',
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { basename, join, relative, resolve } from 'node:path'
3
- import { canonRulesDir } from '@/gov/install'
3
+ import { canonRulesDir, ruleSubdir } from '@/gov/install'
4
+ import { loadRenames, resolveSuccessor } from '@/gov/renames'
4
5
  import { resolveMissingRules } from '@/gov/stacks'
5
6
  import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
6
7
  import { readStamp, stampedChain } from '@/sync/stamp'
@@ -44,15 +45,19 @@ export function indexSourceRules(root: string): Map<string, string> {
44
45
  * path, so a rule that moved between subdirectories in the toolkit still
45
46
  * syncs into the subdirectory the target already uses.
46
47
  *
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.
48
+ * Declares `ownsInstalledRoot` and no `projectSubdir`. A project keeps its own
49
+ * rules under `.claude/rules/project/`, which `installedRoot` narrowing to
50
+ * `.claude/rules/canon/` leaves outside the walk, so everything the walk does
51
+ * reach is the toolkit's. A rule there with no source is one the toolkit
52
+ * retired or renamed, and the sync deletes it rather than leaving it loaded.
53
+ * A rename `governance/renames.toml` declares also installs the rule under its
54
+ * new name.
55
+ * A `projectSubdir` would compute a stale `canon/project/` destination, which
56
+ * is wrong rather than merely redundant.
53
57
  */
54
58
  export function createGovAdapter(root: string): SyncAdapter {
55
59
  const index = indexSourceRules(root)
60
+ const renames = loadRenames(root)
56
61
 
57
62
  return {
58
63
  banner: 'canon gov sync',
@@ -65,10 +70,57 @@ export function createGovAdapter(root: string): SyncAdapter {
65
70
  index.get(basename(file.path, '.md')),
66
71
  collectRetired: (target: string) => collectRetiredGov(target),
67
72
  collectMissing: (target: string) => collectMissingGov(root, target),
73
+ ownsInstalledRoot: true,
74
+ locateSuccessor: (file: InstalledFile, target: string) => {
75
+ const successor = resolveSuccessor(
76
+ renames,
77
+ index,
78
+ basename(file.path, '.md'),
79
+ )
80
+ if (successor === undefined) return undefined
81
+
82
+ const source = index.get(successor)
83
+ if (source === undefined) return undefined
84
+
85
+ return {
86
+ source,
87
+ dest:
88
+ heldRulePath(target, successor) ??
89
+ join(
90
+ canonRulesDir(target),
91
+ ruleSubdir(source, rulesSourceDir(root)),
92
+ `${successor}.md`,
93
+ ),
94
+ }
95
+ },
68
96
  stamp: { domain: 'governance', toolkitRoot: root },
69
97
  }
70
98
  }
71
99
 
100
+ /**
101
+ * Where the target already holds a rule, in whichever band folder. A
102
+ * successor held outside the source's band stays where it is, the same way
103
+ * `locateSource` syncs a moved rule in place, rather than gaining a second copy.
104
+ *
105
+ * Holding the predecessor is the whole entitlement test, so no stamped chain
106
+ * is read here. That covers a rule `--add` layered on and a target installed
107
+ * before chains were stamped alike.
108
+ */
109
+ function heldRulePath(target: string, rule: string): string | undefined {
110
+ const dir = canonRulesDir(target)
111
+ if (!existsSync(dir)) return undefined
112
+
113
+ for (const rel of new Bun.Glob(`**/${rule}.md`).scanSync({
114
+ cwd: dir,
115
+ onlyFiles: true,
116
+ dot: true,
117
+ })) {
118
+ return join(dir, rel)
119
+ }
120
+
121
+ return undefined
122
+ }
123
+
72
124
  /**
73
125
  * Rules the target's recorded chain entitles it to and its tree does not
74
126
  * hold. Reports as `notice` text through the same shape `collectRetired`
@@ -38,8 +38,14 @@ export function installedInternalRulesDir(target: string): string {
38
38
  * Rule names a target already holds, read off the installed `canon/` tree by
39
39
  * basename rather than off a recorded stack, since a target may hold rules
40
40
  * `--add` layered on that no stack lists.
41
+ *
42
+ * `successorOf` adds the name a held rule was renamed to, since the next sync
43
+ * installs it and a reader treating it as missing would name it twice.
41
44
  */
42
- export function installedRuleNames(target: string): Set<string> {
45
+ export function installedRuleNames(
46
+ target: string,
47
+ successorOf?: (name: string) => string | undefined,
48
+ ): Set<string> {
43
49
  const dir = canonRulesDir(target)
44
50
  const names = new Set<string>()
45
51
  if (!existsSync(dir)) return names
@@ -49,7 +55,11 @@ export function installedRuleNames(target: string): Set<string> {
49
55
  onlyFiles: true,
50
56
  dot: true,
51
57
  })) {
52
- names.add(basename(rel, '.md'))
58
+ const name = basename(rel, '.md')
59
+ names.add(name)
60
+
61
+ const successor = successorOf?.(name)
62
+ if (successor !== undefined) names.add(successor)
53
63
  }
54
64
 
55
65
  return names
@@ -84,7 +94,7 @@ export function listRuleSourcePaths(root: string): string[] {
84
94
  * in two subdirectories resolves deterministically rather than by whichever
85
95
  * entry the filesystem yielded first.
86
96
  */
87
- function indexRuleSources(root: string): Map<string, string> {
97
+ export function indexRuleSources(root: string): Map<string, string> {
88
98
  const rulesRoot = rulesSourceDir(root)
89
99
  const byName = new Map<string, string>()
90
100
 
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { indexRuleSources } from '@/gov/install'
4
+
5
+ /** Old rule basename to the basename the same rule ships under now. */
6
+ export type Renames = ReadonlyMap<string, string>
7
+
8
+ function renamesPath(root: string): string {
9
+ return join(root, 'governance', 'renames.toml')
10
+ }
11
+
12
+ /**
13
+ * Reads the shipped rename ledger. A toolkit without one declares no renames,
14
+ * which leaves every sourceless rule on the retire path it took before the
15
+ * ledger existed.
16
+ */
17
+ export function loadRenames(root: string): Renames {
18
+ const path = renamesPath(root)
19
+ if (!existsSync(path)) return new Map()
20
+
21
+ const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
22
+ string,
23
+ unknown
24
+ >
25
+ const table = parsed.renamed
26
+ if (typeof table !== 'object' || table === null) return new Map()
27
+
28
+ return new Map(
29
+ Object.entries(table).filter(
30
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
31
+ ),
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Follows a declared rename to the name the toolkit ships the rule under now,
37
+ * through every later rename of that name. The ledger is never pruned, so a
38
+ * target several releases behind still reaches the current name.
39
+ *
40
+ * Returns nothing when the chain ends on a name with no source, or loops,
41
+ * since installing a guess is worse than the retire the caller falls back to.
42
+ */
43
+ export function resolveSuccessor(
44
+ renames: Renames,
45
+ index: ReadonlyMap<string, string>,
46
+ name: string,
47
+ ): string | undefined {
48
+ const visited = new Set([name])
49
+ let current = renames.get(name)
50
+
51
+ while (current !== undefined) {
52
+ if (visited.has(current)) return undefined
53
+ visited.add(current)
54
+
55
+ const next = renames.get(current)
56
+ if (next === undefined) break
57
+ current = next
58
+ }
59
+
60
+ if (current === undefined || !index.has(current)) return undefined
61
+ return current
62
+ }
63
+
64
+ /**
65
+ * Binds the ledger and the source tree at `root` once, for a caller asking
66
+ * about many held names.
67
+ */
68
+ export function successorResolver(
69
+ root: string,
70
+ ): (name: string) => string | undefined {
71
+ const renames = loadRenames(root)
72
+ if (renames.size === 0) return () => undefined
73
+
74
+ const index = indexRuleSources(root)
75
+ return (name) => resolveSuccessor(renames, index, name)
76
+ }
package/src/gov/stacks.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type RuleSource,
8
8
  rulesSourceDir,
9
9
  } from '@/gov/install'
10
+ import { successorResolver } from '@/gov/renames'
10
11
 
11
12
  export interface GovStack {
12
13
  readonly name: string
@@ -163,6 +164,9 @@ export function unreferencedRules(root: string): string[] {
163
164
  * so reading its leaf entry is enough; no second walk resolves the chain
164
165
  * itself. A stack the toolkit no longer ships resolves to nothing rather than
165
166
  * throwing, the same way `readNewRules`'s band fallback already treats it.
167
+ *
168
+ * A held rule's declared successor counts as held, since the sync that
169
+ * renames the old file installs it.
166
170
  */
167
171
  export function resolveMissingRules(
168
172
  root: string,
@@ -176,7 +180,7 @@ export function resolveMissingRules(
176
180
  if (!resolution.ok) return []
177
181
 
178
182
  const { found } = lookupRules(root, resolution.rules)
179
- const held = installedRuleNames(target)
183
+ const held = installedRuleNames(target, successorResolver(root))
180
184
 
181
185
  return found
182
186
  .filter((source) => !held.has(source.rule))
@@ -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
@@ -4,6 +4,7 @@ import { execa } from 'execa'
4
4
  import { gitEnv } from '@/git-env'
5
5
  import { createDesignAdapter, DESIGN_INSTALL_DIR } from '@/design/adapter'
6
6
  import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
7
+ import { successorResolver } from '@/gov/renames'
7
8
  import { loadGovStack, resolveMissingRules, resolveRules } from '@/gov/stacks'
8
9
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
9
10
  import {
@@ -79,6 +80,8 @@ export interface StateCounts {
79
80
  readonly orphaned: number
80
81
  readonly stranded: number
81
82
  readonly missing: number
83
+ readonly retired: number
84
+ readonly renamed: number
82
85
  }
83
86
 
84
87
  export interface DomainReport {
@@ -314,6 +317,8 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
314
317
  orphaned: count(entries, 'orphaned'),
315
318
  stranded: count(entries, 'stranded'),
316
319
  missing: count(entries, 'missing'),
320
+ retired: count(entries, 'retired'),
321
+ renamed: count(entries, 'renamed'),
317
322
  }
318
323
  }
319
324
 
@@ -342,6 +347,11 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
342
347
  * sync that adds a rule silently changes what a project is governed by, and
343
348
  * nobody chose that, so gating CI on the count would pressure a target into
344
349
  * adopting a rule nobody picked.
350
+ *
351
+ * `retired` counts, since a sync deletes it mechanically. A target's CI goes
352
+ * red the day a release retires a rule it holds, and one sync clears it.
353
+ * `renamed` counts on the same grounds, since the sync that moves it is
354
+ * equally mechanical.
345
355
  */
346
356
  export function hasDrift(report: CheckReport): boolean {
347
357
  if (report.unmigrated.length > 0) return true
@@ -351,7 +361,9 @@ export function hasDrift(report: CheckReport): boolean {
351
361
  domain.counts.stale +
352
362
  domain.counts.customized +
353
363
  domain.counts.drifted +
354
- domain.counts.stranded >
364
+ domain.counts.stranded +
365
+ domain.counts.retired +
366
+ domain.counts.renamed >
355
367
  0,
356
368
  )
357
369
  }
@@ -482,8 +494,14 @@ export function parseNewSkills(paths: string): string[] {
482
494
  * chain a target consumed survives nowhere else. `installRules` copies each
483
495
  * rule into the subdirectory it was authored in, which is what makes the band
484
496
  * folders legible as evidence.
497
+ *
498
+ * `successorOf` adds the name a held rule was renamed to, which the next sync
499
+ * installs, so the fallback does not report it as new.
485
500
  */
486
- export function readInstalledRules(target: string): {
501
+ export function readInstalledRules(
502
+ target: string,
503
+ successorOf?: (name: string) => string | undefined,
504
+ ): {
487
505
  held: Set<string>
488
506
  bands: Set<string>
489
507
  } {
@@ -500,7 +518,10 @@ export function readInstalledRules(target: string): {
500
518
  const posix = rel.split(sep).join('/')
501
519
  const boundary = posix.indexOf('/')
502
520
 
503
- held.add(basename(posix, '.md'))
521
+ const name = basename(posix, '.md')
522
+ held.add(name)
523
+ const successor = successorOf?.(name)
524
+ if (successor !== undefined) held.add(successor)
504
525
  if (boundary > 0) bands.add(posix.slice(0, boundary))
505
526
  }
506
527
 
@@ -634,7 +655,7 @@ export async function readNewRules(
634
655
  'governance/rules/',
635
656
  ])
636
657
 
637
- const { held, bands } = readInstalledRules(target)
658
+ const { held, bands } = readInstalledRules(target, successorResolver(root))
638
659
  return selectNewRules(paths, held, bands.union(baseBands(root)))
639
660
  }
640
661