@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
@@ -0,0 +1,267 @@
1
+ // Reports worker and pull request transitions as they happen. Reads only.
2
+ //
3
+ // The poll answers "what moved since the last run" on a schedule the session
4
+ // holds. This answers "what just changed" on a loop of its own, and it exists
5
+ // because the two readings a dispatch needs are not one reading: a worker that
6
+ // finishes goes idle and a worker that crashes vanishes, so a watch matching
7
+ // only the pull request list stays silent through the second. A third case is
8
+ // neither: a worker that stops on a question or a prompt neither finishes nor
9
+ // crashes, and sits reading "waiting" until something reports it, which is the
10
+ // WORKER-STOPPED line below, or WORKER-UNMEASURABLE for a "waiting" row too old
11
+ // to carry a stamp this reads.
12
+ //
13
+ // Run as `bun watch.ts`. A failed read never ends the loop, since it runs for
14
+ // hours and one transient `gh` failure aborting it is a silent stop. Every read
15
+ // states its own failure instead.
16
+ import { baseBranch, baseRef, gitPath, mainRoot, run } from './repo'
17
+
18
+ // Seconds between passes. A pass costs two cheap reads and the transitions it
19
+ // watches take minutes, so the number trades staleness against little. Raise it
20
+ // in a project whose workers run long.
21
+ const INTERVAL_S = 60
22
+
23
+ // Seconds a worker can sit in "waiting" before this reports it as stopped
24
+ // rather than folding it into the ordinary status-change lines. "busy" and
25
+ // "idle" resolve on their own; "waiting" does not, so a dwell that keeps
26
+ // growing there is a session blocked on something outside itself. The number
27
+ // sits well above INTERVAL_S, so a handful of passes confirm the row before it
28
+ // reports, and well inside the ten-to-thirty-minute span a dispatched build
29
+ // ordinarily runs. A permission prompt clears the moment a person approves it
30
+ // and a question can sit legitimately, and one number serves both.
31
+ export const STALL_THRESHOLD_S = 300
32
+
33
+ export type Read<T> = { isOk: false } | { isOk: true; value: T }
34
+
35
+ export interface Worker {
36
+ branch: string
37
+ dwellMs: number
38
+ name: string
39
+ status: string
40
+ }
41
+
42
+ export interface WatchState {
43
+ /** Names currently reported as stalled, so a stall prints once. */
44
+ stalled: ReadonlySet<string>
45
+ /** Null until a read succeeds, so each source keeps its own baseline flag. */
46
+ pulls: null | readonly string[]
47
+ workers: null | readonly Worker[]
48
+ }
49
+
50
+ export const initialState = (): WatchState => ({
51
+ pulls: null,
52
+ stalled: new Set(),
53
+ workers: null,
54
+ })
55
+
56
+ // `sort` orders by byte, so a title with a non-ASCII first character prints in
57
+ // the order it always did rather than in UTF-16 code unit order.
58
+ const byBytes = (a: string, b: string): number =>
59
+ Buffer.compare(Buffer.from(a), Buffer.from(b))
60
+
61
+ const rowOf = (worker: Worker): string =>
62
+ [worker.name, worker.branch, worker.status, String(worker.dwellMs)].join('\t')
63
+
64
+ // Identity leaves the dwell out. It grows every second, so keying on it
65
+ // reported every worker as new on every pass.
66
+ const identityOf = (worker: Worker): string =>
67
+ JSON.stringify([worker.name, worker.branch, worker.status])
68
+
69
+ const unseen = <T>(
70
+ current: readonly T[],
71
+ previous: readonly T[],
72
+ key: (item: T) => string,
73
+ ): T[] => {
74
+ const known = new Set(previous.map(key))
75
+
76
+ return current.filter((item) => !known.has(key(item)))
77
+ }
78
+
79
+ const stallLines = (
80
+ workers: readonly Worker[],
81
+ previous: ReadonlySet<string>,
82
+ thresholdS: number,
83
+ ): { lines: string[]; stalled: Set<string> } => {
84
+ const stalled = new Set(previous)
85
+ const lines: string[] = []
86
+
87
+ for (const worker of workers) {
88
+ if (worker.name === '') continue
89
+
90
+ if (worker.status !== 'waiting') {
91
+ stalled.delete(worker.name)
92
+ continue
93
+ }
94
+
95
+ if (worker.dwellMs === -1) {
96
+ if (!stalled.has(worker.name)) {
97
+ lines.push(`WORKER-UNMEASURABLE ${worker.name} ${worker.branch}`)
98
+ stalled.add(worker.name)
99
+ }
100
+ continue
101
+ }
102
+
103
+ const dwellS = Math.trunc(worker.dwellMs / 1000)
104
+ if (dwellS < thresholdS) {
105
+ stalled.delete(worker.name)
106
+ } else if (!stalled.has(worker.name)) {
107
+ lines.push(`WORKER-STOPPED ${worker.name} ${worker.branch} ${dwellS}s`)
108
+ stalled.add(worker.name)
109
+ }
110
+ }
111
+
112
+ return { lines, stalled }
113
+ }
114
+
115
+ /**
116
+ * One pass over two reads. A failed read is reported and the previous baseline
117
+ * kept, since reading an empty result as the current state would report every
118
+ * worker gone and every pull request new on the pass after.
119
+ */
120
+ export const pass = (
121
+ state: WatchState,
122
+ pulls: Read<string[]>,
123
+ workers: Read<Worker[]>,
124
+ thresholdS = STALL_THRESHOLD_S,
125
+ ): { lines: string[]; state: WatchState } => {
126
+ const lines: string[] = []
127
+
128
+ if (!pulls.isOk) {
129
+ lines.push(
130
+ 'watch: the open pull request list failed to load, so none is classified this pass',
131
+ )
132
+ }
133
+ if (!workers.isOk) {
134
+ lines.push(
135
+ 'watch: the session roster failed to load, so no worker is classified this pass',
136
+ )
137
+ }
138
+
139
+ if (pulls.isOk && state.pulls !== null) {
140
+ lines.push(
141
+ ...unseen(pulls.value, state.pulls, (line) => line).sort(byBytes),
142
+ )
143
+ }
144
+
145
+ let stalled = state.stalled
146
+ if (workers.isOk) {
147
+ const sorted = [...workers.value].sort((a, b) =>
148
+ byBytes(rowOf(a), rowOf(b)),
149
+ )
150
+ if (state.workers !== null) {
151
+ const changed = unseen(sorted, state.workers, identityOf)
152
+ lines.push(...changed.map((worker) => `WORKER ${rowOf(worker)}`))
153
+
154
+ const names = new Set(sorted.map((worker) => worker.name))
155
+ const gone = [...new Set(state.workers.map((worker) => worker.name))]
156
+ .filter((name) => !names.has(name))
157
+ .sort(byBytes)
158
+ lines.push(...gone.map((name) => `WORKER-GONE ${name}`))
159
+ }
160
+
161
+ const stalls = stallLines(sorted, stalled, thresholdS)
162
+ lines.push(...stalls.lines)
163
+ stalled = stalls.stalled
164
+ }
165
+
166
+ return {
167
+ lines,
168
+ state: {
169
+ pulls: pulls.isOk ? pulls.value : state.pulls,
170
+ stalled,
171
+ workers: workers.isOk ? workers.value : state.workers,
172
+ },
173
+ }
174
+ }
175
+
176
+ const readPulls = (): Read<string[]> => {
177
+ const listed = run('gh', [
178
+ 'pr',
179
+ 'list',
180
+ '--state',
181
+ 'open',
182
+ '--json',
183
+ 'number,headRefName,title',
184
+ '--jq',
185
+ '.[] | "PR-OPEN #\\(.number) \\(.headRefName) \\(.title)"',
186
+ ])
187
+ if (!listed.isOk) return { isOk: false }
188
+
189
+ return {
190
+ isOk: true,
191
+ value: listed.stdout.split('\n').filter((l) => l !== ''),
192
+ }
193
+ }
194
+
195
+ const asText = (value: unknown, fallback: string): string =>
196
+ typeof value === 'string' ? value : fallback
197
+
198
+ /**
199
+ * Every session in this repository holding a branch other than the base one is
200
+ * a worker, whoever launched it. A match on the `orchestrator-` name prefix
201
+ * reads a dispatched worker and misses every hand-launched one. The name stays whole, since one
202
+ * this client writes carries spaces.
203
+ */
204
+ const readWorkers = (repository: string, base: string): Read<Worker[]> => {
205
+ const listed = run('canon', ['sessions', 'list', '--json'])
206
+ if (!listed.isOk) return { isOk: false }
207
+
208
+ const last = listed.stdout.trimEnd().split('\n').at(-1) ?? ''
209
+ let parsed: unknown
210
+ try {
211
+ parsed = JSON.parse(last)
212
+ } catch {
213
+ return { isOk: false }
214
+ }
215
+
216
+ const sessions =
217
+ typeof parsed === 'object' &&
218
+ parsed !== null &&
219
+ Array.isArray((parsed as { sessions?: unknown }).sessions)
220
+ ? (parsed as { sessions: unknown[] }).sessions
221
+ : []
222
+
223
+ const workers: Worker[] = []
224
+ for (const session of sessions) {
225
+ if (typeof session !== 'object' || session === null) continue
226
+ const row = session as Record<string, unknown>
227
+ if (row.repository !== repository) continue
228
+ if (
229
+ row.branch === null ||
230
+ row.branch === undefined ||
231
+ row.branch === base
232
+ ) {
233
+ continue
234
+ }
235
+
236
+ workers.push({
237
+ branch: asText(row.branch, ''),
238
+ dwellMs: typeof row.statusDwellMs === 'number' ? row.statusDwellMs : -1,
239
+ name: asText(row.name, ''),
240
+ status: asText(row.status, ''),
241
+ })
242
+ }
243
+
244
+ return { isOk: true, value: workers }
245
+ }
246
+
247
+ const main = async (): Promise<number> => {
248
+ const root = mainRoot()
249
+ if (root === null) {
250
+ console.error('watch: not a git repository, so nothing is watched')
251
+
252
+ return 1
253
+ }
254
+
255
+ const repository = gitPath(root)
256
+ const base = baseBranch(baseRef())
257
+ let state = initialState()
258
+
259
+ for (;;) {
260
+ const result = pass(state, readPulls(), readWorkers(repository, base))
261
+ for (const line of result.lines) console.log(line)
262
+ state = result.state
263
+ await new Promise((resolve) => setTimeout(resolve, INTERVAL_S * 1000))
264
+ }
265
+ }
266
+
267
+ if (import.meta.main) process.exitCode = await main()
@@ -34,6 +34,7 @@ One row per top-level command, in the order `canon --help` prints them. A domain
34
34
  | `canon drive <url> <run>` | Walk a page through named interactions and measure each state |
35
35
  | `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
36
36
  | `canon teach <verb>` | Open and author learning workspaces |
37
+ | `canon canvas <verb>` | Serve a local canvas of pages and HTML frames, and add pages and frames to it |
37
38
  | `canon sandbox [cat:cmd]` | Run sandbox scenarios, toolkit-only like the tree it reads |
38
39
  | `canon tasks <verb>` | Read and write the task board, the plans it cites, and its archive |
39
40
  | `canon intake <verb>` | Report intake folders and write answers into them |
@@ -95,6 +96,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
95
96
  - `demo`, in `demo.md`: `compile`, `run`, `frames`
96
97
  - `inventory`, described above: `run`
97
98
  - `teach`, in `teach.md`: `list`, `open`, `resource`, `glossary`, `lesson`, `stylesheet`, `nav`, `render`
99
+ - `canvas`, described below: `serve`, `list`, `page add`, `page rename`, `frame add`
98
100
  - `sandbox`, in `sandbox.md`: `reset`, `clean`, `check`, `coverage`
99
101
  - `tasks`, in `tasks.md`: `next-label`, `archive`, `decline`, `plan-citations`, `plan-answers`, `plan-branch`, `plan-reach`, `plan-link`, `pull-request`, `outcome`, `list`, `validate`
100
102
  - `intake`, in `intake.md`: `list`, `answer`
@@ -127,6 +129,7 @@ Subcommands no other page describes:
127
129
  - `claude skills rank` scores either skill corpus's descriptions against a case corpus by TF-IDF cosine similarity, reporting rank-one and top-three (`--cases <path>`).
128
130
  - `design regen` rewrites this repository's `canon/DESIGN.md` from `src/design/tokens.ts` and `src/design/base.css` from `src/design/neutral.ts`. `design css` emits the tokens and components as CSS on stdout, with `--no-components` for properties alone, and `design render` declares each vendored face the typography table names under `--embed-fonts`.
129
131
  - `slides render` renders a `.claude/SLIDES.md` source into a PowerPoint deck and reports any unrecognized layout name on stderr, and `slides list --json` emits the layout catalog.
132
+ - `canvas serve` serves the canvas on `127.0.0.1`, walks ports like `canon serve`, refuses a `Host` other than loopback on its own port, and runs until interrupted. A page is a folder under `.canon/canvas/`, a frame is one HTML file in it, and `layout.json` beside them holds each frame's box. Each frame gets the project's token stylesheet injected, being this toolkit's tokens in its own checkout, else `.claude/design/base.css` plus anything under `.claude/design/project/`, else none with a notice. An open canvas reloads a frame whose file changes. `canvas list --json` emits every page with its frames, and `page add`, `page rename`, and `frame add` (`--width`, `--height`) each emit one record under `--json`, refusing a name that is not one path segment, a page that already exists, or a root that is not a directory, with the reason on stderr.
130
133
  - `labels scan` fails a pull request or a posted review whose title, body, or review comment carries a phase label, a label a code span quotes, a gitignored record path, a session link, a title word no dictionary holds, or a title breaking `standards/pr.md`'s format, casing, or length rule (`--event`, `--body-file`, `--json`).
131
134
  - `repo metadata propose` compares a description, homepage, and topic set computed from the README and `package.json` against what the remote carries, writing nothing, and `repo metadata apply` writes an explicitly supplied set through `gh repo edit` (`--description`, `--homepage`, `--topics`).
132
135
 
@@ -10,12 +10,27 @@ description: Compiling a screencast draft into a runnable plan, driving a served
10
10
  Two verbs, and they are separate because the artifact between them is edited.
11
11
 
12
12
  ```bash
13
- canon demo compile .canon/tmp/screencast/inline-edit.md
14
- canon demo run demos/inline-edit.json
15
- canon demo run demos/inline-edit.json --cursor ~/cursors/theme --out assets
16
- canon demo frames demos/inline-edit.webm --fps 2
13
+ canon demo compile demos/inline-edit/beats.md --slug inline-edit
14
+ canon demo run demos/inline-edit/plan.json
15
+ canon demo run demos/inline-edit/plan.json --cursor ~/cursors/theme --out assets
16
+ canon demo frames demos/inline-edit/take/inline-edit.webm --fps 2
17
17
  ```
18
18
 
19
+ ## One folder per demo
20
+
21
+ Each demo owns a folder, `demos/<slug>/`, and `compile` writes the plan into it.
22
+
23
+ | Path | Committed | Holds |
24
+ | ------------ | --------- | ----------------------------------------------------- |
25
+ | `beats.md` | yes | The draft `draft-screencast` wrote |
26
+ | `plan.json` | yes | The compiled plan, with its hand-tuned timing |
27
+ | `index.html` | yes | The HyperFrames composition, when the demo is wrapped |
28
+ | `demo.gif` | yes | The one deliverable a README embeds |
29
+ | `take/` | no | The raw recording and its still, written by `run` |
30
+ | `renders/` | no | The composed mp4, written by the render |
31
+
32
+ A project carries `demos/*/take/` and `demos/*/renders/` in its `.gitignore`, since both are one command away and a webm is large. A plan compiled before this layout, at `demos/<slug>.json`, still runs by path, because only the default moved.
33
+
19
34
  ## The draft and the plan are different files
20
35
 
21
36
  `draft-screencast` drafts beats for a person. A beat carries what is on screen, one verb, what to watch for, an emphasis, and a caption, and none of that names a selector, a URL, a wait condition, or a timing. Those four are what an executor needs and what would ruin the draft, since the format is pre-seeded so the operator edits down rather than fills blanks.
@@ -33,7 +48,7 @@ A recording that has to match both themes takes one plan per scheme, committed s
33
48
  | Option | Behavior |
34
49
  | --------------- | ----------------------------------------------------------------- |
35
50
  | `--out <dir>` | Directory the plan and its output paths point at, default `demos` |
36
- | `--slug <slug>` | Plan name, defaulting to the draft filename |
51
+ | `--slug <slug>` | Demo folder name, defaulting to the draft filename |
37
52
  | `--force` | Overwrite an existing plan, losing any field set by hand |
38
53
  | `--json` | Add a record on stdout carrying the beats and what is unfilled |
39
54
 
@@ -60,7 +75,7 @@ A step waits on its `waitFor` selector becoming visible and then holds for its o
60
75
 
61
76
  ## What frames does
62
77
 
63
- `canon demo frames` pulls numbered PNG stills back out of a recorded video through the same `ffmpeg` binary the mp4 and gif conversion already shells to. Nothing reads a recording back today, so a broken one ships until a person opens it, and this verb is what a skill calls to read one instead. It writes one frame a second by default, sampling at a rate matched to how long a tuned recording runs rather than at a rate tuned for any one clip. Frames land beside the video by default, named `<video-basename>-frame-<NNN>.png`, so they fall under the same `demos/*.png` gitignore entry the still already uses.
78
+ `canon demo frames` pulls numbered PNG stills back out of a recorded video through the same `ffmpeg` binary the mp4 and gif conversion already shells to. Nothing reads a recording back today, so a broken one ships until a person opens it, and this verb is what a skill calls to read one instead. It writes one frame a second by default, sampling at a rate matched to how long a tuned recording runs rather than at a rate tuned for any one clip. Frames land beside the video by default, named `<video-basename>-frame-<NNN>.png`, so they fall under the same `take/` ignore entry the still already uses.
64
79
 
65
80
  | Option | Behavior |
66
81
  | ----------------- | -------------------------------------------------------- |
@@ -104,8 +119,20 @@ This command was the first browser command to ship, and `canon capture` has sinc
104
119
 
105
120
  The cost is stated rather than hidden: the browser binary installs separately, so a target runs `bunx playwright install chromium` once before a recording works at all. A run that cannot launch reports that command inside the frame and exits 1.
106
121
 
122
+ ## Composing the take into a finished video
123
+
124
+ The take is raw. When the draft's `## Wrap` section asks for an intro, an outro, or music, `record-screencast` composes the take into `demos/<slug>/index.html` through [HyperFrames](https://hyperframes.heygen.com) and renders `renders/<slug>.mp4`, the deliverable. The skill loads HyperFrames' own `hyperframes` skill for the composition rules and runs `bunx -y hyperframes check` and `render`, then hands the render to `read-frames`. A wrap section reading `none` throughout stops at the take. The webm is only the raw take, and a gif is for a host that strips video.
125
+
126
+ Canon neither ships nor installs HyperFrames, and there is no `canon` verb for composing, since the render is one command already. The operator installs it once per machine:
127
+
128
+ ```bash
129
+ bunx -y hyperframes init
130
+ ```
131
+
132
+ `hyperframes init` installs its skills machine-wide without asking, so run it knowing that. A machine without it stops the skill with this command and nothing else.
133
+
107
134
  ## What it does not do
108
135
 
109
- A generated recording is a raw take. Nothing trims it or scores it beyond the caption each beat already carries.
136
+ Nothing in `canon demo` itself trims a take or scores it beyond the caption each beat already carries. Composing is the skill's step and HyperFrames' work.
110
137
 
111
138
  It also does not replace a narrated screencast. That has a hero moment, a cut list, and a voice, none of which survives being generated. This answers the case where the alternative is recording nothing.
@@ -15,7 +15,7 @@ description: What each canon migrate verb moves and rewrites, what it refuses, t
15
15
 
16
16
  A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the nearest non-blank line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
17
17
 
18
- `migrate surface-roots` moves the tracked surfaces from `.claude/` to `canon/`: `context/`, `decisions/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the install stamp folder, respelled `canon/config/` since a project under `canon/` reserves the bare `canon` name for the CLI's own install. Every path is tracked, so each file moves with `git mv` and its history follows it, and every tracked citation of one is rewritten in the same run. `rules/`, `skills/`, `hooks/`, and `settings.json` stay where Claude Code reads them. It reports until `--write` is passed, and a destination already present under `canon/` refuses the whole plan rather than splitting a surface across both roots.
18
+ `migrate surface-roots` moves the tracked surfaces from `.claude/` to `canon/`: `context/`, `wireframes/`, `ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`, and the install stamp folder, respelled `canon/config/` since a project under `canon/` reserves the bare `canon` name for the CLI's own install. Every path is tracked, so each file moves with `git mv` and its history follows it, and every tracked citation of one is rewritten in the same run. `rules/`, `skills/`, `hooks/`, and `settings.json` stay where Claude Code reads them. It reports until `--write` is passed, and a destination already present under `canon/` refuses the whole plan rather than splitting a surface across both roots.
19
19
 
20
20
  A line carrying `canon-keep-surface-root`, or the nearest non-blank line above it, keeps the old spelling. A rule's frontmatter `paths:` glob, a citation shaped like a path into another repository, and one inside a dated paragraph are reported by file and line and left alone. Running it twice rewrites nothing, which is the idempotence check. A project that never runs it keeps resolving each surface under `.claude/`, since every reader takes whichever root holds the entry.
21
21
 
@@ -52,7 +52,7 @@ Both values are taken at the top of the pass rather than at the post, so the com
52
52
 
53
53
  The last non-empty line is where it has to sit, and the reader looks nowhere else. Searching a body for the last match instead reads a marker the body was displaying rather than claiming, since a fenced block showing the format on its own line trims to exactly the pattern, and a pass carrying no marker of its own would then hand the next reader a commit taken from an illustration. That is the same defect through a second door, so position rather than shape is what separates a claim from a quotation.
54
54
 
55
- `canon pr review-state` is the only parser of that format. Two parsers was the alternative, one in the shipped skill body and one in the orchestrator poll's jq, and those two ship on different cadences, so a format change to either would leave the other reading a reviewed commit as unreviewed.
55
+ `canon pr review-state` is the only parser of that format. Two parsers was the alternative, one in the shipped skill body and one in the orchestrator poll's own filters, and those two ship on different cadences, so a format change to either would leave the other reading a reviewed commit as unreviewed.
56
56
 
57
57
  `source` on the record says which reading answered, and a caller checks it before trusting the rest:
58
58
 
@@ -9,7 +9,7 @@ description: Scenario routing, the expectation scoring surface, and the coverage
9
9
 
10
10
  ## Scenarios
11
11
 
12
- Scenarios live under `scripts/sandbox/`, one folder per category. `scripts/sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. `files` in `package.json` excludes that tree, so an installed `canon` carries the command, reports it as toolkit-only on one line, and exits 1 rather than failing on the missing directory. Route non-interactively with `SANDBOX_SCENARIO`:
12
+ Scenarios live under `sandbox/`, one folder per category. `sandbox/fixtures/` is the exception, holding file content that scenarios stage rather than scenarios of its own, so both pickers filter it out. That tree sits outside `files` in `package.json`, so an installed `canon` carries the command, reports it as toolkit-only on one line, and exits 1 rather than failing on the missing directory. Route non-interactively with `SANDBOX_SCENARIO`:
13
13
 
14
14
  ```bash
15
15
  SANDBOX_SCENARIO=sync canon sandbox infra:tooling
@@ -45,6 +45,31 @@ An envelope that parses but carries no `result` field skips the reply assertion
45
45
 
46
46
  Exit 0 means `pass` or `unchecked`. Exit 1 means `fail`, or a caller error: a malformed target, or a sandbox that was never provisioned. A missing sandbox reports as an error rather than a failed verdict, because failing every path assertion would read as a skill that did nothing. `--strict` moves `unchecked` to exit 1 for a caller that has finished arming its scenarios.
47
47
 
48
+ ## Provisioning equivalence
49
+
50
+ `canon sandbox equivalence` proves a change moved nothing in what scenarios provision. It provisions every arm of the named targets on a base ref and on the working checkout, builds a manifest of each tree, and names what differs. It runs offline and never calls `sandbox/run.sh` or a `claude` binary, so no arm it runs bills a model.
51
+
52
+ ```bash
53
+ canon sandbox equivalence claude --base origin/main --json
54
+ canon sandbox equivalence claude:plan-feature/full --base HEAD
55
+ ```
56
+
57
+ A target is `<category>`, `<category>:<command>`, or `<category>:<command>/<arm>`, defaulting to every category.
58
+
59
+ | Flag | Effect |
60
+ | ------------------ | ------------------------------------------------------------------------------------------- |
61
+ | `--base <ref>` | Ref the baseline is provisioned from, `main` by default |
62
+ | `--include-anchor` | Also run the arms that push to the shared GitHub anchor, one at a time, for an attended run |
63
+ | `--out <dir>` | Keep the provisioned trees, logs, and per-side manifests in this folder |
64
+ | `--no-masks` | Compare every path, which lists what each mask would hide |
65
+ | `--json` | Emit the record on stdout |
66
+
67
+ The baseline is a detached worktree with full history, removed on exit including after `SIGINT`. Each arm provisions on the base side, is read into a manifest, is deleted, then provisions on the head side at the same path with the run id pinned. A manifest holds every file's mode and hash, every symlink's target, the exit status, the narration, and for the outer repository and each nested one the commit subjects, the staged index, and the branch.
68
+
69
+ The record lists per arm `identical`, `differs` with the manifest keys that differ, `red-on-base` when both sides fail the same way, or `skipped-anchor`. It also carries `canonVersion`, `baseCommit`, `masksApplied`, `errors`, and `counts`. A declared arm adds a `check` entry from `canon sandbox check` on the head tree, whose `asserted` count shows its `expect.toml` resolved from `sandbox/fixtures/`.
70
+
71
+ Exit 0 means every compared arm is identical or red the same way on both sides. Exit 1 means any `differs`, a base and head exit that disagree, or an armed arm that `canon sandbox coverage` lists and enumeration lacks.
72
+
48
73
  ## Scenario coverage
49
74
 
50
75
  `canon sandbox coverage` reports which scenarios declare expectations and which only provision a state. It reads the fixture tree, so it needs no provisioned sandbox and runs nothing. Where that tree does not ship it exits 1 and prints no percentage, since a denominator nobody looked at is not a coverage result. A tree that is present and holds no scenarios is a real zero and still reports one.
@@ -67,9 +92,9 @@ The record carries every scenario with the arms that declare, plus `totalScenari
67
92
 
68
93
  A skill pairs to a scenario by filename, `<category>-<command>` first and bare `<command>` second, so `claude/target-setup.sh` reaches the `target-setup` skill. `should-be-asserted` is the default rather than a queue to drain, and which of those skills earns an arm is a project decision the census does not make.
69
94
 
70
- `exempt` means no arm should be written and holds only with a reason, declared in `scripts/sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
95
+ `exempt` means no arm should be written and holds only with a reason, declared in `sandbox/exempt.toml` and limited to a harness limit the checker cannot reach past or a skill that writes no artifact. An armed arm outranks an exemption. An exemption naming no shipped skill, or naming one an arm now asserts, exits 1 without `--strict`. Each armed arm reports as `<category>:<command>/<arm>`, so two same-named arms under different scenarios stay distinct.
71
96
 
72
- `scripts/sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.canon/tmp/runs/sandbox/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit.
97
+ `sandbox/run.sh` calls this after a headless run and merges the verdict into the envelope it prints. It also writes that merged record to `.canon/tmp/runs/sandbox/<target>-<arm>-<timestamp>.json` with a `writes` array appended, and logs the path on stderr. Both fields are what a later re-score needs, since `--envelope` and `--writes` read files the run deletes on exit.
73
98
 
74
99
  Two more fields ride alongside the verdict rather than inside it. `escapes` lists what the run wrote under a watched toolkit root, which the verdict cannot assert over because those files sit outside the sandbox tree. `sessions` reports the nested-dispatch bound, carrying `watched` for whether the client's session registry was there to read, `new` for the records that appeared while the run was in flight, `concurrent` for the records present both before and after, and `reap` for what the run found in the session's process group afterwards. Neither field fails a run on its own.
75
100
 
@@ -22,7 +22,7 @@ canon secrets scan --json
22
22
  canon secrets scan ../my-app
23
23
  ```
24
24
 
25
- The corpus is the package's own `files` field rather than a list the check keeps. That field is the single statement of which trees leave this repository, so a second list beside it would answer the same question and drift. It also carries the negations the publish already makes, which is what puts the sandbox tree, the eval tree, and every test file out of scope by the rule that keeps them out of the tarball rather than by an exclusion this check invented.
25
+ The corpus is the package's own `files` field rather than a list the check keeps. That field is the single statement of which trees leave this repository, so a second list beside it would answer the same question and drift. It also carries the negations the publish already makes, which is what puts the eval tree and every test file out of scope by the rule that keeps them out of the tarball rather than by an exclusion this check invented. The sandbox tree sits under no `files` entry at all, so the same field leaves it out without a negation.
26
26
 
27
27
  The plugin reaches a target by a different route, loading live from `claude/` rather than from a tarball. That folder is a `files` entry too, and its `standards` symlink resolves into a tree the field lists in its own right, so both routes land inside the same corpus.
28
28
 
@@ -30,7 +30,7 @@ Three root files are read whether or not the field names them, being `package.js
30
30
 
31
31
  ### What the corpus leaves out
32
32
 
33
- The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/sandbox/`, `scripts/eval/`, and every test file. `src/capture/` was a fourth negation when that reading was taken and is not one now. The folder holds 5 tracked files, 2 of them tests the publish still excludes, so the corpus is 3 wider than the numbers above describe and the unread count is 3 smaller.
33
+ The corpus answers what the package publishes, which is narrower than what the repository holds. Measured against this repository on 2026-08-21, the scan read 544 files and left 593 of the 1139 git lists unread. Those include everything under `.claude/`, `wiki/`, and `internal/`, the workflow definitions under `.github/`, and the trees the publish negations remove, being `scripts/eval/` and every test file. The scenario tree was a third negation at that reading and now sits at `sandbox/` under no `files` entry, so it stays unread. `src/capture/` was a fourth negation when that reading was taken and is not one now. The folder holds 5 tracked files, 2 of them tests the publish still excludes, so the corpus is 3 wider than the numbers above describe and the unread count is 3 smaller.
34
34
 
35
35
  A public repository makes that gap readable by anyone, so a clean run means no credential in the published tree rather than none in the repository. The run states the number on every pass, including a clean one, so the bound travels with the verdict. Widening the corpus to every tracked file is a separate decision, since the row this implements puts the shipped tree first on the toolkit's rule that content leaving the repository gates harder than content that stays.
36
36
 
@@ -79,7 +79,7 @@ The order carries the reason. A sync replaces each toolkit-owned copy with one a
79
79
 
80
80
  ## Move the surfaces off `.claude/`, once
81
81
 
82
- Tracked surfaces moved out of `.claude/` and into a root of their own. `context/`, `decisions/`, `wireframes/`, the three loose documents (`ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`), and the install stamp folder, respelled `canon/config/`, now live under `canon/`, and `rules/`, `skills/`, `hooks/`, and `settings.json` stay put since Claude Code reads those by path.
82
+ Tracked surfaces moved out of `.claude/` and into a root of their own. `context/`, `wireframes/`, the three loose documents (`ARCHITECTURE.md`, `REQUIREMENTS.md`, `DESIGN.md`), and the install stamp folder, respelled `canon/config/`, now live under `canon/`, and `rules/`, `skills/`, `hooks/`, and `settings.json` stay put since Claude Code reads those by path.
83
83
 
84
84
  Run `canon upgrade` first if you have not, then run this from inside the project:
85
85
 
@@ -20,8 +20,7 @@ canon/
20
20
  ├── ARCHITECTURE.md ← technical design decisions
21
21
  ├── DESIGN.md ← visual intent and token decisions (UI projects)
22
22
  ├── wireframes/ ← regions, states, UI copy, and interaction rules (UI projects)
23
- ├── context/ ← per-domain narrative loaded on demand via index.md
24
- └── decisions/ ← decision history a canonical doc points at, never loaded eagerly
23
+ └── context/ ← per-domain narrative loaded on demand via index.md
25
24
 
26
25
  .claude/
27
26
  └── rules/ ← path-scoped governance rules, written by canon gov install
@@ -34,7 +33,7 @@ canon/
34
33
  └── tmp/ ← deletable scratch, safe to remove without loss
35
34
  ```
36
35
 
37
- A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it, and `canon migrate record-tree` follows it to reach the citations inside the records themselves, which the first verb passes over because it enumerates through git. A project scaffolded before the surface move keeps its context, decisions, wireframes, and loose documents under `.claude/` the same way, and `canon migrate surface-roots` moves those to `canon/` with the history following each file.
36
+ A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it, and `canon migrate record-tree` follows it to reach the citations inside the records themselves, which the first verb passes over because it enumerates through git. A project scaffolded before the surface move keeps its context, wireframes, and loose documents under `.claude/` the same way, and `canon migrate surface-roots` moves those to `canon/` with the history following each file.
38
37
 
39
38
  Three tiers of context load with different cost: always-loaded (root `CLAUDE.md`, `canon/REQUIREMENTS.md`, `canon/ARCHITECTURE.md`), path-scoped lazy (`.claude/rules/<scope>.md` with `paths:` glob), and on-demand lookup (`canon/context/<domain>.md`, or `canon/context/<domain>/` once a domain outgrows one file, discovered via `canon/context/index.md`). See [the context model](../../canon/context/context-model/overview.md) for the full picture.
40
39
 
@@ -134,7 +134,7 @@ This page is the corpus the coverage claim is measured against: every name `cano
134
134
  | `canon:ci-workflow` | For a GitHub Actions workflow file |
135
135
  | `canon:draft-slides` | For a deck, drafted as `.claude/SLIDES.md` and rendered to PowerPoint |
136
136
  | `canon:draft-screencast` | For a recording script with beats and defaults already seeded |
137
- | `canon:record-screencast` | For compiling and running a screencast draft into a recording and a still |
137
+ | `canon:record-screencast` | For recording a screencast draft, then composing the take into a finished mp4 when the draft asks for a wrap |
138
138
  | `canon:draft-identity` | For a project's logo mark and its social card, drafted through `draft-and-pick`'s pick loop |
139
139
 
140
140
  ## Answer a question at any point
@@ -6,10 +6,13 @@
6
6
  # An entry declares one-to-one identity and nothing else. A fold of several
7
7
  # rules into one, or a split of one into several, is a retirement plus an
8
8
  # addition and takes no entry here. Add the entry in the same change as the
9
- # rename or renumber, and never prune one: a target can sit any number of
10
- # releases behind, and a chain A to B to C resolves A only while both links
11
- # stay. A key must name no live rule and every chain must end on one, which
12
- # `src/gov/renames.test.ts` checks against this file.
9
+ # rename or renumber. Prune an entry only once its target rule is retired and
10
+ # the retire path covers it, since a chain ending on a rule nothing ships
11
+ # resolves to nothing and a sync deletes the held file instead. Otherwise leave
12
+ # it: a target can sit any number of releases behind, and a chain A to B to C
13
+ # resolves A only while both links stay. A key must name no live rule and every
14
+ # chain must end on one, which `src/gov/renames.test.ts` checks against this
15
+ # file.
13
16
  [renamed]
14
17
  "005-behavior" = "565-behavior"
15
18
  "015-output" = "566-output"
@@ -25,7 +28,6 @@
25
28
  "520-wireframes" = "620-wireframes"
26
29
  "530-requirements" = "630-requirements"
27
30
  "540-architecture" = "640-architecture"
28
- "545-decisions" = "645-decisions"
29
31
  "550-design" = "650-design"
30
32
  "555-tasks" = "655-tasks"
31
33
  "556-groundwork" = "656-groundwork"
@@ -9,4 +9,4 @@ paths:
9
9
 
10
10
  ## Authority
11
11
 
12
- - Follow the requirements standard for what belongs in the problem, goals, non-goals, MVP features, tech stack, and constraints. It is the single source. Read it with `canon standards requirements`.
12
+ - Follow the requirements standard for what belongs in the problem, goals, non-goals, MVP features, and constraints. It is the single source. Read it with `canon standards requirements`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.14.0",
4
+ "version": "5.15.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": {
@@ -17,7 +17,6 @@
17
17
  "docs",
18
18
  "tsconfig.json",
19
19
  "assets/brand/mark.svg",
20
- "!scripts/sandbox",
21
20
  "!scripts/eval",
22
21
  "!**/*.test.ts",
23
22
  "!**/*.test.tsx"
@@ -27,9 +26,9 @@
27
26
  },
28
27
  "scripts": {
29
28
  "check:spell": "cspell '**' '.*/**' '.*' --no-progress --color --show-context",
30
- "format": "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/ tooling/ claude/",
31
- "check:format": "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/ tooling/ claude/",
32
- "check:shell": "find scripts tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
29
+ "format": "prettier --write --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --write --indent 2 scripts/ sandbox/ tooling/ claude/",
30
+ "check:format": "prettier --check --log-level warn --ignore-path .gitignore --ignore-path .prettierignore . && shfmt --diff --indent 2 scripts/ sandbox/ tooling/ claude/",
31
+ "check:shell": "find scripts sandbox tooling claude .claude/hooks -name '*.sh' -exec shellcheck --severity=warning {} + && find .husky -maxdepth 1 -type f -exec shellcheck --severity=warning --shell=sh {} +",
33
32
  "check:types": "tsc --noEmit",
34
33
  "check:install": "./scripts/core/install-check.sh",
35
34
  "test": "bun --bun vitest run",
@@ -53,11 +52,13 @@
53
52
  "web:e2e": "cd web && playwright test"
54
53
  },
55
54
  "dependencies": {
55
+ "@preact/signals": "^2.11.3",
56
56
  "commander": "^13.1.0",
57
57
  "execa": "^9.5.2",
58
58
  "highlight.js": "^11.12.0",
59
59
  "playwright-core": "1.59.1",
60
- "pptxgenjs": "^4.0.1"
60
+ "pptxgenjs": "^4.0.1",
61
+ "preact": "^11.0.0"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@astrojs/check": "^0.9.8",
@@ -71,6 +72,7 @@
71
72
  "@types/micromatch": "^4.0.10",
72
73
  "astro": "^6.1.9",
73
74
  "cspell": "^8.17.1",
75
+ "happy-dom": "^20.14.5",
74
76
  "husky": "^9.1.7",
75
77
  "micromatch": "^4.0.8",
76
78
  "prettier": "^3.8.1",
@@ -6,6 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
7
7
 
8
8
  SCRIPTS_ROOT="$PROJECT_ROOT/scripts"
9
+ SANDBOX_ROOT="$PROJECT_ROOT/sandbox"
9
10
  PALETTE_SOURCE="$SCRIPTS_ROOT/lib/ui.sh"
10
11
 
11
12
  # `find` writes to stderr and returns non-zero for a missing root, inside a
@@ -16,6 +17,15 @@ if [ ! -f "$PALETTE_SOURCE" ]; then
16
17
  exit 1
17
18
  fi
18
19
 
20
+ # The same silence follows a root that moved, so a walked root that is gone
21
+ # fails here rather than shrinking the corpus with no message.
22
+ for root in "$SCRIPTS_ROOT" "$SANDBOX_ROOT"; do
23
+ if [ ! -d "$root" ]; then
24
+ echo "No shell root at ${root#"$PROJECT_ROOT/"}, color sourcing unverifiable." >&2
25
+ exit 1
26
+ fi
27
+ done
28
+
19
29
  # Any SGR sequence in either spelling. The cursor and erase sequences an
20
30
  # interactive prompt writes end in a letter instead, and they run only where a
21
31
  # terminal already exists, so they are not what this measures.
@@ -31,7 +41,7 @@ while IFS= read -r file; do
31
41
  if grep -qE "$SGR" "$file"; then
32
42
  offenders="$offenders ${file#"$PROJECT_ROOT/"}"$'\n'
33
43
  fi
34
- done < <(find "$SCRIPTS_ROOT" -type f -name '*.sh')
44
+ done < <(find "$SCRIPTS_ROOT" "$SANDBOX_ROOT" -type f -name '*.sh')
35
45
 
36
46
  if [ -n "$offenders" ]; then
37
47
  echo "A color escape is defined outside the shared library:" >&2