@erclx/canon 5.13.0 → 5.14.1

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 (68) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/SKILL.md +3 -2
  3. package/claude/skills/deprecation-migration/REQUIREMENT.md +62 -0
  4. package/claude/skills/deprecation-migration/SKILL.md +115 -0
  5. package/claude/skills/deprecation-migration/references/adopted.md +50 -0
  6. package/claude/skills/git-followup/REQUIREMENT.md +1 -0
  7. package/claude/skills/git-followup/SKILL.md +3 -0
  8. package/claude/skills/git-pr/REQUIREMENT.md +1 -0
  9. package/claude/skills/git-pr/SKILL.md +1 -1
  10. package/claude/skills/git-pr/references/evidence.md +2 -2
  11. package/claude/skills/git-ship/REQUIREMENT.md +3 -0
  12. package/claude/skills/git-ship/SKILL.md +12 -1
  13. package/claude/skills/review-pr/SKILL.md +1 -1
  14. package/claude/skills/review-pr/references/close-out.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +1 -1
  16. package/claude/skills/review-ui/REQUIREMENT.md +5 -2
  17. package/claude/skills/review-ui/SKILL.md +12 -4
  18. package/claude/skills/review-ui/references/driving.md +5 -3
  19. package/claude/skills/review-ui/references/post.md +3 -2
  20. package/claude/skills/role-orchestrator/SKILL.md +1 -1
  21. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
  22. package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
  23. package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
  24. package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
  25. package/docs/agents/commands.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/markdown-audit.md +1 -1
  28. package/docs/agents/pr-evidence.md +5 -0
  29. package/docs/agents/pr-frames.md +112 -0
  30. package/docs/agents/pr-reads.md +1 -1
  31. package/docs/agents/sandbox.md +3 -3
  32. package/docs/agents/state-scoped-risk.md +2 -2
  33. package/docs/workflow/skill-map.md +15 -14
  34. package/package.json +4 -5
  35. package/scripts/core/check-color-source.sh +11 -1
  36. package/scripts/core/install-check.sh +13 -4
  37. package/scripts/lib/sandbox-dispatch.sh +1 -1
  38. package/scripts/lib/sandbox-fixtures.sh +2 -2
  39. package/scripts/lib/sandbox-path.sh +1 -1
  40. package/scripts/manage-sandbox.sh +2 -2
  41. package/src/claude/cases/misc.ts +5 -0
  42. package/src/claude/skills-audit.ts +1 -0
  43. package/src/commands/pr.ts +452 -0
  44. package/src/commands/sandbox.ts +2 -2
  45. package/src/context/architecture.ts +5 -2
  46. package/src/context/citations.ts +1 -1
  47. package/src/gate/boundaries.ts +509 -0
  48. package/src/gate/measures.ts +8 -19
  49. package/src/gate/stages.ts +12 -28
  50. package/src/pr/frames.ts +323 -0
  51. package/src/sandbox/census.ts +1 -1
  52. package/src/sandbox/coverage.ts +1 -1
  53. package/src/sandbox/expect.ts +0 -1
  54. package/src/shipped/references.ts +8 -10
  55. package/src/targets/pulls.ts +2 -2
  56. package/src/tooling/manifest.ts +2 -3
  57. package/src/tooling/seeds.ts +37 -0
  58. package/tooling/claude/manifest.toml +3 -3
  59. package/tsconfig.json +1 -1
  60. package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
  61. package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
  62. package/scripts/core/check-capability-seeding.sh +0 -134
  63. package/scripts/core/check-ignore-parity.sh +0 -141
  64. package/scripts/core/check-plugin-boundary.sh +0 -45
  65. package/scripts/core/check-seed-independence.sh +0 -62
  66. package/scripts/core/check-skill-paths.sh +0 -24
  67. package/scripts/core/list-seed-roots.sh +0 -18
  68. package/scripts/lib/tooling.sh +0 -37
@@ -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()
@@ -103,7 +103,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
103
103
  - `worktrees`, in `worktrees.md`: `list`, `reclaim`
104
104
  - `hooks`, in `hooks.md`: `post-merge`
105
105
  - `autoship`, in `review-classification.md`: `classify`
106
- - `pr`, in `pr-reads.md`: `key-changes`, `head`, `checks`, `review-state`, `evidence`, `preview`, `local`
106
+ - `pr`, in `pr-reads.md`: `key-changes`, `head`, `checks`, `review-state`, `evidence`, `preview`, `local`, `frames`
107
107
  - `audits`, in `audits.md`: `run`, `list`
108
108
  - `gate`, in `gate.md`: `run`
109
109
  - `secrets`, in `state-scoped-risk.md`: `scan`
@@ -36,6 +36,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
36
36
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
37
37
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
38
38
  - [The pull request evidence comparison](pr-evidence.md): What canon pr evidence compares, the marker that lets it edit its own comment rather than duplicate it, the refusal reasons it names, what the --check read reports as owed, and why the comparison anchors at the merge base rather than the previous push
39
+ - [The pull request frame store](pr-frames.md): How canon pr frames pushes a UI review frame to a never-merged branch through the GitHub API and returns a branch-path link, which ref it writes, the pass segment that keeps an earlier image fixed, the refusal reasons it names, and how --drop and --prune rewrite the branch so a dropped frame leaves history
39
40
  - [The pull request local preview](pr-local.md): How canon pr local finds the server a worktree is running, why a server in another worktree is never reported, the refusal reasons it names, and how a close workflow removes the link with --remove
40
41
  - [The pull request preview deploy](pr-preview.md): How canon pr preview finds a fenced Cloudflare Pages deploy, dispatches it on a pull request's branch, reads the alias the run prints, the refusal reasons it names, and why an unfenced deploy is refused before anything runs
41
42
  - [Head-sensitive pull request reads](pr-reads.md): Resolving a branch tip from the remote rather than from the pull request object, reading what a review pass covered off its own marker rather than off GitHub's submission stamps, why an empty run list is not a pass, the refusal reasons each verb names, and what the remote read costs
@@ -89,7 +89,7 @@ A hit the closed set could not separate from correct prose was the case with no
89
89
 
90
90
  Five surfaces apply the ban set and four of them go through this verb. `.claude/hooks/standards-audit.sh` runs it against a single file after each markdown edit, the seed copy a project installs does the same, the `Markdown bans` stage in `canon gate run` runs it across the whole corpus before this repository's own push, and the same-named stage in the one verify runner every stack shares, `scripts/verify.ts`, runs it across a scaffolded project's tracked markdown before its own push. Each hook parsed its own copy of the word bans in awk before that, which left a British spelling passing at edit time and failing the push with nothing in between explaining the difference.
91
91
 
92
- The seed copy moved onto the verb when the sets became data, since its awk had nothing left to parse. It resolves one runner where the toolkit copy resolves two, looking for no checkout source, and a machine carrying no `canon` gets a report naming the binary to install rather than a silent pass. `scripts/core/check-seed-independence.sh` scopes its walk to markdown and leaves the seed hooks outside it, which its own comment records as deliberate.
92
+ The seed copy moved onto the verb when the sets became data, since its awk had nothing left to parse. It resolves one runner where the toolkit copy resolves two, looking for no checkout source, and a machine carrying no `canon` gets a report naming the binary to install rather than a silent pass. The Seed independence stage of `bun run check` scopes its walk to markdown and leaves the seed hooks outside it, which its own comment records as deliberate.
93
93
 
94
94
  The target push stage reads the same exit codes `canon gate run`'s own stage reads: 0 passes, 1 is a refusal logged as a skip, 2 is a finding, and 3 is a shipped-empty ban set. It skips and logs rather than failing when no `canon` binary is on the machine's PATH, since a scaffolded project is not guaranteed the toolkit is installed, and its skip line names the install command.
95
95
 
@@ -83,6 +83,11 @@ them means the thread is read before the `no-evidence` answer too, so an
83
83
  unreadable thread refuses as `gh-failed` on both reasons rather than
84
84
  reporting the fields absent.
85
85
 
86
+ `ok` also carries `states`, the comparison the body renders as data: each
87
+ state's name and its items, each with its `path`, `stem`, and whether it was
88
+ `added`. `review-ui` matches a frame against it so it publishes no image the
89
+ worker's evidence already shows. `canon docs pr-frames` covers that store.
90
+
86
91
  ## What `[number]` selects, and what it does not
87
92
 
88
93
  Naming a number picks the pull request the whole record describes: its head
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: The pull request frame store
3
+ description: How canon pr frames pushes a UI review frame to a never-merged branch through the GitHub API and returns a branch-path link, which ref it writes, the pass segment that keeps an earlier image fixed, the refusal reasons it names, and how --drop and --prune rewrite the branch so a dropped frame leaves history
4
+ ---
5
+
6
+ # The pull request frame store
7
+
8
+ `canon pr frames` gives a review comment an image it can embed. `review-ui`
9
+ drives a pull request's checklist and captures a frame per box, and a frame
10
+ on the reviewer's disk resolves for nobody else. The verb pushes one frame to
11
+ the `canon-frames` branch and returns a link to its path there.
12
+
13
+ ```bash
14
+ canon pr frames 1341 --add frames/3.png --box 3 --head <sha> --pass 20261002T154450Z --json
15
+ canon pr frames 1341 --drop --json
16
+ canon pr frames --prune 30 --json
17
+ ```
18
+
19
+ ## What it writes
20
+
21
+ The verb writes `refs/heads/canon-frames` and no other ref. Every call goes
22
+ through the GitHub git data API, being blobs, trees, commits, and that one ref,
23
+ so nothing is checked out, no worktree is entered, and neither `main` nor any
24
+ branch a pull request holds is touched. The branch never merges.
25
+
26
+ A frame lands at `pr-<number>/<short-head>/<pass>/box-<n>.png`. `--head` names
27
+ the commit the pass drove, 7 to 40 lowercase hex characters, and defaults to
28
+ the head the pull request object reports. `--pass` is the instant the pass
29
+ started, in compact UTC as `date -u +%Y%m%dT%H%M%SZ` prints it, which
30
+ `review-ui` records once at its first step. A second pass at the same head and
31
+ box therefore writes a new file rather than replacing the image an earlier
32
+ comment shows, which matters when a preview is minted again at one head.
33
+
34
+ `link` uses the same `blob/<ref>/<path>?raw=true` form as the evidence comment,
35
+ naming the `canon-frames` branch rather than a commit. Every drop rewrites the
36
+ branch, and a link pinned to a commit would stop resolving for every other pull
37
+ request's frames once GitHub collects the commit it names. A path link lives
38
+ exactly as long as its file, so it survives every rewrite that drops another
39
+ pull request and ends only when its own is dropped.
40
+
41
+ The first `--add` creates the branch with a root commit holding the
42
+ frame, so a project needs no setup. An add only fast-forwards the ref. When
43
+ another writer moved it between the read and the move, the add rebuilds on the
44
+ new tip, up to three times, before refusing as `ref-conflict`.
45
+
46
+ Only a frame of the address the pass drove belongs here. The branch is as
47
+ public as the repository, so a frame of a private or pre-release state would
48
+ be published with it.
49
+
50
+ ## What the record carries
51
+
52
+ `reason` on the record is what a caller branches on, not the exit code:
53
+
54
+ | Reason | What it means |
55
+ | ------------------ | -------------------------------------------------------------------------------- |
56
+ | `ok` | An add pushed, or a drop or prune finished, including one that removed nothing. |
57
+ | `bad-mode` | Not exactly one of `--add`, `--drop`, `--prune`, or a number with `--prune`. |
58
+ | `no-number` | `--add` or `--drop` named no pull request. |
59
+ | `bad-box` | `--add` carried no positive `--box`. |
60
+ | `bad-head` | `--head`, or the head the pull request reported, is not a 7 to 40 character sha. |
61
+ | `bad-pass` | `--add` carried no `--pass` stamp in compact UTC. |
62
+ | `bad-days` | `--prune` carried no positive number of days. |
63
+ | `unreadable-frame` | The `--add` file is missing, empty, or not a PNG. Nothing is called. |
64
+ | `read-only` | GitHub refused a write with 403 or 404, which is what a fork's token gets. |
65
+ | `unreadable-tip` | The branch exists and could not be read. |
66
+ | `push-failed` | A write failed partway and the ref did not move. |
67
+ | `ref-conflict` | Another writer kept moving the ref through every attempt. |
68
+ | `no-object-head` | No `--head` and the pull request reported none. |
69
+ | `gh-missing` | `gh` is not on the path. |
70
+ | `gh-failed` | `gh` could not name the repository or read the pull request. |
71
+
72
+ An add's `ok` record carries `link`, `commit`, `path`, `box`, `head`, `pass`,
73
+ and `created`, which is true when the add made the branch. A drop's carries
74
+ `removed`, the pull requests whose folders went, plus `commit` for the rewrite
75
+ or `deleted` when it emptied the branch. A prune adds `kept` and `unread`.
76
+
77
+ `review-ui` reads every reason but `ok` as a cue to describe the frame in words
78
+ and name the reason, so a refused push never fails the pass.
79
+
80
+ ## Dropping frames
81
+
82
+ A plain delete commit would keep every image reachable through history, so
83
+ `--drop` and `--prune` write one root commit holding everything else and
84
+ force-move the ref to it. A rewrite that leaves the branch empty deletes it,
85
+ and the next add recreates it. Dropping a pull request with no frames, or on a
86
+ missing branch, reports `ok` with nothing removed, so a scheduled run never
87
+ fails on an empty branch.
88
+
89
+ `--prune <days>` reads every `pr-<n>/` folder, asks GitHub for each pull
90
+ request's state, and drops every one reading closed with a close date more
91
+ than that many days ago, in a single rewrite. It keeps one still open, one
92
+ reopened after closing, one closed exactly the given days ago, and one whose
93
+ state could not be read, listing that last kind under `unread`.
94
+
95
+ This repository runs `--prune 30` daily from
96
+ `.github/workflows/pr-frames-cleanup.yml`. A frame's file stays on the branch
97
+ until its own pull request has been closed for 30 days, so a link in a review
98
+ comment keeps resolving for about a month after its pull request closes,
99
+ whatever other pull requests the branch drops in between. A target project
100
+ carries no such workflow yet and keeps its frames until someone runs `--drop`.
101
+
102
+ ## What a rewrite does not do
103
+
104
+ A force-move leaves the old commits unreachable without purging them. GitHub
105
+ serves an unreachable commit by sha until it collects it, so a dropped frame
106
+ stays reachable to anyone holding a sha from an earlier record, and a frame
107
+ holding sensitive content needs a purge request to GitHub. Its path link stops
108
+ resolving at the drop, since the branch no longer carries the file.
109
+
110
+ A drop racing an add can lose the add's frame, since GitHub's ref update has no
111
+ compare-and-swap for a force-move. The drop builds from the tip it reads, and
112
+ the window is the length of one rewrite.
@@ -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
@@ -67,9 +67,9 @@ The record carries every scenario with the arms that declare, plus `totalScenari
67
67
 
68
68
  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
69
 
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.
70
+ `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
71
 
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.
72
+ `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
73
 
74
74
  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
75
 
@@ -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
 
@@ -38,20 +38,21 @@ This page is the corpus the coverage claim is measured against: every name `cano
38
38
 
39
39
  ## Build the feature
40
40
 
41
- | Skill | When to use |
42
- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
43
- | `canon:session-worktree` | At the plan-to-execute boundary, to get an isolated tree and branch, or to route a refused main-root write |
44
- | `canon:auto-ship` | After plan approval, to chain implement, verify, review, draft PR |
45
- | `canon:project-commands` | When the project's own command needs running |
46
- | `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
47
- | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
48
- | `canon:build-in-slices` | While building a plan, to commit each tested slice and note out-of-scope defects rather than fix them |
49
- | `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
50
- | `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
51
- | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
52
- | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
53
- | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
54
- | `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
41
+ | Skill | When to use |
42
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------------- |
43
+ | `canon:session-worktree` | At the plan-to-execute boundary, to get an isolated tree and branch, or to route a refused main-root write |
44
+ | `canon:auto-ship` | After plan approval, to chain implement, verify, review, draft PR |
45
+ | `canon:project-commands` | When the project's own command needs running |
46
+ | `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
47
+ | `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
48
+ | `canon:build-in-slices` | While building a plan, to commit each tested slice and note out-of-scope defects rather than fix them |
49
+ | `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
50
+ | `canon:deprecation-migration` | When deleting or replacing code something may still call, to migrate its callers and prove none remain |
51
+ | `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
52
+ | `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
53
+ | `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
54
+ | `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
55
+ | `canon:ui-checklist` | After a UI change, to write what to look at and name what ships untested |
55
56
 
56
57
  ## Check the work before it leaves the branch
57
58
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "5.13.0",
4
+ "version": "5.14.1",
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",
@@ -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
@@ -22,7 +22,7 @@ Usage: scripts/core/install-check.sh [--keep]
22
22
 
23
23
  Verifies the published install path end to end:
24
24
  1. Packs this repo into a tarball at .canon/tmp/install-check/pack
25
- 2. Extracts it and asserts scripts/sandbox is absent from the tree
25
+ 2. Extracts it and asserts sandbox is absent from the tree
26
26
  3. Runs bun install --production in the extracted tree
27
27
  4. Runs the CLI with --help from the extracted tree to confirm it resolves
28
28
  5. Scaffolds a fresh project in .canon/tmp/install-check/target
@@ -51,6 +51,15 @@ mkdir -p "$PACK_DIR" "$EXTRACT_DIR"
51
51
 
52
52
  open_timeline "Install verification"
53
53
 
54
+ # An absence check over a path the source tree no longer holds passes on
55
+ # nothing, so the excluded tree has to exist here before its absence below can
56
+ # mean the publish left it out.
57
+ log_step "Assert excluded path exists in source"
58
+ if [ ! -f "$PROJECT_ROOT/sandbox/run.sh" ]; then
59
+ log_error "sandbox/run.sh is missing from the source tree, so the absence check below would pass on nothing"
60
+ fi
61
+ log_info "Present: sandbox/run.sh"
62
+
54
63
  log_step "Pack"
55
64
  TARBALL_PATH="$(cd "$PROJECT_ROOT" && bun pm pack --quiet --ignore-scripts --destination "$PACK_DIR" | tail -n 1)"
56
65
  log_info "Packed to $TARBALL_PATH"
@@ -60,10 +69,10 @@ tar -xzf "$TARBALL_PATH" -C "$EXTRACT_DIR" --strip-components=1
60
69
  log_info "Extracted to $EXTRACT_DIR"
61
70
 
62
71
  log_step "Assert excluded path is absent"
63
- if [ -e "$EXTRACT_DIR/scripts/sandbox" ]; then
64
- log_error "scripts/sandbox shipped in the tarball, and the files field in package.json excludes it"
72
+ if [ -e "$EXTRACT_DIR/sandbox" ]; then
73
+ log_error "sandbox shipped in the tarball, and the files field in package.json leaves it out"
65
74
  fi
66
- log_info "Absent: scripts/sandbox"
75
+ log_info "Absent: sandbox"
67
76
 
68
77
  log_step "Install dependencies (production)"
69
78
  (cd "$EXTRACT_DIR" && bun install --production --ignore-scripts --silent 2>&1 | pipe_output) || log_error "bun install --production failed"
@@ -9,7 +9,7 @@
9
9
  # them, so the escape watch reported clean throughout and was right to. The run
10
10
  # spent real cost until a person found it, and `SIGTERM` alone did not end it.
11
11
  #
12
- # Three mechanisms sit here rather than in `scripts/sandbox/run.sh`, which is the
12
+ # Three mechanisms sit here rather than in `sandbox/run.sh`, which is the
13
13
  # only caller. Each is reachable from a test that never launches a session:
14
14
  # `src/sandbox-dispatch.test.ts` drives the shim against a stub binary and the
15
15
  # reap against a `sleep`, where proving either through `run.sh` would mean