@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-operator/SKILL.md +1 -1
- package/claude/skills/canon-rollout/SKILL.md +2 -2
- package/claude/skills/design-extract/SKILL.md +1 -1
- package/claude/skills/docs-fold/SKILL.md +2 -2
- package/claude/skills/draft-ready/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/draft-ready/references/assembly.md +8 -0
- package/claude/skills/draft-screencast/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +5 -3
- package/claude/skills/memory-capture/SKILL.md +3 -3
- package/claude/skills/memory-review/SKILL.md +3 -3
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +8 -1
- package/claude/skills/review-branch/SKILL.md +1 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +4 -2
- package/claude/skills/role-planner/SKILL.md +1 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +10 -41
- package/claude/skills/seed-sync/SKILL.md +1 -1
- package/claude/skills/session-compact/SKILL.md +1 -1
- package/claude/skills/session-map/SKILL.md +1 -1
- package/claude/skills/session-relay/REQUIREMENT.md +7 -1
- package/claude/skills/session-relay/SKILL.md +4 -7
- package/claude/skills/session-worktree/REQUIREMENT.md +5 -1
- package/claude/skills/session-worktree/SKILL.md +7 -1
- package/claude/skills/session-worktree/references/main-root-writes.md +24 -0
- package/claude/skills/target-check/references/domains.md +1 -1
- package/claude/skills/task-board/SKILL.md +4 -3
- package/claude/skills/teach-workspace/SKILL.md +4 -4
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/claude/skills/ux-walkthrough/references/relay.md +1 -1
- package/docs/agents/commands.md +3 -3
- package/docs/agents/context-audit-checks.md +2 -28
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +28 -17
- package/docs/agents/tasks.md +4 -4
- package/docs/target-projects.md +2 -2
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/renames.toml +13 -0
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/085-worktrees.md +2 -8
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +10 -0
- package/src/commands/context.ts +11 -111
- package/src/commands/sync.ts +11 -3
- package/src/commands/tasks.ts +9 -8
- package/src/context/audit.ts +3 -145
- package/src/design/base.css +19 -19
- package/src/design/board.ts +18 -7
- package/src/design/neutral.ts +127 -0
- package/src/design/regen.ts +6 -4
- package/src/design/tokens.ts +5 -2
- package/src/gate/measures.ts +37 -0
- package/src/gate/stages.ts +13 -4
- package/src/gov/adapter.ts +59 -7
- package/src/gov/install.ts +13 -3
- package/src/gov/renames.ts +76 -0
- package/src/gov/stacks.ts +5 -1
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +25 -4
- package/src/sync/engine.ts +154 -10
- package/src/sync/stamp.ts +44 -0
- package/src/tasks/archive.ts +28 -7
- package/src/tasks/record.ts +16 -9
- package/src/tasks/validate.ts +3 -1
- package/standards/plan.md +24 -1
- package/standards/tasks.md +5 -3
- package/governance/rules/core/091-channel.md +0 -13
- 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 }
|
package/src/design/regen.ts
CHANGED
|
@@ -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
|
|
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/
|
|
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(
|
|
55
|
+
buildDesignCss(NEUTRAL_TOKENS, { banner: BASE_BANNER }),
|
|
54
56
|
),
|
|
55
57
|
}
|
|
56
58
|
}
|
package/src/design/tokens.ts
CHANGED
|
@@ -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(
|
|
390
|
-
|
|
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
|
/**
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
*
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
|
212
|
-
//
|
|
213
|
-
//
|
|
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',
|
package/src/gov/adapter.ts
CHANGED
|
@@ -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`.
|
|
48
|
-
* `.claude/rules/project
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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`
|
package/src/gov/install.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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))
|
package/src/records/validate.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|