@erclx/canon 5.60.0 → 5.62.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 (65) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +2 -1
  5. package/claude/skills/design-identity/references/card-route.md +3 -3
  6. package/claude/skills/design-record/references/design.md +19 -0
  7. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  8. package/claude/skills/document-health/SKILL.md +6 -5
  9. package/claude/skills/document-health/references/axes.md +3 -1
  10. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  11. package/claude/skills/review-ui/SKILL.md +3 -2
  12. package/claude/skills/review-ui/references/post.md +2 -2
  13. package/claude/skills/target-update/SKILL.md +9 -1
  14. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  15. package/claude/skills/ui-checklist/SKILL.md +12 -0
  16. package/claude/skills/ux-audit/SKILL.md +1 -1
  17. package/docs/agents/install-and-sync.md +45 -11
  18. package/docs/agents/records.md +1 -1
  19. package/docs/index.md +1 -1
  20. package/docs/target/migrations.md +7 -9
  21. package/docs/target/sync.md +20 -1
  22. package/docs/workflow/ai-workflow.md +1 -1
  23. package/docs/workflow/index.md +3 -3
  24. package/docs/workflow/parallel-features.md +1 -1
  25. package/docs/workflow/visual-design-workflow.md +58 -32
  26. package/governance/detect.toml +8 -0
  27. package/governance/ledger/ui/a11y.md +8 -6
  28. package/governance/ledger/ui/keyboard.md +20 -21
  29. package/governance/ledger/ui/pointer.md +24 -0
  30. package/governance/ledger/ui/surface-capture.md +12 -12
  31. package/governance/ledger/ui/ux-completeness.md +11 -23
  32. package/governance/ledger/ui/ux-controls.md +26 -0
  33. package/governance/rules/ui/a11y.md +2 -0
  34. package/governance/rules/ui/keyboard.md +4 -7
  35. package/governance/rules/ui/pointer.md +23 -0
  36. package/governance/rules/ui/surface-capture.md +5 -5
  37. package/governance/rules/ui/ux-completeness.md +2 -10
  38. package/governance/rules/ui/ux-controls.md +25 -0
  39. package/governance/splits.toml +15 -0
  40. package/package.json +2 -1
  41. package/src/commands/driver.ts +29 -3
  42. package/src/commands/migrate.ts +7 -0
  43. package/src/commands/records.ts +3 -2
  44. package/src/commands/sync.ts +3 -1
  45. package/src/commands/tooling.ts +329 -10
  46. package/src/design/audit.ts +25 -1
  47. package/src/design/budget.ts +2 -1
  48. package/src/design/document.ts +5 -1
  49. package/src/design/themes.ts +176 -0
  50. package/src/design/tokens.ts +3 -0
  51. package/src/driver/drive.ts +17 -1
  52. package/src/driver/probes/axe.ts +195 -0
  53. package/src/driver/steps.ts +20 -1
  54. package/src/gate/measures.ts +9 -3
  55. package/src/gov/adapter.ts +53 -4
  56. package/src/gov/splits.ts +36 -0
  57. package/src/markdown/structure.ts +4 -1
  58. package/src/migrate/records.ts +46 -7
  59. package/src/records/stale-canonical.ts +2 -2
  60. package/src/sync/check.ts +9 -1
  61. package/src/sync/stamp.ts +135 -11
  62. package/src/tooling/disposition.ts +167 -0
  63. package/src/tooling/inject.ts +17 -2
  64. package/src/tooling/scan.ts +86 -7
  65. package/src/tooling/stamp.ts +51 -7
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: Destructive actions, edit cancel, and element states for interactive controls
3
+ paths:
4
+ - '**/*.tsx'
5
+ - '**/*.jsx'
6
+ - '**/*.astro'
7
+ - '**/*.html'
8
+ - '**/*.css'
9
+ ---
10
+
11
+ # UX controls standards
12
+
13
+ ## Destructive actions
14
+
15
+ - Confirm a destructive action, such as a delete, disconnect, or discard, before it runs, unless it can be undone afterward.
16
+ - Restore the value from before an edit when the edit is cancelled. Never discard typed work on Escape or an outside click unasked, but confirm first when the person has typed something.
17
+
18
+ ## Element states
19
+
20
+ - Give every interactive element distinct hover, active, focus, and disabled styles.
21
+ - Never flash an intermediate state, such as a light frame before a dark theme or a layout jump before data arrives. Reserve the space or settle it first.
22
+ - Say why a control is disabled, in text beside it or a tooltip, and keep it focusable through `aria-disabled` when the reason matters.
23
+ - Declare `color-scheme` on `:root` to match each theme, so scrollbars and form controls follow the theme instead of staying light.
24
+ - Keep content shown on hover or focus dismissible without moving the pointer, hoverable, and visible until the trigger leaves or the person dismisses it (WCAG 1.4.13).
25
+ - Change no context, such as navigating, submitting, or moving focus, when a control gains focus or changes value, unless the person was told first (WCAG 3.2.1, 3.2.2).
@@ -0,0 +1,15 @@
1
+ # Rule splits a sync tells a target about. Each key under [split] is the
2
+ # basename of a rule that shed bullets into others, and its value lists the
3
+ # basenames those bullets moved to. `canon gov sync` adds no rule, so a target
4
+ # holding the key and not a partner reads a notice naming the partner and the
5
+ # `canon gov install --add` command that installs it.
6
+ #
7
+ # This ledger is not the rename ledger. A rename entry resolves to a successor
8
+ # the sync installs, and a split key is a live rule the target keeps. Add the
9
+ # entry in the same change as the split. The notice is stateless, so a target
10
+ # installed after the split reads it too until it adds the partner. Prune an
11
+ # entry only once its key or partner is retired, which the live-rule check in
12
+ # `src/gov/splits.test.ts` forces.
13
+ [split]
14
+ keyboard = ["pointer"]
15
+ ux-completeness = ["ux-controls"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.60.0",
4
+ "version": "5.62.0",
5
5
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -51,6 +51,7 @@
51
51
  "web:e2e": "cd web && playwright test"
52
52
  },
53
53
  "dependencies": {
54
+ "axe-core": "4.14.0",
54
55
  "commander": "^13.1.0",
55
56
  "execa": "^9.5.2",
56
57
  "playwright-core": "1.64.1"
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
5
  import type { DriverRefusal } from '@/driver/drive'
6
+ import { AXE_GATE_RULES } from '@/driver/probes/axe'
6
7
  import { describeViewport } from '@/driver/probes/viewport'
7
8
  import { PROBE_NAMES, readDriverPlan, type ProbeName } from '@/driver/steps'
8
9
  import type { PlanRefusal } from '@/driver/steps'
@@ -51,6 +52,8 @@ const PLAN_REFUSALS: Record<PlanRefusal, string> = {
51
52
 
52
53
  const DRIVE_REFUSALS: Record<DriverRefusal, string> = {
53
54
  'browser-missing': 'The browser binary is not installed in this project.',
55
+ 'axe-missing':
56
+ 'The axe engine did not resolve from canon, so no page was read with it.',
54
57
  'server-unreachable': 'Nothing answered at the URL, so no state was reached.',
55
58
  'drive-failed': 'The drive failed against a reachable page.',
56
59
  }
@@ -62,6 +65,7 @@ const PROBE_NOTES: Record<ProbeName, string> = {
62
65
  'diagram-geometry':
63
66
  'an SVG label outside its frame, covered by a fill, or on another label',
64
67
  'diagram-strokes': 'an SVG label a stroke crosses, in either paint order',
68
+ axe: 'axe-core on WCAG 2.2 A and AA, frames excluded, run only when named',
65
69
  }
66
70
 
67
71
  export function register(program: Command): void {
@@ -89,9 +93,10 @@ export function register(program: Command): void {
89
93
  'run, since nothing else there measures the page as it first painted.',
90
94
  '',
91
95
  'The run file carries the viewport, the default probe list, and the',
92
- 'steps. A step names its own probes to override the default. Viewport',
93
- 'heights are never defaulted: the heights a defect hides at are a',
94
- 'property of the layout rather than of this command.',
96
+ 'steps. A step names its own probes to override the default, and a run',
97
+ 'naming no list gets every probe but axe. Viewport heights are never',
98
+ 'defaulted: the heights a defect hides at are a property of the layout',
99
+ 'rather than of this command.',
95
100
  '',
96
101
  ' {',
97
102
  ' "viewport": { "width": 1440, "heights": [900, 1200, 1500] },',
@@ -116,6 +121,11 @@ export function register(program: Command): void {
116
121
  (name) => ` ${name.padEnd(18)}${PROBE_NOTES[name]}`,
117
122
  ),
118
123
  '',
124
+ 'An axe finding carries rule, outcome, and gate. outcome is violation or',
125
+ 'incomplete, the second being axe declining to decide. gate is true for',
126
+ 'a violation of one of these rules, which a UI review holds open on:',
127
+ ...wrapRules(AXE_GATE_RULES),
128
+ '',
119
129
  'Reasons:',
120
130
  ' no-run-file nothing readable at the run path',
121
131
  ' unreadable-plan the run file is not valid JSON',
@@ -128,6 +138,7 @@ export function register(program: Command): void {
128
138
  ' no-heights the viewport names no height, which is never defaulted',
129
139
  ' engine-missing the browser engine is not installed in this project',
130
140
  ' browser-missing the engine is installed and its browser binary is not',
141
+ ' axe-missing the run names axe and its engine did not resolve',
131
142
  ' server-unreachable nothing answered at the URL, so no state was reached',
132
143
  ' drive-failed the drive failed against a reachable page',
133
144
  '',
@@ -153,6 +164,21 @@ export function register(program: Command): void {
153
164
  })
154
165
  }
155
166
 
167
+ /** Lays the gate list out at the help text's width. */
168
+ function wrapRules(rules: readonly string[]): string[] {
169
+ const lines: string[] = []
170
+ let line = ' '
171
+ for (const rule of rules) {
172
+ if (line.length + rule.length + 1 > 74) {
173
+ lines.push(line)
174
+ line = ' '
175
+ }
176
+ line += ` ${rule}`
177
+ }
178
+ lines.push(line)
179
+ return lines
180
+ }
181
+
156
182
  async function runDriver(
157
183
  url: string,
158
184
  runPath: string,
@@ -326,6 +326,11 @@ function reportRecords(plan: RecordsPlan, records: number): void {
326
326
  logInfo(
327
327
  `${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
328
328
  )
329
+ if (plan.dropped > 0) {
330
+ logInfo(
331
+ `${plural(plan.dropped, 'ignore line')} dropped, already covered by .canon/.`,
332
+ )
333
+ }
329
334
  logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
330
335
 
331
336
  // A count rather than a list, and its own line rather than a place in
@@ -401,6 +406,7 @@ function toRecordsRecord(
401
406
  collisions: plan.collisions,
402
407
  files: plan.entries.length,
403
408
  rewritten: plan.rewritten,
409
+ dropped: plan.dropped,
404
410
  kept: plan.kept,
405
411
  excluded: plan.excluded.length,
406
412
  coupled: plan.coupled,
@@ -414,6 +420,7 @@ function toRecordsRecord(
414
420
  paths: plan.entries.map((entry) => ({
415
421
  path: entry.path,
416
422
  rewritten: entry.rewritten,
423
+ dropped: entry.dropped,
417
424
  })),
418
425
  }
419
426
  }
@@ -223,7 +223,7 @@ export function register(program: Command): void {
223
223
  records
224
224
  .command('stale')
225
225
  .description(
226
- 'Report when the requirements record was last reviewed and how many releases have shipped since',
226
+ 'Report when the requirements and design records were last reviewed and how many releases have shipped since',
227
227
  )
228
228
  .argument('<kind>', `Record folder: ${STALE_KINDS.join(', ')}`)
229
229
  .helpOption('-h, --help', 'Show this help message')
@@ -233,7 +233,8 @@ export function register(program: Command): void {
233
233
  'after',
234
234
  [
235
235
  '',
236
- 'canonical: reads REQUIREMENTS.md at whichever surface root carries it,',
236
+ 'canonical: reads REQUIREMENTS.md and DESIGN.md at whichever surface root',
237
+ 'carries each, skipping one the project lacks,',
237
238
  'in the current worktree since it is tracked. It reports its reviewed date',
238
239
  'and the release tags merged into HEAD after that day, or never when it',
239
240
  'carries no date.',
@@ -285,7 +285,9 @@ function renderTooling(report: CheckReport): void {
285
285
  if (count > 0) logWarn(`${count} ${category}`)
286
286
  }
287
287
  logInfo(
288
- 'Run `canon tooling sync --check` to see which files, `--write` to apply.',
288
+ tooling.changes === tooling.counts.customized
289
+ ? 'A customized config moved upstream, and no write touches it. Run `canon tooling diff` to see which, and `canon tooling lift <path>` to take it.'
290
+ : 'Run `canon tooling sync --check` to see which files, `--write` to apply.',
289
291
  )
290
292
  }
291
293
 
@@ -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 every golden config that differs from the stack.',
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/home.spec.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) return refuse(opts, prepared.reason, prepared.error)
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
- opts,
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(opts: SyncOptions, reason: string, message: string): number {
669
+ function refuse(
670
+ json: boolean | undefined,
671
+ reason: string,
672
+ message: string,
673
+ ): number {
471
674
  logWarn(message)
472
- if (opts.diff?.json) {
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') logWarn(entry.rel)
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')
@@ -717,6 +1032,10 @@ function summarize(result: ScanResult): string {
717
1032
  result.configs.filter((entry) => entry.state !== 'matching').length,
718
1033
  'configs',
719
1034
  )
1035
+ add(
1036
+ result.dispositions.filter((entry) => entry.moved === true).length,
1037
+ 'moved customized',
1038
+ )
720
1039
  add(result.seeds.filter((entry) => entry.state === 'missing').length, 'seeds')
721
1040
  add(
722
1041
  result.scripts.filter((entry) => entry.state !== 'matching').length,