@erclx/canon 5.14.0 → 5.15.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 (79) hide show
  1. package/README.md +3 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/draft-screencast/REQUIREMENT.md +2 -1
  4. package/claude/skills/draft-screencast/SKILL.md +14 -9
  5. package/claude/skills/read-frames/SKILL.md +3 -2
  6. package/claude/skills/record-screencast/REQUIREMENT.md +3 -0
  7. package/claude/skills/record-screencast/SKILL.md +28 -5
  8. package/claude/skills/review-pr/SKILL.md +1 -1
  9. package/claude/skills/review-pr/references/close-out.md +1 -1
  10. package/claude/skills/review-pr/references/post.md +1 -1
  11. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  12. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  13. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  14. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  15. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  16. package/docs/agents/commands.md +3 -0
  17. package/docs/agents/demo.md +34 -7
  18. package/docs/agents/migrate.md +1 -1
  19. package/docs/agents/pr-reads.md +1 -1
  20. package/docs/agents/sandbox.md +28 -3
  21. package/docs/agents/state-scoped-risk.md +2 -2
  22. package/docs/target/migrations.md +1 -1
  23. package/docs/workflow/ai-workflow.md +2 -3
  24. package/docs/workflow/skill-map.md +1 -1
  25. package/governance/renames.toml +7 -5
  26. package/governance/rules/canon/630-requirements.md +1 -1
  27. package/package.json +8 -6
  28. package/scripts/core/check-color-source.sh +11 -1
  29. package/scripts/core/install-check.sh +13 -4
  30. package/scripts/lib/sandbox-dispatch.sh +1 -1
  31. package/scripts/lib/sandbox-fixtures.sh +2 -2
  32. package/scripts/lib/sandbox-path.sh +1 -1
  33. package/scripts/manage-sandbox.sh +2 -2
  34. package/src/canvas/client/app.tsx +85 -0
  35. package/src/canvas/client/main.tsx +62 -0
  36. package/src/canvas/client/pages-panel.tsx +123 -0
  37. package/src/canvas/client/shell.css +319 -0
  38. package/src/canvas/client/state.ts +186 -0
  39. package/src/canvas/client/surface.tsx +213 -0
  40. package/src/canvas/content.ts +326 -0
  41. package/src/canvas/index.html +14 -0
  42. package/src/canvas/server.ts +302 -0
  43. package/src/canvas/tokens.ts +71 -0
  44. package/src/claude/seeds.ts +0 -1
  45. package/src/cli.ts +2 -0
  46. package/src/commands/canvas.ts +325 -0
  47. package/src/commands/claude.ts +9 -14
  48. package/src/commands/demo.ts +6 -6
  49. package/src/commands/sandbox.ts +110 -2
  50. package/src/context/citations.ts +1 -1
  51. package/src/context/classify/prompts.ts +1 -1
  52. package/src/context/folders.ts +0 -1
  53. package/src/demo/compile.ts +8 -4
  54. package/src/demo/container.ts +1 -1
  55. package/src/gov/renames.ts +3 -2
  56. package/src/help.ts +1 -0
  57. package/src/record-root.ts +1 -0
  58. package/src/sandbox/census.ts +1 -1
  59. package/src/sandbox/coverage.ts +1 -1
  60. package/src/sandbox/equivalence.ts +847 -0
  61. package/src/sandbox/expect.ts +0 -1
  62. package/src/serve/static.ts +25 -11
  63. package/src/shipped/references.ts +8 -10
  64. package/src/surface-root.ts +0 -1
  65. package/src/targets/pulls.ts +2 -2
  66. package/standards/architecture.md +1 -1
  67. package/standards/context.md +1 -1
  68. package/standards/design.md +1 -1
  69. package/standards/index.md +0 -1
  70. package/standards/requirements.md +4 -6
  71. package/standards/wireframes.md +2 -2
  72. package/tooling/claude/reference.md +0 -1
  73. package/tooling/claude/seeds/canon/REQUIREMENTS.md +0 -2
  74. package/tsconfig.json +1 -1
  75. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  76. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  77. package/governance/rules/canon/645-decisions.md +0 -12
  78. package/standards/decisions.md +0 -105
  79. package/tooling/claude/seeds/canon/decisions/index.md +0 -8
@@ -20,7 +20,6 @@ const HOOKS = 'hooks'
20
20
  export const SUBDIRS: readonly string[] = [
21
21
  HOOKS,
22
22
  'context',
23
- 'decisions',
24
23
  'diagrams',
25
24
  'memory',
26
25
  'tasks',
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ import { register as transcripts } from '@/commands/transcripts'
23
23
  import { register as tasks } from '@/commands/tasks'
24
24
  import { register as intake } from '@/commands/intake'
25
25
  import { register as teach } from '@/commands/teach'
26
+ import { register as canvas } from '@/commands/canvas'
26
27
  import { register as comments } from '@/commands/comments'
27
28
  import { register as context } from '@/commands/context'
28
29
  import { register as markdown } from '@/commands/markdown'
@@ -84,6 +85,7 @@ transcripts(program)
84
85
  tasks(program)
85
86
  intake(program)
86
87
  teach(program)
88
+ canvas(program)
87
89
  comments(program)
88
90
  context(program)
89
91
  markdown(program)
@@ -0,0 +1,325 @@
1
+ import { statSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import {
5
+ addFrame,
6
+ addPage,
7
+ type ContentRefused,
8
+ canvasDir,
9
+ DEFAULT_FRAME,
10
+ listPages,
11
+ renamePage,
12
+ } from '@/canvas/content'
13
+ import { CANVAS_PORT, startCanvas } from '@/canvas/server'
14
+ import { resolveFrameTokens } from '@/canvas/tokens'
15
+ import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
16
+ import {
17
+ intro,
18
+ logAdd,
19
+ logError,
20
+ logInfo,
21
+ logStep,
22
+ logWarn,
23
+ outro,
24
+ plural,
25
+ } from '@/ui'
26
+ import { mainWorktreeRoot } from '@/worktree'
27
+
28
+ const BANNER = 'canon canvas'
29
+
30
+ interface RootOptions {
31
+ readonly root?: string
32
+ readonly json?: boolean
33
+ }
34
+
35
+ interface Refused {
36
+ readonly ok: false
37
+ readonly reason: ContentRefused['reason'] | 'no-root'
38
+ readonly detail: string
39
+ }
40
+
41
+ type RootOutcome = { readonly ok: true; readonly root: string } | Refused
42
+
43
+ /**
44
+ * Refuses a root that is not a directory rather than letting the first write
45
+ * scaffold `.canon/canvas/` under a mistyped path, the way `canon serve`
46
+ * refuses a directory it cannot find.
47
+ */
48
+ async function resolveRoot(opts: RootOptions): Promise<RootOutcome> {
49
+ const root = opts.root
50
+ ? resolve(process.cwd(), opts.root)
51
+ : await mainWorktreeRoot()
52
+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
53
+ return {
54
+ ok: false,
55
+ reason: 'no-root',
56
+ detail: `${root} is not a directory`,
57
+ }
58
+ }
59
+ return { ok: true, root }
60
+ }
61
+
62
+ function writeJson(record: unknown): void {
63
+ process.stdout.write(`${JSON.stringify(record)}\n`)
64
+ }
65
+
66
+ /** Every verb's refusal, framed on stderr and mirrored on stdout under --json. */
67
+ function refuse(refusal: Refused, emitJson: boolean): number {
68
+ if (emitJson) writeJson(refusal)
69
+ intro(BANNER)
70
+ logError(refusal.detail)
71
+ outro()
72
+ return 1
73
+ }
74
+
75
+ /**
76
+ * Parses a size flag. NaN for a value that is not a positive integer, which
77
+ * the content module then refuses as an invalid size.
78
+ */
79
+ function parseSize(raw: string | undefined, fallback: number): number {
80
+ if (raw === undefined) return fallback
81
+ const value = Number(raw)
82
+ return Number.isInteger(value) && value > 0 ? value : Number.NaN
83
+ }
84
+
85
+ function withRoot(command: Command): Command {
86
+ return command
87
+ .helpOption('-h, --help', 'Show this help message')
88
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
89
+ .option('--json', 'Emit a machine-readable record on stdout')
90
+ }
91
+
92
+ export function register(program: Command): void {
93
+ const canvas = program
94
+ .command('canvas')
95
+ .helpOption('-h, --help', 'Show this help message')
96
+ .description(
97
+ 'Local canvas of pages and HTML frames (serve, list, page, frame)',
98
+ )
99
+ .addHelpText(
100
+ 'after',
101
+ [
102
+ '',
103
+ 'A page is a folder under .canon/canvas/, a frame is one HTML file in',
104
+ 'it, and layout.json beside them holds each frame box. Edit a frame by',
105
+ 'editing its file, and an open canvas reloads that frame.',
106
+ '',
107
+ 'Examples:',
108
+ ' canon canvas serve',
109
+ ' canon canvas page add drafts',
110
+ ' canon canvas frame add drafts hero --width 1440 --height 900',
111
+ ' canon canvas list --json',
112
+ '',
113
+ ].join('\n'),
114
+ )
115
+
116
+ withRoot(
117
+ canvas
118
+ .command('serve')
119
+ .description('Serve the canvas on localhost and print the link')
120
+ .option('--port <number>', `Port to try first, default ${CANVAS_PORT}`),
121
+ )
122
+ .addHelpText(
123
+ 'after',
124
+ [
125
+ '',
126
+ 'Exit codes:',
127
+ ' 0 the server stopped after running',
128
+ ' 1 refused, with the reason on stderr or in the JSON record',
129
+ '',
130
+ 'The server binds 127.0.0.1 and nothing else. It runs until',
131
+ 'interrupted, so a session wanting the link starts it in the',
132
+ 'background and reads the record off stdout.',
133
+ '',
134
+ ].join('\n'),
135
+ )
136
+ .action(async (opts: RootOptions & { port?: string }) => {
137
+ process.exitCode = await runServe(opts)
138
+ })
139
+
140
+ withRoot(
141
+ canvas.command('list').description('List pages and the frames on each'),
142
+ ).action(async (opts: RootOptions) => {
143
+ const resolved = await resolveRoot(opts)
144
+ if (!resolved.ok) {
145
+ process.exitCode = refuse(resolved, opts.json ?? false)
146
+ return
147
+ }
148
+ const { root } = resolved
149
+ const pages = listPages(root)
150
+ if (opts.json) {
151
+ writeJson({ ok: true, root, content: canvasDir(root), pages })
152
+ }
153
+ intro(BANNER)
154
+ if (pages.length === 0) {
155
+ logStep('No pages')
156
+ logInfo('canon canvas page add <name> adds one')
157
+ }
158
+ for (const page of pages) {
159
+ logStep(`${page.name} (${plural(page.frames.length, 'frame')})`)
160
+ for (const frame of page.frames) {
161
+ logInfo(`${frame.name} ${frame.width} × ${frame.height}`)
162
+ }
163
+ if (page.layoutIssue === 'malformed') {
164
+ logWarn('layout.json does not parse, so frames sit in a default row')
165
+ }
166
+ }
167
+ outro()
168
+ })
169
+
170
+ const page = canvas
171
+ .command('page')
172
+ .helpOption('-h, --help', 'Show this help message')
173
+ .description('Add or rename a page')
174
+
175
+ withRoot(
176
+ page
177
+ .command('add')
178
+ .description('Add a page')
179
+ .argument('<name>', 'Page name, one path segment'),
180
+ ).action(async (name: string, opts: RootOptions) => {
181
+ const resolved = await resolveRoot(opts)
182
+ const outcome = resolved.ok ? addPage(resolved.root, name) : resolved
183
+ if (!outcome.ok) {
184
+ process.exitCode = refuse(outcome, opts.json ?? false)
185
+ return
186
+ }
187
+ if (opts.json) writeJson(outcome)
188
+ intro(BANNER)
189
+ logAdd(`page ${outcome.page}`)
190
+ outro()
191
+ })
192
+
193
+ withRoot(
194
+ page
195
+ .command('rename')
196
+ .description('Rename a page, keeping its frames and layout')
197
+ .argument('<from>', 'Current page name')
198
+ .argument('<to>', 'New page name'),
199
+ ).action(async (from: string, to: string, opts: RootOptions) => {
200
+ const resolved = await resolveRoot(opts)
201
+ const outcome = resolved.ok ? renamePage(resolved.root, from, to) : resolved
202
+ if (!outcome.ok) {
203
+ process.exitCode = refuse(outcome, opts.json ?? false)
204
+ return
205
+ }
206
+ if (opts.json) writeJson({ ...outcome, from })
207
+ intro(BANNER)
208
+ logAdd(`page ${from} → ${outcome.page}`)
209
+ outro()
210
+ })
211
+
212
+ const frame = canvas
213
+ .command('frame')
214
+ .helpOption('-h, --help', 'Show this help message')
215
+ .description('Add a frame to a page')
216
+
217
+ withRoot(
218
+ frame
219
+ .command('add')
220
+ .description('Add a frame as a new HTML file, placed after the others')
221
+ .argument('<page>', 'Page to add the frame to')
222
+ .argument('<name>', 'Frame name, which becomes <name>.html')
223
+ .option('--width <px>', `Frame width, default ${DEFAULT_FRAME.width}`)
224
+ .option('--height <px>', `Frame height, default ${DEFAULT_FRAME.height}`),
225
+ ).action(
226
+ async (
227
+ pageName: string,
228
+ name: string,
229
+ opts: RootOptions & { width?: string; height?: string },
230
+ ) => {
231
+ const resolved = await resolveRoot(opts)
232
+ if (!resolved.ok) {
233
+ process.exitCode = refuse(resolved, opts.json ?? false)
234
+ return
235
+ }
236
+ const { root } = resolved
237
+ const outcome = addFrame(root, pageName, name, {
238
+ width: parseSize(opts.width, DEFAULT_FRAME.width),
239
+ height: parseSize(opts.height, DEFAULT_FRAME.height),
240
+ })
241
+ if (!outcome.ok) {
242
+ process.exitCode = refuse(outcome, opts.json ?? false)
243
+ return
244
+ }
245
+ if (opts.json) {
246
+ writeJson({
247
+ ...outcome,
248
+ path: resolve(canvasDir(root), outcome.page, outcome.file),
249
+ })
250
+ }
251
+ intro(BANNER)
252
+ logAdd(`${outcome.page}/${outcome.file}`)
253
+ logInfo(
254
+ `${outcome.box.width} × ${outcome.box.height} at ${outcome.box.x}, ${outcome.box.y}`,
255
+ )
256
+ outro()
257
+ },
258
+ )
259
+ }
260
+
261
+ async function runServe(
262
+ opts: RootOptions & { port?: string },
263
+ ): Promise<number> {
264
+ const emitJson = opts.json ?? false
265
+ const port = opts.port === undefined ? CANVAS_PORT : parsePort(opts.port)
266
+ if (port === undefined) {
267
+ return refuseServe('no-port', `${opts.port} is not a port`, emitJson)
268
+ }
269
+
270
+ const resolved = await resolveRoot(opts)
271
+ if (!resolved.ok) {
272
+ return refuseServe(resolved.reason, resolved.detail, emitJson)
273
+ }
274
+ const { root } = resolved
275
+ /*
276
+ * Imported here rather than at the top, so a command module the test runner
277
+ * loads never asks it for an HTML module it cannot parse.
278
+ */
279
+ const { default: shell } = await import('@/canvas/index.html')
280
+ const outcome = startCanvas(root, { port, shell })
281
+ if (!outcome.ok) {
282
+ return refuseServe(outcome.reason, outcome.detail, emitJson)
283
+ }
284
+
285
+ const tokens = resolveFrameTokens(root)
286
+ const { css: _css, ...tokenSource } = tokens
287
+ if (emitJson) {
288
+ writeJson({
289
+ ok: true,
290
+ root: outcome.root,
291
+ content: outcome.content,
292
+ host: outcome.host,
293
+ port: outcome.port,
294
+ url: outcome.url,
295
+ tokens: tokenSource,
296
+ })
297
+ }
298
+ intro(BANNER)
299
+ logStep('Serving')
300
+ logInfo(displayPath(outcome.content))
301
+ logStep('Open')
302
+ logInfo(outcome.url)
303
+ logStep('Tokens')
304
+ if (tokens.source === 'none') logWarn(tokens.notice)
305
+ else if (tokens.source === 'installed') {
306
+ for (const file of tokens.files) logInfo(file)
307
+ } else logInfo('this toolkit’s own token module')
308
+ logStep('Stop')
309
+ logInfo('Ctrl-C')
310
+
311
+ await waitForInterrupt(outcome.stop)
312
+ return 0
313
+ }
314
+
315
+ function refuseServe(
316
+ reason: string,
317
+ detail: string,
318
+ emitJson: boolean,
319
+ ): number {
320
+ if (emitJson) writeJson({ ok: false, reason, detail })
321
+ intro(BANNER)
322
+ logError(detail)
323
+ outro()
324
+ return 1
325
+ }
@@ -153,21 +153,16 @@ const SEEDED_FILES: readonly string[] = [
153
153
  'REQUIREMENTS.md',
154
154
  'DESIGN.md',
155
155
  ]
156
- const SEEDED_DIRS: readonly string[] = [
157
- 'decisions',
158
- 'memory',
159
- 'tasks',
160
- 'wireframes',
161
- ]
156
+ const SEEDED_DIRS: readonly string[] = ['memory', 'tasks', 'wireframes']
162
157
 
163
158
  /**
164
159
  * Where a `SEEDED_DIRS` entry resolves for the sync presence check.
165
160
  *
166
- * `wireframes` and `decisions` are tracked surface entries, resolved under
167
- * `canon/` ahead of `.claude/`, while `memory` and `tasks` are session records
168
- * resolved under `.canon/` ahead of `.claude/`. Routing every name through
169
- * `recordDir` reported a migrated target's `canon/wireframes/` or
170
- * `canon/decisions/` as missing, since that resolver never checks `canon/`.
161
+ * `wireframes` is a tracked surface entry, resolved under `canon/` ahead of
162
+ * `.claude/`, while `memory` and `tasks` are session records resolved under
163
+ * `.canon/` ahead of `.claude/`. Routing every name through `recordDir`
164
+ * reported a migrated target's `canon/wireframes/` as missing, since that
165
+ * resolver never checks `canon/`.
171
166
  */
172
167
  export function seededDirPath(resolved: string, name: string): string {
173
168
  return SURFACE_ENTRIES.includes(name)
@@ -605,9 +600,9 @@ async function runSync(target: string): Promise<number> {
605
600
 
606
601
  // A record folder resolves at either root, so a migrated target is reported as
607
602
  // seeded rather than sent to `canon claude init` to re-create records it
608
- // already holds. `wireframes` and `decisions` are tracked surface entries
609
- // instead, resolved through `surfaceDir`, since a migrated target holds them
610
- // at `canon/` rather than under either record root.
603
+ // already holds. `wireframes` is a tracked surface entry instead, resolved
604
+ // through `surfaceDir`, since a migrated target holds it at `canon/` rather
605
+ // than under either record root.
611
606
  logStep('Seeded')
612
607
  for (const name of SEEDED_FILES) {
613
608
  if (existsSync(join(resolved, '.claude', name))) logInfo(name)
@@ -73,7 +73,7 @@ export function register(program: Command): void {
73
73
  ' 1 refused, with the reason on stderr',
74
74
  '',
75
75
  'Examples:',
76
- ' canon demo compile .canon/tmp/screencast/inline-edit.md',
76
+ ' canon demo compile demos/inline-edit/beats.md --slug inline-edit',
77
77
  ' canon demo compile draft.md --out demos --force',
78
78
  '',
79
79
  ].join('\n'),
@@ -114,8 +114,8 @@ export function register(program: Command): void {
114
114
  ' 1 refused, with the reason on stderr',
115
115
  '',
116
116
  'Examples:',
117
- ' canon demo run demos/inline-edit.json',
118
- ' canon demo run demos/inline-edit.json --cursor ~/cursors/theme',
117
+ ' canon demo run demos/inline-edit/plan.json',
118
+ ' canon demo run demos/inline-edit/plan.json --cursor ~/cursors/theme',
119
119
  '',
120
120
  ].join('\n'),
121
121
  )
@@ -146,8 +146,8 @@ export function register(program: Command): void {
146
146
  ' 1 refused, with the reason on stderr',
147
147
  '',
148
148
  'Examples:',
149
- ' canon demo frames demos/inline-edit.webm',
150
- ' canon demo frames demos/inline-edit.webm --fps 2 --out frames',
149
+ ' canon demo frames demos/inline-edit/take/inline-edit.webm',
150
+ ' canon demo frames demos/inline-edit/take/inline-edit.webm --fps 2 --out frames',
151
151
  '',
152
152
  ].join('\n'),
153
153
  )
@@ -182,7 +182,7 @@ function runCompile(draftPath: string, opts: CompileOptions): number {
182
182
  }
183
183
 
184
184
  const slug = opts.slug ?? basename(source, extname(source))
185
- const target = resolve(process.cwd(), opts.out, `${slug}.json`)
185
+ const target = resolve(process.cwd(), opts.out, slug, 'plan.json')
186
186
 
187
187
  if (existsSync(target) && !opts.force) {
188
188
  logStep('Plan')
@@ -13,6 +13,11 @@ import {
13
13
  coveragePercent,
14
14
  type CoverageReport,
15
15
  } from '@/sandbox/coverage'
16
+ import {
17
+ equivalenceExitCode,
18
+ runEquivalence as collectEquivalence,
19
+ type EquivalenceRecord,
20
+ } from '@/sandbox/equivalence'
16
21
  import {
17
22
  expectFilePath,
18
23
  parseTarget,
@@ -34,13 +39,13 @@ import {
34
39
  select,
35
40
  } from '@/ui'
36
41
 
37
- const SANDBOX_DIR = join(PROJECT_ROOT, 'scripts', 'sandbox')
42
+ const SANDBOX_DIR = join(PROJECT_ROOT, 'sandbox')
38
43
 
39
44
  /**
40
45
  * Reports that the scenario tree does not ship, and answers whether it reported,
41
46
  * so a caller that sees `true` returns without touching the tree.
42
47
  *
43
- * `scripts/sandbox` is excluded from the published package, so an installed
48
+ * `sandbox` sits outside the published package, so an installed
44
49
  * `canon` resolves `SANDBOX_DIR` to a directory that is not there. Both entry
45
50
  * points that walk the tree ask here rather than carrying a check each, because
46
51
  * absence is a property of the install rather than of a verb, and a second copy
@@ -338,6 +343,71 @@ function runCoverage(options: CoverageOptions): void {
338
343
  process.exitCode = wrongExemption || rolloutIncomplete ? 1 : 0
339
344
  }
340
345
 
346
+ interface EquivalenceOptions {
347
+ readonly base: string
348
+ readonly includeAnchor?: boolean
349
+ readonly out?: string
350
+ readonly masks: boolean
351
+ readonly json?: boolean
352
+ }
353
+
354
+ /**
355
+ * Provisions every arm on a base ref and on this checkout, then names what
356
+ * differs. The record is the only thing on stdout, and a red arm that is red the
357
+ * same way on both sides is reported without failing the run, since it proves
358
+ * nothing either way.
359
+ */
360
+ async function runEquivalence(
361
+ targets: string[],
362
+ options: EquivalenceOptions,
363
+ ): Promise<void> {
364
+ if (reportAbsentScenarioTree()) return
365
+
366
+ intro('canon sandbox equivalence')
367
+
368
+ let record: EquivalenceRecord
369
+ try {
370
+ record = await collectEquivalence({
371
+ root: PROJECT_ROOT,
372
+ targets,
373
+ base: options.base,
374
+ includeAnchor: options.includeAnchor === true,
375
+ useMasks: options.masks,
376
+ out: options.out,
377
+ })
378
+ } catch (error) {
379
+ const reason = error instanceof Error ? error.message : String(error)
380
+ logError(`Cannot provision the baseline ${options.base}: ${reason}`)
381
+ outro()
382
+ process.exitCode = 1
383
+
384
+ return
385
+ }
386
+
387
+ logStep(`Base ${record.base} (${record.baseCommit.slice(0, 8)})`)
388
+ logInfo(`canon ${record.canonVersion}`)
389
+
390
+ for (const arm of record.arms) {
391
+ if (arm.state === 'identical' || arm.state === 'skipped-anchor') continue
392
+ const detail = arm.differs.length > 0 ? `: ${arm.differs.join(', ')}` : ''
393
+ if (arm.state === 'differs') logRemove(`${arm.arm} differs${detail}`)
394
+ else logWarn(`${arm.arm} is ${arm.state}`)
395
+ }
396
+ for (const line of record.masksApplied) logInfo(`masked ${line}`)
397
+ for (const error of record.errors) logError(error)
398
+
399
+ const { counts } = record
400
+ logStep('Equivalence')
401
+ const summary = `${counts.identical} identical, ${counts.differs} differs, ${counts['red-on-base']} red-on-base, ${counts['skipped-anchor']} skipped-anchor`
402
+ if (equivalenceExitCode(record) === 0) logInfo(summary)
403
+ else logWarn(summary)
404
+
405
+ if (options.json === true) process.stdout.write(`${JSON.stringify(record)}\n`)
406
+
407
+ outro()
408
+ process.exitCode = equivalenceExitCode(record)
409
+ }
410
+
341
411
  function runCheck(
342
412
  target: string,
343
413
  arm: string | undefined,
@@ -482,4 +552,42 @@ export function register(program: Command): void {
482
552
  .action((options: CoverageOptions) => {
483
553
  runCoverage(options)
484
554
  })
555
+
556
+ sandbox
557
+ .command('equivalence')
558
+ .description(
559
+ 'Provision every arm on a base ref and on this checkout, and diff the trees',
560
+ )
561
+ .argument(
562
+ '[target...]',
563
+ 'Category or <category>:<command>, defaulting to every category',
564
+ )
565
+ .helpOption('-h, --help', 'Show this help message')
566
+ .option('--base <ref>', 'Ref the baseline is provisioned from', 'main')
567
+ .option(
568
+ '--include-anchor',
569
+ 'Also run the arms that push to the shared GitHub anchor, one at a time (attended)',
570
+ )
571
+ .option('--out <dir>', 'Keep the provisioned trees and logs in this folder')
572
+ .option(
573
+ '--no-masks',
574
+ 'Compare every path, listing what each mask would hide',
575
+ )
576
+ .option('--json', 'Emit the record as JSON on stdout')
577
+ .addHelpText(
578
+ 'after',
579
+ [
580
+ '',
581
+ 'Examples:',
582
+ ' canon sandbox equivalence claude --json',
583
+ ' canon sandbox equivalence claude:plan-feature --base origin/main',
584
+ '',
585
+ 'Provisions offline only and never runs sandbox/run.sh or a claude binary.',
586
+ 'Exit codes: 0 when every compared arm is identical or red the same way',
587
+ 'on both sides, 1 on any difference, exit mismatch, or enumeration error.',
588
+ ].join('\n'),
589
+ )
590
+ .action((targets: string[], options: EquivalenceOptions) =>
591
+ runEquivalence(targets, options),
592
+ )
485
593
  }
@@ -60,7 +60,7 @@ function ignoredCitations(line: string): IgnoredCitations | undefined {
60
60
  * eval harness names paths in its target project. Neither is a reference into
61
61
  * this repository, so an unresolved path there is correct rather than stale.
62
62
  */
63
- const FIXTURE_TREES: readonly string[] = ['scripts/sandbox/', 'scripts/eval/']
63
+ const FIXTURE_TREES: readonly string[] = ['sandbox/', 'scripts/eval/']
64
64
 
65
65
  const FIXTURE_SEGMENTS: readonly string[] = ['fixtures', '__fixtures__']
66
66
 
@@ -20,7 +20,7 @@ File types and what they hold:
20
20
  Verdicts (pick exactly one):
21
21
  - KEEP: states current design, a rule, copy, a decision with its rejected alternative, or a live gotcha, on the right surface.
22
22
  - REPLACE: restates a fact or figure the existing section already carries ("moved again", "now", "the count moved", a newer number appended after an older one). The old statement should be rewritten in place, not appended to.
23
- - HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR or a decision log.
23
+ - HISTORY: narrates how things got here: which branch/PR changed what, "closed on", "did not survive", review-pass stories, pick-by-pick rounds. The current state is what belongs; the trail goes to the PR.
24
24
  - MOVE: correct content on the wrong surface (domain mechanism in ARCHITECTURE.md, implementation detail in a wireframe).
25
25
 
26
26
  Return only JSON: {"verdict": "...", "quote": "<the shortest added phrase that decided it>", "reason": "<one sentence>"}
@@ -19,7 +19,6 @@ import { SURFACE_ROOTS } from '@/surface-root'
19
19
  */
20
20
  export const DEFAULT_FOLDERS: readonly string[] = [
21
21
  'context',
22
- 'decisions',
23
22
  'diagrams',
24
23
  'wireframes',
25
24
  ]
@@ -134,8 +134,8 @@ export function compilePlan(draft: Draft, options: CompileOptions): DemoPlan {
134
134
  url: '',
135
135
  viewport: VIEWPORT,
136
136
  output: {
137
- video: `${options.outDir}/${options.slug}.webm`,
138
- still: `${options.outDir}/${options.slug}.png`,
137
+ video: `${options.outDir}/${options.slug}/take/${options.slug}.webm`,
138
+ still: `${options.outDir}/${options.slug}/take/${options.slug}.png`,
139
139
  },
140
140
  pointer: { travelMs: POINTER_TRAVEL_MS, typeDelayMs: TYPE_DELAY_MS },
141
141
  annotations: ANNOTATIONS,
@@ -252,8 +252,12 @@ export function parsePlan(text: string): PlanParse {
252
252
  },
253
253
  ...(colorScheme.value && { colorScheme: colorScheme.value }),
254
254
  output: {
255
- video: asText(output.video) || `demos/${slug || 'demo'}.webm`,
256
- still: asText(output.still) || `demos/${slug || 'demo'}.png`,
255
+ video:
256
+ asText(output.video) ||
257
+ `demos/${slug || 'demo'}/take/${slug || 'demo'}.webm`,
258
+ still:
259
+ asText(output.still) ||
260
+ `demos/${slug || 'demo'}/take/${slug || 'demo'}.png`,
257
261
  },
258
262
  pointer: {
259
263
  travelMs: asNumber(pointer.travelMs, POINTER_TRAVEL_MS),
@@ -135,7 +135,7 @@ export async function convertToGif(
135
135
 
136
136
  /**
137
137
  * Written beside the video by default, so frames fall under the same
138
- * `demos/*.png` gitignore entry the still already uses with no new rule
138
+ * `take` folder gitignore entry the still already uses with no new rule
139
139
  * needed. One frame a second by default, matched to the seconds-to-tens-of-
140
140
  * seconds length a tuned recording runs, without a smarter sampling strategy
141
141
  * nobody has asked for.
@@ -34,8 +34,9 @@ export function loadRenames(root: string): Renames {
34
34
 
35
35
  /**
36
36
  * Follows a declared rename to the name the toolkit ships the rule under now,
37
- * through every later rename of that name. The ledger is never pruned, so a
38
- * target several releases behind still reaches the current name.
37
+ * through every later rename of that name. The ledger is pruned only once a
38
+ * rule is retired, so a target several releases behind still reaches the
39
+ * current name of every rule that still ships.
39
40
  *
40
41
  * Returns nothing when the chain ends on a name with no source, or loops,
41
42
  * since installing a guess is worse than the retire the caller falls back to.
package/src/help.ts CHANGED
@@ -45,6 +45,7 @@ export const COMMAND_GROUPS: { title: string; rows: CommandRow[] }[] = [
45
45
  ['drive <url> <run>', 'Walk a page through interactions, measure each'],
46
46
  ['transcripts <url>', 'Fetch a YouTube transcript with frontmatter'],
47
47
  ['teach [cmd]', 'Learning workspaces (list, open, resource, glossary)'],
48
+ ['canvas [cmd]', 'Local canvas of pages and frames (serve, list)'],
48
49
  ],
49
50
  },
50
51
  {
@@ -64,6 +64,7 @@ export const RECORD_ENTRIES: readonly string[] = [
64
64
  '.records.git',
65
65
  SCRATCH,
66
66
  'README.md',
67
+ 'canvas',
67
68
  'diagrams',
68
69
  'evidence',
69
70
  'feedback',
@@ -134,7 +134,7 @@ export function parseExemptions(source: string): Map<string, string> {
134
134
  }
135
135
 
136
136
  function readExemptions(root: string): Map<string, string> {
137
- const path = join(root, 'scripts', 'sandbox', EXEMPT_FILE)
137
+ const path = join(root, 'sandbox', EXEMPT_FILE)
138
138
  if (!existsSync(path)) return new Map()
139
139
 
140
140
  return parseExemptions(readFileSync(path, 'utf8'))