@erclx/canon 5.61.0 → 5.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-cli/SKILL.md +2 -1
- package/claude/skills/create-skill/references/skill-practice.md +1 -0
- package/claude/skills/design-identity/references/card-route.md +3 -3
- package/claude/skills/design-record/references/design.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +2 -1
- package/claude/skills/design-taste/SKILL.md +6 -5
- package/claude/skills/design-taste/references/adopted.md +136 -8
- package/claude/skills/design-taste/references/craft.md +1 -1
- package/claude/skills/design-taste/references/kinds.md +8 -2
- package/claude/skills/design-taste/references/preflight.md +3 -3
- package/claude/skills/design-taste/references/tells.md +26 -5
- package/claude/skills/plan-feature/SKILL.md +2 -2
- package/claude/skills/plan-feature/references/plan.md +24 -3
- package/claude/skills/review-craft/SKILL.md +1 -1
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/target-update/SKILL.md +9 -1
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ui-craft/REQUIREMENT.md +29 -0
- package/claude/skills/ui-craft/SKILL.md +44 -0
- package/{governance/rules/ui → claude/skills/ui-craft/references}/a11y.md +2 -10
- package/claude/skills/ui-craft/references/adopted.md +27 -0
- package/{governance/rules/ui/surface-capture.md → claude/skills/ui-craft/references/capture.md} +5 -15
- package/claude/skills/ui-craft/references/controls.md +18 -0
- package/claude/skills/ui-craft/references/copy.md +45 -0
- package/{governance/rules/ui → claude/skills/ui-craft/references}/forms.md +0 -9
- package/{governance/rules/ui → claude/skills/ui-craft/references}/keyboard.md +3 -16
- package/{governance/rules/ui → claude/skills/ui-craft/references}/motion.md +0 -10
- package/claude/skills/ui-craft/references/pointer.md +13 -0
- package/{governance/rules/ui/ux-completeness.md → claude/skills/ui-craft/references/states.md} +2 -20
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/write-human/references/adopted.md +4 -1
- package/docs/agents/install-and-sync.md +47 -13
- package/docs/index.md +1 -1
- package/docs/target/migrations.md +6 -8
- package/docs/target/sync.md +21 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/index.md +3 -3
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +58 -32
- package/governance/detect.toml +1 -37
- package/governance/ledger/lib/test-scope.md +1 -1
- package/governance/ledger/{ui → skills/ui-craft}/a11y.md +11 -9
- package/governance/ledger/{ui/surface-capture.md → skills/ui-craft/capture.md} +15 -15
- package/governance/ledger/skills/ui-craft/controls.md +31 -0
- package/governance/ledger/skills/ui-craft/copy.md +102 -0
- package/governance/ledger/{ui → skills/ui-craft}/forms.md +3 -3
- package/governance/ledger/skills/ui-craft/keyboard.md +67 -0
- package/governance/ledger/{ui → skills/ui-craft}/motion.md +3 -3
- package/governance/ledger/skills/ui-craft/pointer.md +24 -0
- package/governance/ledger/{ui/ux-completeness.md → skills/ui-craft/states.md} +14 -26
- package/governance/ledger/ui/craft.md +19 -0
- package/governance/renames.toml +17 -3
- package/governance/rules/lib/test-scope.md +1 -1
- package/governance/rules/ui/craft.md +22 -0
- package/governance/splits.toml +13 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +150 -12
- package/src/commands/claude.ts +5 -0
- package/src/commands/sync.ts +3 -1
- package/src/commands/tooling.ts +332 -10
- package/src/gate/measures.ts +4 -3
- package/src/gov/adapter.ts +53 -4
- package/src/gov/audit.ts +6 -1
- package/src/gov/splits.ts +36 -0
- package/src/inventory/subjects.ts +1 -1
- package/src/sync/check.ts +9 -1
- package/src/sync/stamp.ts +135 -11
- package/src/tooling/disposition.ts +167 -0
- package/src/tooling/inject.ts +17 -2
- package/src/tooling/package.ts +22 -13
- package/src/tooling/scan.ts +86 -7
- package/src/tooling/stamp.ts +51 -7
- package/tooling/web/reference.md +3 -1
- package/governance/ledger/ui/copy.md +0 -49
- package/governance/ledger/ui/design-taste.md +0 -19
- package/governance/ledger/ui/interface-casing.md +0 -47
- package/governance/ledger/ui/keyboard.md +0 -68
- package/governance/ledger/ui/link-behavior.md +0 -29
- package/governance/rules/ui/copy.md +0 -28
- package/governance/rules/ui/design-taste.md +0 -18
- package/governance/rules/ui/interface-casing.md +0 -29
- package/governance/rules/ui/link-behavior.md +0 -19
- /package/tooling/web/{configs → seeds}/e2e/home.spec.ts +0 -0
package/src/commands/tooling.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@/tooling/inject'
|
|
12
12
|
import { buildStackSummaries, describeStack } from '@/tooling/list'
|
|
13
13
|
import {
|
|
14
|
+
configFiles,
|
|
14
15
|
isStackExcluded,
|
|
15
16
|
listStacks,
|
|
16
17
|
loadManifest,
|
|
@@ -23,6 +24,16 @@ import {
|
|
|
23
24
|
renderStackSections,
|
|
24
25
|
resolveReference,
|
|
25
26
|
} from '@/tooling/read'
|
|
27
|
+
import { resolveRecorded } from '@/sync/check'
|
|
28
|
+
import { readStamp, stampedChain, writeDispositions } from '@/sync/stamp'
|
|
29
|
+
import {
|
|
30
|
+
customize,
|
|
31
|
+
decline,
|
|
32
|
+
lift,
|
|
33
|
+
normalizeGoldenPath,
|
|
34
|
+
readDispositions,
|
|
35
|
+
resolveGolden,
|
|
36
|
+
} from '@/tooling/disposition'
|
|
26
37
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
27
38
|
import { recordToolingChain } from '@/tooling/stamp'
|
|
28
39
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
@@ -44,14 +55,26 @@ interface SyncOptions {
|
|
|
44
55
|
readonly skip?: string
|
|
45
56
|
readonly check?: boolean
|
|
46
57
|
readonly write?: boolean
|
|
58
|
+
readonly replace?: readonly string[]
|
|
47
59
|
readonly diff?: { readonly json: boolean }
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
function collect(value: string, previous: readonly string[]): string[] {
|
|
63
|
+
return [...previous, value]
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
interface DiffOptions {
|
|
51
67
|
readonly skip?: string
|
|
52
68
|
readonly json?: boolean
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
interface DispositionOptions {
|
|
72
|
+
readonly reason?: string
|
|
73
|
+
readonly json?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type DispositionAction = 'decline' | 'customize' | 'lift'
|
|
77
|
+
|
|
55
78
|
interface InjectOptions {
|
|
56
79
|
readonly configs?: boolean
|
|
57
80
|
readonly seeds?: boolean
|
|
@@ -94,6 +117,12 @@ export function register(program: Command): void {
|
|
|
94
117
|
'Report what would change and write nothing, always exiting 0. Prefer `canon tooling diff`',
|
|
95
118
|
)
|
|
96
119
|
.option('--write', 'Apply every change without prompting')
|
|
120
|
+
.option(
|
|
121
|
+
'--replace <path>',
|
|
122
|
+
'Replace a held config with the golden copy (repeatable)',
|
|
123
|
+
collect,
|
|
124
|
+
[],
|
|
125
|
+
)
|
|
97
126
|
.addHelpText(
|
|
98
127
|
'after',
|
|
99
128
|
[
|
|
@@ -102,6 +131,7 @@ export function register(program: Command): void {
|
|
|
102
131
|
' canon tooling sync base',
|
|
103
132
|
' canon tooling sync base --check',
|
|
104
133
|
' canon tooling sync base --write',
|
|
134
|
+
' canon tooling sync base --write --replace .oxfmtrc.json',
|
|
105
135
|
' canon tooling sync vite-react web --skip base',
|
|
106
136
|
'',
|
|
107
137
|
'A target below its git root syncs as a subfolder. It withholds',
|
|
@@ -115,8 +145,15 @@ export function register(program: Command): void {
|
|
|
115
145
|
'what `canon sync --check --exit-code` spells with a flag. Pass --check',
|
|
116
146
|
'to report the same list and always exit 0.',
|
|
117
147
|
'',
|
|
118
|
-
'--write replaces
|
|
148
|
+
'--write replaces a golden config only when canon changed it and the',
|
|
149
|
+
'project did not, which the hash the last write recorded tells apart.',
|
|
150
|
+
'A config the project edited, or one written before hashes were',
|
|
151
|
+
'recorded, is held and listed. Name it with --replace to take the',
|
|
152
|
+
'golden copy, or run `canon tooling customize <path>` to keep yours.',
|
|
153
|
+
'A config no recorded stack ships is a first install and never held,',
|
|
154
|
+
'so a fresh scaffold takes every golden config of a stack new to it.',
|
|
119
155
|
'Seeds are copy-once, so a seed the target already holds is left alone.',
|
|
156
|
+
'A declined or customized path is never written.',
|
|
120
157
|
'',
|
|
121
158
|
'Exit codes:',
|
|
122
159
|
' 0 synced, up to date, cancelled, or run with --check',
|
|
@@ -167,6 +204,88 @@ export function register(program: Command): void {
|
|
|
167
204
|
},
|
|
168
205
|
)
|
|
169
206
|
|
|
207
|
+
const dispositionHelp = [
|
|
208
|
+
'',
|
|
209
|
+
'<path> is relative to the target, as `canon tooling diff` lists it, and',
|
|
210
|
+
'resolves against the chain the last sync recorded. These verbs are the',
|
|
211
|
+
'only writers of the record, kept under domains.tooling.dispositions in',
|
|
212
|
+
'canon/config/config.json. Nothing here deletes or edits the file itself.',
|
|
213
|
+
'',
|
|
214
|
+
'Exit codes:',
|
|
215
|
+
' 0 recorded',
|
|
216
|
+
' 1 refused: no-chain, bad-path, not-golden, already-held,',
|
|
217
|
+
' seed-not-customizable, missing-file, not-held, or toolkit-root',
|
|
218
|
+
'',
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
tooling
|
|
222
|
+
.command('decline')
|
|
223
|
+
.description('Keep sync and inject from ever writing one golden file')
|
|
224
|
+
.argument('<path>', 'Golden file path, relative to the target')
|
|
225
|
+
.argument('[target]', 'Target directory', '.')
|
|
226
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
227
|
+
.option('--reason <text>', 'Record why the project declined it')
|
|
228
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
229
|
+
.addHelpText(
|
|
230
|
+
'after',
|
|
231
|
+
[
|
|
232
|
+
...dispositionHelp,
|
|
233
|
+
'Examples:',
|
|
234
|
+
' canon tooling decline .github/workflows/readme-screenshot.yml',
|
|
235
|
+
' canon tooling decline .oxlintrc.json web --reason "lint runs in CI only"',
|
|
236
|
+
'',
|
|
237
|
+
].join('\n'),
|
|
238
|
+
)
|
|
239
|
+
.action(async (path: string, target: string, opts: DispositionOptions) => {
|
|
240
|
+
process.exitCode = await runDisposition('decline', path, target, opts)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
tooling
|
|
244
|
+
.command('customize')
|
|
245
|
+
.description(
|
|
246
|
+
'Keep the project copy of one golden config through every write',
|
|
247
|
+
)
|
|
248
|
+
.argument('<path>', 'Golden config path, relative to the target')
|
|
249
|
+
.argument('[target]', 'Target directory', '.')
|
|
250
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
251
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
252
|
+
.addHelpText(
|
|
253
|
+
'after',
|
|
254
|
+
[
|
|
255
|
+
...dispositionHelp,
|
|
256
|
+
'The golden hash is recorded, so `canon tooling diff` exits 1 once the',
|
|
257
|
+
'stack ships a different copy. A seed cannot be customized, since sync',
|
|
258
|
+
'never overwrites one.',
|
|
259
|
+
'',
|
|
260
|
+
'Examples:',
|
|
261
|
+
' canon tooling customize e2e/screenshot.ts',
|
|
262
|
+
'',
|
|
263
|
+
].join('\n'),
|
|
264
|
+
)
|
|
265
|
+
.action(async (path: string, target: string, opts: DispositionOptions) => {
|
|
266
|
+
process.exitCode = await runDisposition('customize', path, target, opts)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
tooling
|
|
270
|
+
.command('lift')
|
|
271
|
+
.description('Hand a declined or customized file back to sync')
|
|
272
|
+
.argument('<path>', 'Golden file path, relative to the target')
|
|
273
|
+
.argument('[target]', 'Target directory', '.')
|
|
274
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
275
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
276
|
+
.addHelpText(
|
|
277
|
+
'after',
|
|
278
|
+
[
|
|
279
|
+
...dispositionHelp,
|
|
280
|
+
'Examples:',
|
|
281
|
+
' canon tooling lift .github/workflows/readme-screenshot.yml',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
)
|
|
285
|
+
.action(async (path: string, target: string, opts: DispositionOptions) => {
|
|
286
|
+
process.exitCode = await runDisposition('lift', path, target, opts)
|
|
287
|
+
})
|
|
288
|
+
|
|
170
289
|
tooling
|
|
171
290
|
.command('inject')
|
|
172
291
|
.description('Apply one stack to a target without scanning or prompting')
|
|
@@ -380,7 +499,7 @@ async function runSync(
|
|
|
380
499
|
|
|
381
500
|
if (opts.check === true && opts.write === true) {
|
|
382
501
|
return refuse(
|
|
383
|
-
opts,
|
|
502
|
+
opts.diff?.json,
|
|
384
503
|
'conflicting-flags',
|
|
385
504
|
'Pass --check or --write, not both.',
|
|
386
505
|
)
|
|
@@ -388,22 +507,37 @@ async function runSync(
|
|
|
388
507
|
|
|
389
508
|
const selected = stack ?? (await promptForStack())
|
|
390
509
|
if (selected === undefined) {
|
|
391
|
-
return refuse(opts, 'no-stacks', 'No tooling stacks found')
|
|
510
|
+
return refuse(opts.diff?.json, 'no-stacks', 'No tooling stacks found')
|
|
392
511
|
}
|
|
393
512
|
|
|
394
513
|
if (isStackExcluded(selected)) {
|
|
395
514
|
return refuse(
|
|
396
|
-
opts,
|
|
515
|
+
opts.diff?.json,
|
|
397
516
|
'excluded-stack',
|
|
398
517
|
'Claude is managed by `canon claude`, not `canon tooling`. Run `canon claude sync` instead.',
|
|
399
518
|
)
|
|
400
519
|
}
|
|
401
520
|
|
|
402
521
|
const prepared = prepare(selected, target, opts.skip)
|
|
403
|
-
if (!prepared.ok)
|
|
522
|
+
if (!prepared.ok)
|
|
523
|
+
return refuse(opts.diff?.json, prepared.reason, prepared.error)
|
|
404
524
|
|
|
405
525
|
const result = scan(prepared.chain, prepared.target)
|
|
406
526
|
|
|
527
|
+
const replace = (opts.replace ?? []).map(
|
|
528
|
+
(input) => normalizeGoldenPath(input) ?? input,
|
|
529
|
+
)
|
|
530
|
+
const unknown = replace.filter(
|
|
531
|
+
(rel) => !result.configs.some((entry) => entry.rel === rel),
|
|
532
|
+
)
|
|
533
|
+
if (unknown.length > 0) {
|
|
534
|
+
return refuse(
|
|
535
|
+
opts.diff?.json,
|
|
536
|
+
'unknown-replace',
|
|
537
|
+
`--replace names no config this chain writes: ${unknown.join(', ')}`,
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
407
541
|
report(result, prepared.target)
|
|
408
542
|
|
|
409
543
|
if (opts.diff !== undefined) {
|
|
@@ -442,15 +576,16 @@ async function runSync(
|
|
|
442
576
|
return decision === 'unauthorized' ? 1 : 0
|
|
443
577
|
}
|
|
444
578
|
|
|
579
|
+
const held = heldConfigs(result, replace, prepared.chain, prepared.target)
|
|
445
580
|
if (result.configs.some((entry) => entry.state !== 'matching')) {
|
|
446
|
-
await injectConfigs(prepared.chain, prepared.target)
|
|
581
|
+
await injectConfigs(prepared.chain, prepared.target, new Set(held))
|
|
447
582
|
}
|
|
448
583
|
|
|
449
584
|
await injectSeeds(prepared.chain, prepared.target)
|
|
450
585
|
const manifest = await injectManifest(prepared.chain, prepared.target)
|
|
451
586
|
if (!manifest.ok) {
|
|
452
587
|
return refuse(
|
|
453
|
-
|
|
588
|
+
false,
|
|
454
589
|
manifest.reason,
|
|
455
590
|
`${manifest.message}\nConfigs and seeds above were written. Scripts, gitignore entries, and the stamp were not, so the next sync retries.`,
|
|
456
591
|
)
|
|
@@ -458,24 +593,170 @@ async function runSync(
|
|
|
458
593
|
|
|
459
594
|
await stampChain(prepared.chain, prepared.target)
|
|
460
595
|
|
|
596
|
+
if (held.length > 0) {
|
|
597
|
+
reportHeld(held, selected, target, opts.skip)
|
|
598
|
+
}
|
|
599
|
+
|
|
461
600
|
outroSuccess('Tooling sync complete')
|
|
462
601
|
return 0
|
|
463
602
|
}
|
|
464
603
|
|
|
604
|
+
/**
|
|
605
|
+
* A drifted config the project edited, or one no write recorded, is held
|
|
606
|
+
* rather than replaced, since a write naming a stack is not consent to lose an
|
|
607
|
+
* edit inside it. Only a path `--replace` names is taken over.
|
|
608
|
+
*
|
|
609
|
+
* A path no recorded stack ships is a first install of that stack, so it is
|
|
610
|
+
* never held. `canon init` records `base` before a framework stack syncs over
|
|
611
|
+
* a fresh scaffold, whose own copies of that stack's configs came from the
|
|
612
|
+
* scaffold rather than from a project edit.
|
|
613
|
+
*/
|
|
614
|
+
function heldConfigs(
|
|
615
|
+
result: ScanResult,
|
|
616
|
+
replace: readonly string[],
|
|
617
|
+
chain: readonly Manifest[],
|
|
618
|
+
target: string,
|
|
619
|
+
): string[] {
|
|
620
|
+
const recorded = new Set(stampedChain(readStamp(target), 'tooling'))
|
|
621
|
+
const installed = new Set(
|
|
622
|
+
chain
|
|
623
|
+
.filter((manifest) => recorded.has(manifest.name))
|
|
624
|
+
.flatMap(configFiles),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
return result.configs
|
|
628
|
+
.filter(
|
|
629
|
+
(entry) =>
|
|
630
|
+
entry.state === 'drifted' &&
|
|
631
|
+
entry.cause !== 'stale' &&
|
|
632
|
+
installed.has(entry.rel) &&
|
|
633
|
+
!replace.includes(entry.rel),
|
|
634
|
+
)
|
|
635
|
+
.map((entry) => entry.rel)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Names every held path in one copyable command, so taking the first run's
|
|
640
|
+
* unattributed holds costs one paste rather than one flag per path typed.
|
|
641
|
+
*/
|
|
642
|
+
function reportHeld(
|
|
643
|
+
held: readonly string[],
|
|
644
|
+
stack: string,
|
|
645
|
+
target: string,
|
|
646
|
+
skip: string | undefined,
|
|
647
|
+
): void {
|
|
648
|
+
logStep('Held')
|
|
649
|
+
for (const rel of held) logWarn(rel)
|
|
650
|
+
logInfo(
|
|
651
|
+
'The project changed these, or no earlier write recorded them, so --write left them as they are. Keep one with `canon tooling customize <path>`, or take the golden copies with:',
|
|
652
|
+
)
|
|
653
|
+
const command = [
|
|
654
|
+
'canon tooling sync',
|
|
655
|
+
stack,
|
|
656
|
+
target,
|
|
657
|
+
...(skip === undefined ? [] : ['--skip', skip]),
|
|
658
|
+
'--write',
|
|
659
|
+
...held.flatMap((rel) => ['--replace', rel]),
|
|
660
|
+
]
|
|
661
|
+
logInfo(command.join(' '))
|
|
662
|
+
}
|
|
663
|
+
|
|
465
664
|
/**
|
|
466
665
|
* A diff caller branches on the record rather than the exit, since a shell
|
|
467
666
|
* wrapper can flatten the status. Every refusal therefore answers with a reason
|
|
468
667
|
* on stdout under `--json`, beside the exit 1 that keeps a gate failing.
|
|
469
668
|
*/
|
|
470
|
-
function refuse(
|
|
669
|
+
function refuse(
|
|
670
|
+
json: boolean | undefined,
|
|
671
|
+
reason: string,
|
|
672
|
+
message: string,
|
|
673
|
+
): number {
|
|
471
674
|
logWarn(message)
|
|
472
|
-
if (
|
|
675
|
+
if (json === true) {
|
|
473
676
|
process.stdout.write(`${JSON.stringify({ ok: false, reason, message })}\n`)
|
|
474
677
|
}
|
|
475
678
|
outro()
|
|
476
679
|
return 1
|
|
477
680
|
}
|
|
478
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Resolves the path against the chain the last sync recorded rather than a
|
|
684
|
+
* stack argument, since a disposition belongs to the path whichever stack
|
|
685
|
+
* ships it. An unstamped target has no chain to resolve against and refuses.
|
|
686
|
+
*/
|
|
687
|
+
async function runDisposition(
|
|
688
|
+
action: DispositionAction,
|
|
689
|
+
input: string,
|
|
690
|
+
target: string,
|
|
691
|
+
opts: DispositionOptions,
|
|
692
|
+
): Promise<number> {
|
|
693
|
+
intro(`canon tooling ${action}`)
|
|
694
|
+
|
|
695
|
+
const resolved = resolve(target)
|
|
696
|
+
if (resolved === PROJECT_ROOT) {
|
|
697
|
+
return refuse(
|
|
698
|
+
opts.json,
|
|
699
|
+
'toolkit-root',
|
|
700
|
+
'Cannot run against toolkit root. Files here are the source of truth.',
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const rel = normalizeGoldenPath(input)
|
|
705
|
+
if (rel === undefined) {
|
|
706
|
+
return refuse(
|
|
707
|
+
opts.json,
|
|
708
|
+
'bad-path',
|
|
709
|
+
`${input} is not a path inside the target. Pass it relative to the target.`,
|
|
710
|
+
)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const chain = resolveRecorded(
|
|
714
|
+
PROJECT_ROOT,
|
|
715
|
+
stampedChain(readStamp(resolved), 'tooling'),
|
|
716
|
+
)
|
|
717
|
+
if (chain.length === 0) {
|
|
718
|
+
return refuse(
|
|
719
|
+
opts.json,
|
|
720
|
+
'no-chain',
|
|
721
|
+
`No tooling chain is recorded in ${resolved}. Run \`canon tooling sync <stack>\` there first.`,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const current = readDispositions(resolved)
|
|
726
|
+
const golden = resolveGolden(chain, rel)
|
|
727
|
+
const transition =
|
|
728
|
+
action === 'decline'
|
|
729
|
+
? decline(current, golden, opts.reason)
|
|
730
|
+
: action === 'customize'
|
|
731
|
+
? customize(current, golden, existsSync(join(resolved, rel)))
|
|
732
|
+
: lift(current, rel)
|
|
733
|
+
|
|
734
|
+
if (!transition.ok) {
|
|
735
|
+
return refuse(opts.json, transition.reason, transition.message)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await writeDispositions(
|
|
739
|
+
resolved,
|
|
740
|
+
{ domain: 'tooling', toolkitRoot: PROJECT_ROOT },
|
|
741
|
+
transition.dispositions,
|
|
742
|
+
new Date(),
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
const entry = transition.dispositions[rel]
|
|
746
|
+
if (opts.json === true) {
|
|
747
|
+
process.stdout.write(
|
|
748
|
+
`${JSON.stringify({ ok: true, action, path: rel, ...(entry === undefined ? {} : { entry }) })}\n`,
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
outroSuccess(
|
|
753
|
+
entry === undefined
|
|
754
|
+
? `Lifted ${rel}. The next sync writes it again.`
|
|
755
|
+
: `Recorded ${rel} as ${entry.disposition}. Sync and inject skip it.`,
|
|
756
|
+
)
|
|
757
|
+
return 0
|
|
758
|
+
}
|
|
759
|
+
|
|
479
760
|
type WriteMode = 'apply' | 'prompt' | 'report' | 'unauthorized'
|
|
480
761
|
|
|
481
762
|
type ApplyDecision = 'apply' | 'cancelled' | 'reported' | 'unauthorized'
|
|
@@ -629,13 +910,18 @@ function report(result: ScanResult, target: string): void {
|
|
|
629
910
|
if (entry.state === 'matching') logInfo(entry.rel)
|
|
630
911
|
}
|
|
631
912
|
for (const entry of result.configs) {
|
|
632
|
-
if (entry.state === 'drifted')
|
|
913
|
+
if (entry.state === 'drifted') {
|
|
914
|
+
logWarn(
|
|
915
|
+
entry.cause === 'stale' ? entry.rel : `${entry.rel} (${entry.cause})`,
|
|
916
|
+
)
|
|
917
|
+
}
|
|
633
918
|
}
|
|
634
919
|
for (const entry of result.configs) {
|
|
635
920
|
if (entry.state === 'new') logAdd(entry.rel)
|
|
636
921
|
}
|
|
637
922
|
|
|
638
923
|
if (result.withheld.length > 0) reportWithheld(result, target)
|
|
924
|
+
reportDispositions(result)
|
|
639
925
|
|
|
640
926
|
logStep('Scanning seeds')
|
|
641
927
|
for (const entry of result.seeds) {
|
|
@@ -681,6 +967,35 @@ function reportWithheld(result: ScanResult, target: string): void {
|
|
|
681
967
|
logInfo(`working-directory: ${subfolderPath(target) ?? '.'}`)
|
|
682
968
|
}
|
|
683
969
|
|
|
970
|
+
function reportDispositions(result: ScanResult): void {
|
|
971
|
+
const declined = result.dispositions.filter(
|
|
972
|
+
(entry) => entry.disposition === 'declined',
|
|
973
|
+
)
|
|
974
|
+
const customized = result.dispositions.filter(
|
|
975
|
+
(entry) => entry.disposition === 'customized',
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
if (declined.length > 0) {
|
|
979
|
+
logStep('Declined')
|
|
980
|
+
for (const entry of declined) {
|
|
981
|
+
logInfo(entry.present ? `${entry.rel} (still here)` : entry.rel)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (customized.length > 0) {
|
|
986
|
+
logStep('Customized')
|
|
987
|
+
for (const entry of customized) {
|
|
988
|
+
if (entry.moved === true) {
|
|
989
|
+
logWarn(
|
|
990
|
+
`${entry.rel}: the ${entry.stack} golden changed since it was customized. Run \`canon tooling lift ${entry.rel}\` to take the new copy on the next write, or lift and customize again to keep this one.`,
|
|
991
|
+
)
|
|
992
|
+
} else {
|
|
993
|
+
logInfo(entry.rel)
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
684
999
|
function reportPackage(result: ScanResult): void {
|
|
685
1000
|
if (result.scripts.length > 0) {
|
|
686
1001
|
logStep('Scanning scripts')
|
|
@@ -699,6 +1014,9 @@ function reportPackage(result: ScanResult): void {
|
|
|
699
1014
|
logStep('Scanning dependencies')
|
|
700
1015
|
for (const entry of result.deps) {
|
|
701
1016
|
if (entry.state === 'present') logInfo(entry.name)
|
|
1017
|
+
if (entry.state === 'ahead') {
|
|
1018
|
+
logInfo(`${entry.name} ${entry.installed} (ahead of ${entry.spec})`)
|
|
1019
|
+
}
|
|
702
1020
|
}
|
|
703
1021
|
for (const entry of result.deps) {
|
|
704
1022
|
if (entry.state === 'missing') logAdd(entry.spec)
|
|
@@ -717,6 +1035,10 @@ function summarize(result: ScanResult): string {
|
|
|
717
1035
|
result.configs.filter((entry) => entry.state !== 'matching').length,
|
|
718
1036
|
'configs',
|
|
719
1037
|
)
|
|
1038
|
+
add(
|
|
1039
|
+
result.dispositions.filter((entry) => entry.moved === true).length,
|
|
1040
|
+
'moved customized',
|
|
1041
|
+
)
|
|
720
1042
|
add(result.seeds.filter((entry) => entry.state === 'missing').length, 'seeds')
|
|
721
1043
|
add(
|
|
722
1044
|
result.scripts.filter((entry) => entry.state !== 'matching').length,
|
package/src/gate/measures.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
auditSkills,
|
|
13
13
|
LEDGER_SKILLS,
|
|
14
14
|
PRACTICE_LEDGER,
|
|
15
|
+
SKILL_LEDGERS,
|
|
15
16
|
PRACTICE_SKILLS,
|
|
16
17
|
SHIPPED_CORPUS,
|
|
17
18
|
} from '@/claude/skills-audit'
|
|
@@ -384,7 +385,7 @@ export const skillProvenance: Measure = async (ctx) => {
|
|
|
384
385
|
|
|
385
386
|
/**
|
|
386
387
|
* Every listed practice skill against the closing sections, and every skill on
|
|
387
|
-
* the wider ledger list against
|
|
388
|
+
* the wider ledger list against a ledger that cites something, both of which
|
|
388
389
|
* `claude/skills/create-skill/references/skill-practice.md` requires.
|
|
389
390
|
*
|
|
390
391
|
* Read in-process for the reason `skillProvenance` is, so the verb keeps its
|
|
@@ -407,14 +408,14 @@ export const skillPracticeShape: Measure = async (ctx) => {
|
|
|
407
408
|
.join(', ')
|
|
408
409
|
return {
|
|
409
410
|
emissions: [],
|
|
410
|
-
failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list.`,
|
|
411
|
+
failure: `${plural(found.length, 'practice shape finding')}: ${where}. Add the closing H2 exactly as named to a practice skill, or the ledger at ${PRACTICE_LEDGER} to any skill on the ledger list, citing one linked source or one House choice row. A reference on the per-bullet list takes one ledger row per bullet under the same H2 at ${SKILL_LEDGERS}.`,
|
|
411
412
|
}
|
|
412
413
|
}
|
|
413
414
|
|
|
414
415
|
return {
|
|
415
416
|
emissions: [
|
|
416
417
|
info(
|
|
417
|
-
`${plural(PRACTICE_SKILLS.length, 'practice skill')} carry every closing section, and ${plural(LEDGER_SKILLS.length, 'listed skill')} carry a ledger`,
|
|
418
|
+
`${plural(PRACTICE_SKILLS.length, 'practice skill')} carry every closing section, and ${plural(LEDGER_SKILLS.length, 'listed skill')} carry a ledger citing a source`,
|
|
418
419
|
),
|
|
419
420
|
],
|
|
420
421
|
}
|
package/src/gov/adapter.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, join, relative, resolve } from 'node:path'
|
|
|
3
3
|
import { readCoverage } from '@/gov/coverage'
|
|
4
4
|
import { canonRulesDir, ruleSubdir } from '@/gov/install'
|
|
5
5
|
import { loadRenames, resolveSuccessor } from '@/gov/renames'
|
|
6
|
+
import { loadSplits } from '@/gov/splits'
|
|
6
7
|
import { resolveMissingRules } from '@/gov/stacks'
|
|
7
8
|
import type { InstalledFile, RetiredSurface, SyncAdapter } from '@/sync/engine'
|
|
8
9
|
import { readStamp, stampedChain } from '@/sync/stamp'
|
|
@@ -125,14 +126,15 @@ function heldRulePath(target: string, rule: string): string | undefined {
|
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
128
|
* Rules the target's recorded chain entitles it to and its tree does not
|
|
128
|
-
* hold. Reports as `notice` text
|
|
129
|
-
* already returns, since both are
|
|
130
|
-
* absence to remove, this one an
|
|
129
|
+
* hold, then the partners of a held rule that split. Reports as `notice` text
|
|
130
|
+
* through the same shape `collectRetired` already returns, since both are
|
|
131
|
+
* surfaces the file walk cannot see: one an absence to remove, this one an
|
|
132
|
+
* absence to add.
|
|
131
133
|
*/
|
|
132
134
|
function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
133
135
|
const chain = stampedChain(readStamp(target), 'governance')
|
|
134
136
|
|
|
135
|
-
|
|
137
|
+
const fromChain = resolveMissingRules(root, target, chain).map((source) => {
|
|
136
138
|
const dest = join(canonRulesDir(target), source.subdir, `${source.rule}.md`)
|
|
137
139
|
const rel = relative(target, dest)
|
|
138
140
|
return {
|
|
@@ -141,6 +143,53 @@ function collectMissingGov(root: string, target: string): RetiredSurface[] {
|
|
|
141
143
|
notice: `${rel} (listed by ${chain[0]}, not installed. Run canon gov install ${chain[0]} to add it.)`,
|
|
142
144
|
}
|
|
143
145
|
})
|
|
146
|
+
|
|
147
|
+
const listed = new Set(fromChain.map((entry) => entry.path))
|
|
148
|
+
const fromSplits = collectSplitPartners(root, target).filter(
|
|
149
|
+
(entry) => !listed.has(entry.path),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return [...fromChain, ...fromSplits]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Partners the split ledger names for a rule the target holds and whose own
|
|
157
|
+
* file it lacks. The ui rules sit in no stack, so no chain entitles a target
|
|
158
|
+
* to them and the chain-based read stays silent when one splits. Holding the
|
|
159
|
+
* rule is the entitlement, found by basename the way `heldRulePath` finds a
|
|
160
|
+
* predecessor, so a partner held under another folder reads as held.
|
|
161
|
+
*/
|
|
162
|
+
function collectSplitPartners(root: string, target: string): RetiredSurface[] {
|
|
163
|
+
const splits = loadSplits(root)
|
|
164
|
+
if (splits.size === 0) return []
|
|
165
|
+
|
|
166
|
+
const index = indexSourceRules(root)
|
|
167
|
+
const notices: RetiredSurface[] = []
|
|
168
|
+
|
|
169
|
+
for (const [held, partners] of splits) {
|
|
170
|
+
if (heldRulePath(target, held) === undefined) continue
|
|
171
|
+
|
|
172
|
+
for (const partner of partners) {
|
|
173
|
+
const source = index.get(partner)
|
|
174
|
+
if (source === undefined || heldRulePath(target, partner) !== undefined) {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const dest = join(
|
|
179
|
+
canonRulesDir(target),
|
|
180
|
+
ruleSubdir(source, rulesSourceDir(root)),
|
|
181
|
+
`${partner}.md`,
|
|
182
|
+
)
|
|
183
|
+
const rel = relative(target, dest)
|
|
184
|
+
notices.push({
|
|
185
|
+
path: dest,
|
|
186
|
+
rel,
|
|
187
|
+
notice: `${rel} (${held}.md moved bullets here. Run canon gov install --add ${partner} to add it.)`,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return notices
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
function collectRetiredGov(target: string): RetiredSurface[] {
|
package/src/gov/audit.ts
CHANGED
|
@@ -294,7 +294,12 @@ export function countLedgerRows(text: string): HeadingCount[] {
|
|
|
294
294
|
return [...counts].map(([key, count]) => ({ heading: key, count }))
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Pairs bullets and ledger rows under each heading, naming every heading whose
|
|
299
|
+
* counts differ. The skill audit reads a practice skill's references through
|
|
300
|
+
* it, so a rule and a reference are held to one comparison.
|
|
301
|
+
*/
|
|
302
|
+
export function compareLedger(
|
|
298
303
|
groups: readonly HeadingCount[],
|
|
299
304
|
rows: readonly HeadingCount[],
|
|
300
305
|
): LedgerMismatch[] {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Held rule basename to the basenames its moved bullets now live in. */
|
|
5
|
+
export type Splits = ReadonlyMap<string, readonly string[]>
|
|
6
|
+
|
|
7
|
+
function splitsPath(root: string): string {
|
|
8
|
+
return join(root, 'governance', 'splits.toml')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the shipped split ledger. A toolkit without one declares no splits,
|
|
13
|
+
* which leaves every sync silent about partners, the way it was before the
|
|
14
|
+
* ledger existed.
|
|
15
|
+
*/
|
|
16
|
+
export function loadSplits(root: string): Splits {
|
|
17
|
+
const path = splitsPath(root)
|
|
18
|
+
if (!existsSync(path)) return new Map()
|
|
19
|
+
|
|
20
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
21
|
+
string,
|
|
22
|
+
unknown
|
|
23
|
+
>
|
|
24
|
+
const table = parsed.split
|
|
25
|
+
if (typeof table !== 'object' || table === null) return new Map()
|
|
26
|
+
|
|
27
|
+
const entries: [string, string[]][] = []
|
|
28
|
+
for (const [held, value] of Object.entries(table)) {
|
|
29
|
+
if (!Array.isArray(value)) continue
|
|
30
|
+
const partners = value.filter(
|
|
31
|
+
(partner): partner is string => typeof partner === 'string',
|
|
32
|
+
)
|
|
33
|
+
if (partners.length > 0) entries.push([held, partners])
|
|
34
|
+
}
|
|
35
|
+
return new Map(entries)
|
|
36
|
+
}
|
|
@@ -27,7 +27,7 @@ export interface Subject {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Reports what each element changes about itself when it takes focus, which is
|
|
30
|
-
* the reading `
|
|
30
|
+
* the reading the `ui-craft` keyboard reference states three rules against and
|
|
31
31
|
* nothing measures.
|
|
32
32
|
*
|
|
33
33
|
* The difference between rest and focus is the treatment, rather than the
|