@erclx/canon 5.63.0 → 5.65.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 (62) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/references/adopted.md +1 -1
  4. package/claude/skills/context-fold/REQUIREMENT.md +2 -3
  5. package/claude/skills/context-fold/SKILL.md +1 -11
  6. package/claude/skills/context-fold/references/completion-report.md +8 -9
  7. package/claude/skills/create-skill/references/skill-practice.md +16 -1
  8. package/claude/skills/design-taste/references/adopted.md +17 -97
  9. package/claude/skills/design-taste/references/tells.md +2 -2
  10. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  11. package/claude/skills/document-health/SKILL.md +3 -14
  12. package/claude/skills/document-health/references/axes.md +14 -16
  13. package/claude/skills/test-craft/references/e2e.md +1 -1
  14. package/docs/agents/audits.md +2 -6
  15. package/docs/agents/context-audit.md +1 -1
  16. package/governance/ledger/skills/api-design/SKILL.md +62 -0
  17. package/governance/ledger/skills/code-craft/SKILL.md +53 -0
  18. package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
  19. package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
  20. package/governance/ledger/skills/codebase-layout/tests.md +26 -0
  21. package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
  22. package/governance/ledger/skills/design-taste/SKILL.md +64 -0
  23. package/governance/ledger/skills/design-taste/craft.md +66 -0
  24. package/governance/ledger/skills/design-taste/kinds.md +58 -0
  25. package/governance/ledger/skills/design-taste/systems.md +42 -0
  26. package/governance/ledger/skills/design-taste/tells.md +103 -0
  27. package/governance/ledger/skills/review-craft/SKILL.md +61 -0
  28. package/governance/ledger/skills/review-craft/security.md +34 -0
  29. package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
  30. package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
  31. package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
  32. package/governance/ledger/skills/test-craft/SKILL.md +67 -0
  33. package/governance/ledger/skills/test-craft/component.md +49 -0
  34. package/governance/ledger/skills/test-craft/e2e.md +55 -0
  35. package/governance/ledger/skills/test-craft/unit.md +54 -0
  36. package/package.json +3 -3
  37. package/src/audits/catalog.ts +0 -52
  38. package/src/claude/skills-audit.ts +124 -41
  39. package/src/cli/retired.ts +8 -0
  40. package/src/commands/claude.ts +11 -2
  41. package/src/commands/context.ts +0 -425
  42. package/src/commands/sandbox.ts +247 -2
  43. package/src/context/citations.ts +9 -3
  44. package/src/gate/measures.ts +4 -1
  45. package/src/gate/stages.ts +25 -0
  46. package/src/git/files.ts +8 -3
  47. package/src/markdown/gated.ts +1 -1
  48. package/src/markdown/scope.ts +0 -1
  49. package/src/migrate/records.ts +6 -0
  50. package/src/sandbox/replay/expected.ts +137 -0
  51. package/src/sandbox/replay/run.ts +323 -0
  52. package/src/sandbox/replay/verdict.ts +102 -0
  53. package/tooling/astro/configs/playwright.config.ts +2 -2
  54. package/tooling/nextjs/configs/playwright.config.ts +2 -2
  55. package/tooling/vite-react/configs/playwright.config.ts +2 -2
  56. package/claude/skills/context-fold/references/classify.md +0 -87
  57. package/src/context/classify/extract.ts +0 -443
  58. package/src/context/classify/ollama.ts +0 -172
  59. package/src/context/classify/patterns.ts +0 -75
  60. package/src/context/classify/prompts.ts +0 -74
  61. package/src/context/classify/run.ts +0 -348
  62. package/src/context/classify/settings.ts +0 -196
@@ -30,26 +30,6 @@ import {
30
30
  type ShapeIssue,
31
31
  } from '@/context/audit'
32
32
  import { auditCitations, type CitationReport } from '@/context/citations'
33
- import {
34
- CANONICAL_DOC_TYPES,
35
- type CanonicalDocType,
36
- } from '@/context/classify/extract'
37
- import {
38
- classifyDiff,
39
- classifySweep,
40
- type ClassifyOutcome,
41
- type ClassifyRefusal,
42
- type DiffFinding,
43
- type DiffRecord,
44
- type SweepFinding,
45
- type SweepRecord,
46
- } from '@/context/classify/run'
47
- import {
48
- CLASSIFIER_CONFIG_REL,
49
- type ClassifierBackend,
50
- resolveClassifier,
51
- writeClassifierConfig,
52
- } from '@/context/classify/settings'
53
33
  import {
54
34
  type AuditedFolder,
55
35
  DEFAULT_FOLDERS,
@@ -159,411 +139,6 @@ export function register(program: Command): void {
159
139
  .action(async (path: string | undefined, opts: AuditCommandOptions) => {
160
140
  process.exitCode = await runAudit(path, opts)
161
141
  })
162
-
163
- const classify = context
164
- .command('classify')
165
- .description(
166
- 'Classify canonical-doc content as keep, replace/rewrite, history, or move',
167
- )
168
- .helpOption('-h, --help', 'Show this help message')
169
-
170
- classify
171
- .command('diff')
172
- .description(
173
- 'Classify the chunks a git range changed, each with the section it landed in',
174
- )
175
- .argument('[path]', 'Project root, defaulting to the current directory')
176
- .helpOption('-h, --help', 'Show this help message')
177
- .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
178
- .option(
179
- '--doc-types <list>',
180
- `Comma-separated canonical doc types (default: all three: ${CANONICAL_DOC_TYPES.join(', ')})`,
181
- )
182
- .option(
183
- '--backend <name>',
184
- 'Override the resolved model backend for this run',
185
- )
186
- .option('--model <name>', 'Override the resolved model name for this run')
187
- .option('--json', 'Add a machine-readable record on stdout')
188
- .addHelpText(
189
- 'after',
190
- [
191
- '',
192
- 'The regex layer always runs. The model layer runs only when a',
193
- 'backend resolves through `canon context classifier show`, and a',
194
- 'configured-but-unreachable backend warns and falls back to regex',
195
- 'rather than failing the run.',
196
- '',
197
- 'Exit codes:',
198
- ' 0 the run completed, whatever the findings say',
199
- ' 1 refused: a bad range or a file this could not read',
200
- '',
201
- 'Examples:',
202
- ' canon context classify diff',
203
- ' canon context classify diff --base origin/main --json',
204
- ' canon context classify diff --doc-types context',
205
- '',
206
- ].join('\n'),
207
- )
208
- .action(async (path: string | undefined, opts: ClassifyDiffOptions) => {
209
- process.exitCode = await runClassifyDiff(path, opts)
210
- })
211
-
212
- classify
213
- .command('sweep')
214
- .description(
215
- 'Classify every section of the three canonical doc types, split at H3',
216
- )
217
- .argument('[path]', 'Project root, defaulting to the current directory')
218
- .helpOption('-h, --help', 'Show this help message')
219
- .option(
220
- '--doc-types <list>',
221
- `Comma-separated canonical doc types (default: all three: ${CANONICAL_DOC_TYPES.join(', ')})`,
222
- )
223
- .option(
224
- '--backend <name>',
225
- 'Override the resolved model backend for this run',
226
- )
227
- .option('--model <name>', 'Override the resolved model name for this run')
228
- .option('--json', 'Add a machine-readable record on stdout')
229
- .addHelpText(
230
- 'after',
231
- [
232
- '',
233
- 'Exit codes:',
234
- ' 0 the run completed, whatever the findings say',
235
- ' 1 refused: a file this could not read',
236
- '',
237
- 'Examples:',
238
- ' canon context classify sweep',
239
- ' canon context classify sweep --doc-types design,requirements --json',
240
- '',
241
- ].join('\n'),
242
- )
243
- .action(async (path: string | undefined, opts: ClassifySweepOptions) => {
244
- process.exitCode = await runClassifySweep(path, opts)
245
- })
246
-
247
- const classifier = context
248
- .command('classifier')
249
- .description('Read or write the project classifier setting')
250
- .helpOption('-h, --help', 'Show this help message')
251
-
252
- classifier
253
- .command('show')
254
- .description(
255
- 'Report the backend and model that would run, and which source decided it',
256
- )
257
- .argument('[path]', 'Project root, defaulting to the current directory')
258
- .helpOption('-h, --help', 'Show this help message')
259
- .option('--json', 'Add a machine-readable record on stdout')
260
- .action((path: string | undefined, opts: { json?: boolean }) => {
261
- runClassifierShow(path, opts)
262
- })
263
-
264
- classifier
265
- .command('set')
266
- .description('Write the project classifier setting')
267
- .argument('[path]', 'Project root, defaulting to the current directory')
268
- .helpOption('-h, --help', 'Show this help message')
269
- .requiredOption('--backend <name>', 'ollama or off')
270
- .option('--model <name>', 'Model name, required when --backend is ollama')
271
- .option('--json', 'Add a machine-readable record on stdout')
272
- .action((path: string | undefined, opts: ClassifierSetOptions) => {
273
- process.exitCode = runClassifierSet(path, opts)
274
- })
275
- }
276
-
277
- interface ClassifyDiffOptions {
278
- readonly json?: boolean
279
- readonly base?: string
280
- readonly docTypes?: string
281
- readonly backend?: string
282
- readonly model?: string
283
- }
284
-
285
- interface ClassifySweepOptions {
286
- readonly json?: boolean
287
- readonly docTypes?: string
288
- readonly backend?: string
289
- readonly model?: string
290
- }
291
-
292
- interface ClassifierSetOptions {
293
- readonly json?: boolean
294
- readonly backend: string
295
- readonly model?: string
296
- }
297
-
298
- function parseDocTypes(
299
- list: string | undefined,
300
- ): readonly CanonicalDocType[] | string {
301
- if (!list) return CANONICAL_DOC_TYPES
302
-
303
- const names = list
304
- .split(',')
305
- .map((name) => name.trim())
306
- .filter(Boolean)
307
-
308
- const invalid = names.filter(
309
- (name) => !CANONICAL_DOC_TYPES.includes(name as CanonicalDocType),
310
- )
311
- if (invalid.length > 0) {
312
- return `--doc-types takes ${CANONICAL_DOC_TYPES.join(', ')}: ${invalid.join(', ')}`
313
- }
314
-
315
- return names as CanonicalDocType[]
316
- }
317
-
318
- /**
319
- * Validates `--backend` up front, so a typo'd value refuses rather than
320
- * falling through `resolveBackend`'s tier chain to the environment, the
321
- * file, or the default, which reads as the flag having been ignored.
322
- */
323
- function parseBackendFlag(value: string | undefined): string | undefined {
324
- if (value === undefined || value === 'ollama' || value === 'off') {
325
- return undefined
326
- }
327
- return `--backend takes ollama or off: ${value}`
328
- }
329
-
330
- /** Widens the extraction refusals with the CLI's own argument-parsing failure. */
331
- type CliRefusal = ClassifyRefusal | 'bad-flags'
332
-
333
- function refuseClassify(
334
- reason: CliRefusal,
335
- message: string,
336
- emitJson: boolean,
337
- ): number {
338
- intro('canon context classify')
339
- logStep('Refused')
340
- logWarn(message)
341
- outro()
342
-
343
- if (emitJson) {
344
- process.stdout.write(
345
- `${JSON.stringify({ decision: 'refused', reason, message })}\n`,
346
- )
347
- }
348
- return 1
349
- }
350
-
351
- /** How each not-off model-layer state reads in the report. */
352
- const MODEL_LAYER_LABEL: Record<string, string> = {
353
- ran: 'ran',
354
- 'skipped-no-model': 'skipped, no model resolved for the configured backend',
355
- 'skipped-unreachable': 'skipped, the configured backend did not answer',
356
- }
357
-
358
- function reportLayers(record: DiffRecord | SweepRecord): void {
359
- logStep('Layers')
360
- logInfo('regex: ran')
361
-
362
- if (record.modelLayer === 'off') {
363
- logInfo('model: off, no backend configured')
364
- return
365
- }
366
-
367
- logInfo(
368
- `model: ${MODEL_LAYER_LABEL[record.modelLayer]} (${record.backend ?? 'none'}${record.model ? `, ${record.model}` : ''})`,
369
- )
370
- if (record.modelLayer !== 'ran') {
371
- logWarn('Falling back to the regex layer alone for this run.')
372
- }
373
- }
374
-
375
- function reportFindings(
376
- findings: readonly (DiffFinding | SweepFinding)[],
377
- label: (finding: DiffFinding | SweepFinding) => string,
378
- ): void {
379
- logStep('Findings')
380
-
381
- if (findings.length === 0) {
382
- logInfo('Nothing met the extraction floor under the requested doc types.')
383
- return
384
- }
385
-
386
- const flagged = findings.filter((finding) => finding.verdict !== 'KEEP')
387
- logInfo(
388
- `${plural(findings.length, 'item')} read, ${plural(flagged.length, 'flagged')}`,
389
- )
390
-
391
- if (flagged.length === 0) return
392
-
393
- pipeOutput(
394
- flagged
395
- .map((finding) => {
396
- const chosen =
397
- finding.decidedBy === 'model' ? finding.model : finding.regex
398
- return `${label(finding)} ${finding.verdict} (${finding.decidedBy})\n ${chosen?.quote ? `"${chosen.quote}" ` : ''}${chosen?.reason ?? ''}`
399
- })
400
- .join('\n'),
401
- )
402
- }
403
-
404
- async function runClassifyDiff(
405
- path: string | undefined,
406
- opts: ClassifyDiffOptions,
407
- ): Promise<number> {
408
- const root = resolve(path ?? process.cwd())
409
- const emitJson = opts.json ?? false
410
-
411
- const docTypes = parseDocTypes(opts.docTypes)
412
- if (typeof docTypes === 'string') {
413
- return refuseClassify('bad-flags', docTypes, emitJson)
414
- }
415
-
416
- const backendError = parseBackendFlag(opts.backend)
417
- if (backendError !== undefined) {
418
- return refuseClassify('bad-flags', backendError, emitJson)
419
- }
420
-
421
- const result: ClassifyOutcome<DiffRecord> = await classifyDiff(
422
- root,
423
- opts.base,
424
- {
425
- docTypes,
426
- flags: { backend: opts.backend, model: opts.model },
427
- },
428
- )
429
-
430
- if (result.kind === 'refused') {
431
- return refuseClassify(result.reason, result.message, emitJson)
432
- }
433
-
434
- intro('canon context classify diff')
435
- reportLayers(result.record)
436
- reportFindings(result.record.findings, (finding) => finding.file)
437
- outro()
438
-
439
- if (emitJson) {
440
- process.stdout.write(
441
- `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
442
- )
443
- }
444
-
445
- return 0
446
- }
447
-
448
- async function runClassifySweep(
449
- path: string | undefined,
450
- opts: ClassifySweepOptions,
451
- ): Promise<number> {
452
- const root = resolve(path ?? process.cwd())
453
- const emitJson = opts.json ?? false
454
-
455
- const docTypes = parseDocTypes(opts.docTypes)
456
- if (typeof docTypes === 'string') {
457
- return refuseClassify('bad-flags', docTypes, emitJson)
458
- }
459
-
460
- const backendError = parseBackendFlag(opts.backend)
461
- if (backendError !== undefined) {
462
- return refuseClassify('bad-flags', backendError, emitJson)
463
- }
464
-
465
- const result: ClassifyOutcome<SweepRecord> = await classifySweep(root, {
466
- docTypes,
467
- flags: { backend: opts.backend, model: opts.model },
468
- })
469
-
470
- if (result.kind === 'refused') {
471
- return refuseClassify(result.reason, result.message, emitJson)
472
- }
473
-
474
- intro('canon context classify sweep')
475
- reportLayers(result.record)
476
- reportFindings(
477
- result.record.findings,
478
- (finding) => `${finding.file}:${(finding as SweepFinding).heading}`,
479
- )
480
- outro()
481
-
482
- if (emitJson) {
483
- process.stdout.write(
484
- `${JSON.stringify({ decision: 'ok', ...result.record })}\n`,
485
- )
486
- }
487
-
488
- return 0
489
- }
490
-
491
- function runClassifierShow(
492
- path: string | undefined,
493
- opts: { json?: boolean },
494
- ): void {
495
- const root = resolve(path ?? process.cwd())
496
- const resolution = resolveClassifier(root, {})
497
-
498
- intro('canon context classifier show')
499
-
500
- if (resolution.kind === 'off') {
501
- logInfo(
502
- `Model layer off (source: ${resolution.source}). Regex layer always runs.`,
503
- )
504
- } else if (resolution.kind === 'no-model') {
505
- logWarn(
506
- `Backend ${resolution.backend} configured (source: ${resolution.source}) with no model name. \`classify\` runs regex only.`,
507
- )
508
- } else {
509
- logInfo(
510
- `Backend ${resolution.backend}, model ${resolution.model} (source: ${resolution.source}).`,
511
- )
512
- }
513
-
514
- logInfo(
515
- `Reads ${CLASSIFIER_CONFIG_REL} when neither a flag nor an environment variable decides.`,
516
- )
517
- outro()
518
-
519
- if (opts.json) {
520
- process.stdout.write(`${JSON.stringify(resolution)}\n`)
521
- }
522
- }
523
-
524
- function runClassifierSet(
525
- path: string | undefined,
526
- opts: ClassifierSetOptions,
527
- ): number {
528
- const root = resolve(path ?? process.cwd())
529
- const emitJson = opts.json ?? false
530
-
531
- if (opts.backend !== 'ollama' && opts.backend !== 'off') {
532
- return refuseClassify(
533
- 'bad-flags',
534
- '--backend takes ollama or off.',
535
- emitJson,
536
- )
537
- }
538
-
539
- if (opts.backend === 'ollama' && !opts.model) {
540
- return refuseClassify(
541
- 'bad-flags',
542
- '--model is required when --backend is ollama.',
543
- emitJson,
544
- )
545
- }
546
-
547
- const backend: ClassifierBackend | 'off' = opts.backend
548
- writeClassifierConfig(
549
- root,
550
- backend,
551
- backend === 'off' ? undefined : opts.model,
552
- )
553
-
554
- intro('canon context classifier set')
555
- logInfo(
556
- `Wrote ${CLASSIFIER_CONFIG_REL}: backend ${opts.backend}${opts.model ? `, model ${opts.model}` : ''}.`,
557
- )
558
- outro()
559
-
560
- if (emitJson) {
561
- process.stdout.write(
562
- `${JSON.stringify({ decision: 'ok', backend: opts.backend, model: opts.model })}\n`,
563
- )
564
- }
565
-
566
- return 0
567
142
  }
568
143
 
569
144
  function parseFolders(list: string | undefined): string[] | string {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { basename, dirname, join, relative, resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
5
5
  import {
@@ -27,8 +27,24 @@ import {
27
27
  } from '@/sandbox/expect'
28
28
  import { runHeadless } from '@/sandbox/headless/run'
29
29
  import { runSandbox } from '@/sandbox/provision'
30
+ import {
31
+ EXPECTED_FILE,
32
+ expectationOf,
33
+ judgeDrift,
34
+ readExpectation,
35
+ writeExpectation,
36
+ type ReplayExpectation,
37
+ } from '@/sandbox/replay/expected'
38
+ import {
39
+ checkoutRefusal,
40
+ hasFormatter,
41
+ listSnapshots,
42
+ replaySource,
43
+ type ReplayResult,
44
+ type ReplaySource,
45
+ } from '@/sandbox/replay/run'
30
46
  import { listCategories, listScenarioFiles } from '@/sandbox/scenario'
31
- import { sandboxTree } from '@/sandbox/tree'
47
+ import { assertSandboxDirSafe, sandboxRoots, sandboxTree } from '@/sandbox/tree'
32
48
  import {
33
49
  frameError,
34
50
  intro,
@@ -434,6 +450,194 @@ async function runEquivalence(
434
450
  process.exitCode = equivalenceExitCode(record)
435
451
  }
436
452
 
453
+ interface ReplayOptions {
454
+ readonly json?: boolean
455
+ readonly record?: boolean
456
+ }
457
+
458
+ const REPLAY_FINDINGS_SHOWN = 5
459
+
460
+ function refuseReplay(reason: string, message: string, json: boolean): void {
461
+ logError(message)
462
+ if (json)
463
+ process.stdout.write(`${JSON.stringify({ ok: false, reason, message })}\n`)
464
+ outro()
465
+ process.exitCode = 1
466
+ }
467
+
468
+ /**
469
+ * Replays each consumer through a sync with this checkout's CLI and judges
470
+ * the reads that follow. Bare, it replays the frozen snapshots, which is what
471
+ * the gate runs. Given paths, it copies each real checkout, the operator's
472
+ * step before a release. Every source is checked before any replay starts, so
473
+ * a refusal never arrives after half the runs. A snapshot is judged against
474
+ * the answer recorded beside it, since a frozen tree answers the same on every
475
+ * push, and `--record` rewrites that answer from a run that broke nothing.
476
+ */
477
+ function runReplay(paths: string[], options: ReplayOptions): void {
478
+ if (reportAbsentScenarioTree()) return
479
+
480
+ intro('canon sandbox replay')
481
+ const json = options.json === true
482
+ const record = options.record === true
483
+
484
+ if (record && paths.length > 0) {
485
+ refuseReplay(
486
+ 'record-checkout',
487
+ 'A named checkout moves between runs, so --record pins snapshots only. Drop the paths.',
488
+ json,
489
+ )
490
+ return
491
+ }
492
+
493
+ if (!hasFormatter(PROJECT_ROOT)) {
494
+ refuseReplay(
495
+ 'no-formatter',
496
+ 'No oxfmt under node_modules, so the format step cannot run. Run bun install in this checkout.',
497
+ json,
498
+ )
499
+ return
500
+ }
501
+
502
+ let sources: ReplaySource[]
503
+ if (paths.length === 0) {
504
+ sources = listSnapshots(join(SANDBOX_DIR, 'fixtures', 'replay'))
505
+ if (sources.length === 0) {
506
+ refuseReplay(
507
+ 'no-snapshots',
508
+ 'No snapshot under sandbox/fixtures/replay/',
509
+ json,
510
+ )
511
+ return
512
+ }
513
+ } else {
514
+ sources = []
515
+ for (const raw of paths) {
516
+ const path = resolve(process.cwd(), raw)
517
+ const refusal = checkoutRefusal(path)
518
+ if (refusal !== undefined) {
519
+ refuseReplay(refusal.reason, refusal.message, json)
520
+ return
521
+ }
522
+ sources.push({ name: basename(path), path, kind: 'checkout' })
523
+ }
524
+ }
525
+
526
+ // Beside the provisioned tree rather than inside it, since `canon sandbox
527
+ // clean` wipes that one and a concurrent replay would lose its tree.
528
+ const tempRoot = join(dirname(sandboxTree()), 'replay')
529
+ const unsafe = assertSandboxDirSafe(
530
+ join(tempRoot, 'tree'),
531
+ sandboxRoots(PROJECT_ROOT),
532
+ )
533
+ if (unsafe !== undefined) {
534
+ refuseReplay('unsafe-root', unsafe, json)
535
+ return
536
+ }
537
+
538
+ const expectations = new Map<string, ReplayExpectation | undefined>()
539
+ if (!record)
540
+ for (const source of sources) {
541
+ if (source.kind !== 'snapshot') continue
542
+ try {
543
+ expectations.set(source.name, readExpectation(source.path))
544
+ } catch (error) {
545
+ refuseReplay('bad-expectation', (error as Error).message, json)
546
+ return
547
+ }
548
+ }
549
+
550
+ const replays: ReplayResult[] = []
551
+ for (const source of sources) {
552
+ const run = replaySource(source, {
553
+ checkoutRoot: PROJECT_ROOT,
554
+ tempRoot,
555
+ })
556
+ const result: ReplayResult =
557
+ source.kind === 'snapshot' && !record
558
+ ? {
559
+ ...run,
560
+ steps: judgeDrift(run.steps, expectations.get(source.name)),
561
+ }
562
+ : run
563
+ replays.push(result)
564
+
565
+ logStep(`${result.name} (${result.kind})`)
566
+ for (const step of result.steps) {
567
+ if (step.verdict === 'ok') logInfo(`${step.step}, exit ${step.exit}`)
568
+ else
569
+ logError(`${result.name}: ${step.step} ${step.verdict}: ${step.detail}`)
570
+ // A sync over an old layout prints a line per rule it moves, so the
571
+ // frame shows the first few and the record carries every one.
572
+ if (json) continue
573
+ for (const finding of step.findings.slice(0, REPLAY_FINDINGS_SHOWN))
574
+ logWarn(`${step.step}: ${finding}`)
575
+ const hidden = step.findings.length - REPLAY_FINDINGS_SHOWN
576
+ if (hidden > 0)
577
+ logWarn(`${step.step}: ${hidden} more, listed under --json`)
578
+ }
579
+ }
580
+
581
+ const failed = replays
582
+ .flatMap((r) => r.steps)
583
+ .filter((s) => s.verdict !== 'ok')
584
+
585
+ if (record) {
586
+ recordReplays(sources, replays, failed.length, json)
587
+ return
588
+ }
589
+
590
+ logStep('Replay')
591
+ const summary = `${replays.length} replayed, ${failed.length} failed steps`
592
+ if (failed.length === 0) logInfo(summary)
593
+ else logWarn(summary)
594
+
595
+ if (json)
596
+ process.stdout.write(
597
+ `${JSON.stringify({ ok: true, failed: failed.length, replays })}\n`,
598
+ )
599
+
600
+ outro()
601
+ process.exitCode = failed.length === 0 ? 0 : 2
602
+ }
603
+
604
+ /**
605
+ * Writes each snapshot's expectation from its run, or refuses and writes none
606
+ * when any step broke, since pinning a crash would make it the expected answer.
607
+ */
608
+ function recordReplays(
609
+ sources: readonly ReplaySource[],
610
+ replays: readonly ReplayResult[],
611
+ broken: number,
612
+ json: boolean,
613
+ ): void {
614
+ if (broken > 0) {
615
+ refuseReplay(
616
+ 'broken-run',
617
+ `${broken} steps broke, so nothing was recorded. Fix the run, then record it.`,
618
+ json,
619
+ )
620
+ return
621
+ }
622
+
623
+ logStep('Record')
624
+ const recorded: string[] = []
625
+ for (const [i, source] of sources.entries()) {
626
+ const replay = replays[i]
627
+ if (replay === undefined) continue
628
+ writeExpectation(source.path, expectationOf(replay.steps))
629
+ const path = relative(PROJECT_ROOT, join(source.path, EXPECTED_FILE))
630
+ recorded.push(path)
631
+ logInfo(path)
632
+ }
633
+
634
+ if (json)
635
+ process.stdout.write(`${JSON.stringify({ ok: true, recorded, replays })}\n`)
636
+
637
+ outro()
638
+ process.exitCode = 0
639
+ }
640
+
437
641
  function runCheck(
438
642
  target: string,
439
643
  arm: string | undefined,
@@ -683,4 +887,45 @@ export function register(program: Command): void {
683
887
  .action((targets: string[], options: EquivalenceOptions) =>
684
888
  runEquivalence(targets, options),
685
889
  )
890
+
891
+ sandbox
892
+ .command('replay')
893
+ .description(
894
+ 'Sync a copy of each consumer with this checkout and judge the reads that follow',
895
+ )
896
+ .argument(
897
+ '[paths...]',
898
+ 'Consumer checkouts to copy, defaulting to every snapshot under sandbox/fixtures/replay/',
899
+ )
900
+ .helpOption('-h, --help', 'Show this help message')
901
+ .option('--json', 'Emit the record as JSON on stdout')
902
+ .option(
903
+ '--record',
904
+ `Write each snapshot's ${EXPECTED_FILE} from this run instead of judging against it`,
905
+ )
906
+ .addHelpText(
907
+ 'after',
908
+ [
909
+ '',
910
+ 'Examples:',
911
+ ' canon sandbox replay',
912
+ ' canon sandbox replay --record',
913
+ ' canon sandbox replay ../frameboard ../jobtriage --json',
914
+ '',
915
+ 'Each replay copies the source to a temp tree outside the repository,',
916
+ 'runs sync, then sync --check, context audit, markdown audit, and',
917
+ 'migrate status, and checks what sync wrote with oxfmt. A finding or a',
918
+ 'refusal from a read is the verb working. Only a crash, stdout a --json',
919
+ 'caller cannot parse, or an unformatted write fails the run.',
920
+ 'A snapshot also fails a step whose exit or finding count drifted from',
921
+ `the ${EXPECTED_FILE} beside it, and fails every step when it has none.`,
922
+ '--record rewrites those files, refusing named paths and a broken run.',
923
+ 'A named checkout with uncommitted changes is refused, never synced over.',
924
+ '',
925
+ 'Exit codes: 0 clean or recorded, 1 refused, 2 when any step failed.',
926
+ ].join('\n'),
927
+ )
928
+ .action((paths: string[], options: ReplayOptions) => {
929
+ runReplay(paths, options)
930
+ })
686
931
  }