@erclx/canon 5.26.0 → 5.27.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 (242) hide show
  1. package/README.md +2 -2
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/SKILL.md +2 -0
  4. package/claude/skills/auto-ship/SKILL.md +3 -11
  5. package/claude/skills/auto-ship/references/review-findings.md +20 -0
  6. package/claude/skills/backlog-triage/SKILL.md +2 -0
  7. package/claude/skills/bash-cli-script/SKILL.md +2 -0
  8. package/claude/skills/build-in-slices/SKILL.md +2 -0
  9. package/claude/skills/canon-cli/SKILL.md +2 -0
  10. package/claude/skills/canon-feedback/SKILL.md +2 -0
  11. package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
  12. package/claude/skills/canon-operator/SKILL.md +2 -0
  13. package/claude/skills/canon-rollout/SKILL.md +3 -11
  14. package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
  15. package/claude/skills/canvas/SKILL.md +13 -0
  16. package/claude/skills/ci-workflow/SKILL.md +2 -0
  17. package/claude/skills/code-craft/SKILL.md +2 -0
  18. package/claude/skills/codebase-layout/SKILL.md +2 -0
  19. package/claude/skills/context-fold/SKILL.md +3 -17
  20. package/claude/skills/context-fold/references/completion-report.md +26 -0
  21. package/claude/skills/create-rule/SKILL.md +2 -0
  22. package/claude/skills/create-skill/SKILL.md +2 -0
  23. package/claude/skills/create-standard/SKILL.md +2 -0
  24. package/claude/skills/decision-escalate/SKILL.md +2 -0
  25. package/claude/skills/deploy-app/SKILL.md +2 -0
  26. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  27. package/claude/skills/design-extract/SKILL.md +2 -0
  28. package/claude/skills/design-taste/SKILL.md +2 -0
  29. package/claude/skills/docs-sync/SKILL.md +2 -0
  30. package/claude/skills/document-health/SKILL.md +2 -0
  31. package/claude/skills/draft-and-pick/SKILL.md +2 -0
  32. package/claude/skills/draft-doc/SKILL.md +2 -0
  33. package/claude/skills/draft-doc/references/wiki.md +1 -1
  34. package/claude/skills/draft-figure/SKILL.md +2 -0
  35. package/claude/skills/draft-identity/SKILL.md +2 -0
  36. package/claude/skills/draft-ready/SKILL.md +2 -0
  37. package/claude/skills/draft-screencast/SKILL.md +2 -0
  38. package/claude/skills/draft-slides/SKILL.md +2 -0
  39. package/claude/skills/git-branch/SKILL.md +2 -0
  40. package/claude/skills/git-commit/SKILL.md +2 -0
  41. package/claude/skills/git-followup/SKILL.md +2 -0
  42. package/claude/skills/git-issue/SKILL.md +2 -0
  43. package/claude/skills/git-pr/SKILL.md +2 -0
  44. package/claude/skills/git-pr/references/evidence.md +6 -6
  45. package/claude/skills/git-ship/SKILL.md +2 -0
  46. package/claude/skills/git-split/SKILL.md +2 -0
  47. package/claude/skills/git-stage/SKILL.md +2 -0
  48. package/claude/skills/git-worktree/SKILL.md +2 -0
  49. package/claude/skills/index-lookup/SKILL.md +2 -0
  50. package/claude/skills/markdown-propose/SKILL.md +2 -0
  51. package/claude/skills/memory-capture/SKILL.md +2 -0
  52. package/claude/skills/memory-review/SKILL.md +2 -0
  53. package/claude/skills/plan-feature/SKILL.md +2 -0
  54. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  55. package/claude/skills/plan-intake/SKILL.md +2 -0
  56. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  57. package/claude/skills/project-commands/SKILL.md +2 -0
  58. package/claude/skills/read-frames/SKILL.md +2 -0
  59. package/claude/skills/record-screencast/SKILL.md +2 -0
  60. package/claude/skills/repo-metadata/SKILL.md +2 -0
  61. package/claude/skills/restate-plainly/SKILL.md +2 -0
  62. package/claude/skills/review-address/SKILL.md +3 -7
  63. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  64. package/claude/skills/review-branch/SKILL.md +2 -0
  65. package/claude/skills/review-craft/SKILL.md +2 -0
  66. package/claude/skills/review-craft/references/ui.md +2 -2
  67. package/claude/skills/review-pr/SKILL.md +4 -8
  68. package/claude/skills/review-pr/references/body-variants.md +52 -0
  69. package/claude/skills/review-pr/references/key-changes.md +16 -0
  70. package/claude/skills/review-pr/references/post.md +15 -43
  71. package/claude/skills/review-ui/SKILL.md +3 -1
  72. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  73. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  74. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  75. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  76. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  77. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  78. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  79. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  80. package/claude/skills/role-planner/SKILL.md +2 -0
  81. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  82. package/claude/skills/role-reviewer/SKILL.md +2 -0
  83. package/claude/skills/role-worker/SKILL.md +2 -0
  84. package/claude/skills/search-craft/SKILL.md +2 -0
  85. package/claude/skills/seed-sync/SKILL.md +2 -0
  86. package/claude/skills/session-compact/SKILL.md +2 -0
  87. package/claude/skills/session-map/SKILL.md +2 -0
  88. package/claude/skills/session-relay/SKILL.md +2 -0
  89. package/claude/skills/session-resume/SKILL.md +2 -0
  90. package/claude/skills/session-worktree/SKILL.md +3 -11
  91. package/claude/skills/session-worktree/references/install-command.md +20 -0
  92. package/claude/skills/sketch-design/SKILL.md +2 -0
  93. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  94. package/claude/skills/target-check/SKILL.md +2 -0
  95. package/claude/skills/target-setup/SKILL.md +2 -0
  96. package/claude/skills/task-board/SKILL.md +2 -0
  97. package/claude/skills/teach-workspace/SKILL.md +5 -15
  98. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  99. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  100. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  101. package/claude/skills/test-craft/SKILL.md +2 -0
  102. package/claude/skills/test-first/SKILL.md +2 -0
  103. package/claude/skills/ui-checklist/SKILL.md +2 -0
  104. package/claude/skills/ux-audit/SKILL.md +2 -0
  105. package/claude/skills/ux-measure/SKILL.md +2 -0
  106. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  107. package/claude/skills/video-craft/SKILL.md +2 -0
  108. package/claude/skills/write-human/SKILL.md +2 -0
  109. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  110. package/docs/agents/canvas.md +23 -7
  111. package/docs/agents/capture.md +5 -5
  112. package/docs/agents/commands.md +2 -2
  113. package/docs/agents/index.md +3 -3
  114. package/docs/agents/install-and-sync.md +7 -0
  115. package/docs/agents/key-changes.md +1 -1
  116. package/docs/agents/pr-evidence.md +38 -15
  117. package/docs/agents/pr-local.md +19 -13
  118. package/docs/agents/pr-preview.md +44 -21
  119. package/docs/agents/sandbox.md +28 -4
  120. package/docs/agents/scripting.md +8 -1
  121. package/docs/agents/skills-audit.md +2 -1
  122. package/docs/workflow/operating-model.md +5 -6
  123. package/docs/workflow/parallel-features.md +22 -1
  124. package/docs/workflow/skill-map.md +3 -1
  125. package/docs/workflow/zshrc-aliases.md +1 -1
  126. package/package.json +1 -1
  127. package/scripts/core/regen-hero.ts +1 -1
  128. package/scripts/lib/sandbox-path.sh +6 -162
  129. package/src/audits/run.ts +1 -1
  130. package/src/canvas/address.ts +12 -3
  131. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  132. package/src/canvas/client/inspector/color-field.tsx +201 -171
  133. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  134. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  135. package/src/canvas/client/inspector/color-space.ts +240 -0
  136. package/src/canvas/client/inspector/field.tsx +5 -1
  137. package/src/canvas/client/inspector/fields.css +395 -6
  138. package/src/canvas/client/inspector/size-field.tsx +69 -0
  139. package/src/canvas/client/inspector/values.ts +43 -1
  140. package/src/canvas/client/inspector.tsx +190 -4
  141. package/src/canvas/client/selection.tsx +197 -0
  142. package/src/canvas/client/shell.css +104 -12
  143. package/src/canvas/client/state.ts +69 -9
  144. package/src/canvas/client/surface.tsx +147 -22
  145. package/src/canvas/content.ts +50 -1
  146. package/src/canvas/edit.ts +37 -4
  147. package/src/canvas/editing.ts +203 -0
  148. package/src/canvas/global.d.ts +15 -0
  149. package/src/canvas/server.ts +149 -12
  150. package/src/canvas/tokens.ts +59 -17
  151. package/src/census/count.ts +1 -1
  152. package/src/claude/seeds.ts +1 -1
  153. package/src/claude/skills-audit.ts +28 -0
  154. package/src/claude/skills-drift.ts +1 -1
  155. package/src/claude/skills-families.ts +36 -0
  156. package/src/claude/skills-list.ts +76 -15
  157. package/src/commands/audits.ts +2 -2
  158. package/src/commands/canvas.ts +179 -2
  159. package/src/commands/claude.ts +67 -9
  160. package/src/commands/design.ts +142 -2
  161. package/src/commands/gov.ts +1 -1
  162. package/src/commands/hooks.ts +1 -1
  163. package/src/commands/indexes.ts +1 -1
  164. package/src/commands/init.ts +1 -1
  165. package/src/commands/intake.ts +1 -1
  166. package/src/commands/migrate.ts +1 -1
  167. package/src/commands/pr.ts +105 -58
  168. package/src/commands/records.ts +1 -1
  169. package/src/commands/repo.ts +1 -1
  170. package/src/commands/sandbox.ts +54 -2
  171. package/src/commands/sessions.ts +1 -1
  172. package/src/commands/sync.ts +1 -1
  173. package/src/commands/tasks.ts +2 -2
  174. package/src/commands/teach.ts +1 -1
  175. package/src/commands/transcripts.ts +1 -1
  176. package/src/commands/worktrees.ts +1 -1
  177. package/src/comments/scan.ts +1 -1
  178. package/src/comments/trend.ts +1 -1
  179. package/src/context/citations.ts +1 -1
  180. package/src/context/classify/extract.ts +2 -2
  181. package/src/design/project-fonts.ts +392 -0
  182. package/src/feedback/github.ts +1 -1
  183. package/src/gate/measures.ts +47 -9
  184. package/src/gate/sequencer.ts +1 -1
  185. package/src/gate/stages.ts +9 -0
  186. package/src/{git-files.ts → git/files.ts} +2 -2
  187. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  188. package/src/{worktree.ts → git/worktree.ts} +1 -1
  189. package/src/gov/citations.ts +1 -1
  190. package/src/gov/install.ts +1 -1
  191. package/src/gov/superseded.ts +1 -1
  192. package/src/gov/test-order.ts +2 -2
  193. package/src/help.ts +1 -1
  194. package/src/hooks/post-merge.ts +1 -1
  195. package/src/indexes/walk.ts +1 -1
  196. package/src/labels/audit.ts +1 -1
  197. package/src/markdown/files.ts +1 -1
  198. package/src/migrate/apply.ts +1 -1
  199. package/src/pr/bijection.ts +1 -1
  200. package/src/pr/evidence.ts +96 -43
  201. package/src/pr/local.ts +16 -10
  202. package/src/pr/preview.ts +78 -2
  203. package/src/records/backup.ts +1 -1
  204. package/src/records/stale-canonical.ts +1 -1
  205. package/src/sandbox/headless/dispatch.ts +264 -0
  206. package/src/sandbox/headless/run.ts +653 -0
  207. package/src/sandbox/headless/watch.ts +145 -0
  208. package/src/sandbox/provision.ts +12 -1
  209. package/src/sandbox/tree.ts +4 -8
  210. package/src/secrets/scan.ts +1 -1
  211. package/src/sessions/claim.ts +1 -1
  212. package/src/sessions/resolve.ts +1 -1
  213. package/src/sessions/transfer/bundle.ts +1 -1
  214. package/src/slides/package/fonts.ts +6 -2
  215. package/src/standards/audit.ts +2 -2
  216. package/src/sync/check.ts +2 -2
  217. package/src/sync/engine.ts +2 -2
  218. package/src/sync/git.ts +1 -1
  219. package/src/sync/history.ts +1 -1
  220. package/src/sync/reverse.ts +1 -1
  221. package/src/sync/target.ts +1 -1
  222. package/src/sync/workflow.ts +1 -1
  223. package/src/targets/pulls.ts +2 -2
  224. package/src/targets/sweep.ts +2 -2
  225. package/src/tasks/reach.ts +1 -1
  226. package/src/tasks/trunk.ts +1 -1
  227. package/src/tooling/inject.ts +1 -1
  228. package/src/tooling/subfolder.ts +1 -1
  229. package/src/web/readme-citations.ts +1 -1
  230. package/src/worktrees/bare-flag.ts +1 -1
  231. package/src/worktrees/reclaim.ts +2 -2
  232. package/src/worktrees/remove.ts +1 -1
  233. package/standards/canvas.md +2 -1
  234. package/standards/skill.md +1 -0
  235. package/standards/wiki.md +12 -3
  236. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  237. package/scripts/lib/sandbox-dispatch.sh +0 -190
  238. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  239. /package/src/{git-base.ts → git/base.ts} +0 -0
  240. /package/src/{git-env.ts → git/env.ts} +0 -0
  241. /package/src/{copy.ts → sync/copy.ts} +0 -0
  242. /package/src/{target.ts → targets/validate.ts} +0 -0
@@ -0,0 +1,653 @@
1
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ openSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { constants, tmpdir } from 'node:os'
13
+ import { join, relative } from 'node:path'
14
+ import {
15
+ describeSession,
16
+ installDispatchShim,
17
+ processGroupOf,
18
+ reapProcessGroup,
19
+ sessionsBetween,
20
+ sessionsConcurrent,
21
+ sessionsDir,
22
+ snapshotSessions,
23
+ type ReapState,
24
+ } from '@/sandbox/headless/dispatch'
25
+ import {
26
+ escapeRoots,
27
+ snapshotRoot,
28
+ snapshotTree,
29
+ writesBetween,
30
+ } from '@/sandbox/headless/watch'
31
+ import { mintSandboxRunId, sandboxTree } from '@/sandbox/tree'
32
+ import {
33
+ intro,
34
+ logError,
35
+ logInfo,
36
+ logRemove,
37
+ logStep,
38
+ logWarn,
39
+ outro,
40
+ } from '@/ui'
41
+
42
+ /**
43
+ * The flags and budget every run takes, each overridable through its
44
+ * `CANON_SKILL_TEST_*` variable.
45
+ *
46
+ * `maxTurns` is the only budget. `max_turns` in an arm's `expect.toml` is a
47
+ * ceiling asserted after the run, not a cap enforced during it, so a
48
+ * declaration cannot raise what it runs under. An arm needing more than this
49
+ * truncates, and a truncated run fails the same assertions a reasoning miss
50
+ * does with nothing to separate them.
51
+ *
52
+ * `bypassPermissions` is the only mode that lets a run write under `.claude/`,
53
+ * which most arms need. The scoping the permission layer would have given
54
+ * comes from `write_scope` instead, asserted after the run rather than enforced
55
+ * during it, so a skill that writes where it should not still wrote there.
56
+ */
57
+ export function runSettings(env: NodeJS.ProcessEnv): {
58
+ readonly model: string
59
+ readonly allowedTools: string
60
+ readonly maxTurns: string
61
+ readonly permissionMode: string
62
+ } {
63
+ const read = (name: string, fallback: string): string => {
64
+ const value = env[name]
65
+ return value === undefined || value === '' ? fallback : value
66
+ }
67
+
68
+ return {
69
+ model: read('CANON_SKILL_TEST_MODEL', 'sonnet'),
70
+ allowedTools: read(
71
+ 'CANON_SKILL_TEST_TOOLS',
72
+ 'Bash,Read,Glob,Grep,Edit,Write',
73
+ ),
74
+ maxTurns: read('CANON_SKILL_TEST_MAX_TURNS', '30'),
75
+ permissionMode: read(
76
+ 'CANON_SKILL_TEST_PERMISSION_MODE',
77
+ 'bypassPermissions',
78
+ ),
79
+ }
80
+ }
81
+
82
+ /** Narrows the arguments to a run that can start, or names why they cannot. */
83
+ export function parseRunArgs(
84
+ target: string,
85
+ prompt: string | undefined,
86
+ ):
87
+ | { readonly isValid: true; readonly prompt: string }
88
+ | { readonly isValid: false; readonly reason: string } {
89
+ if (!target.includes(':'))
90
+ return {
91
+ isValid: false,
92
+ reason: 'Invalid target. Use <category>:<command>, e.g. git:commit.',
93
+ }
94
+ if (prompt === undefined || prompt === '')
95
+ return {
96
+ isValid: false,
97
+ reason:
98
+ 'Missing prompt. Pass the skill invocation, e.g. "/canon:git-commit".',
99
+ }
100
+
101
+ return { isValid: true, prompt }
102
+ }
103
+
104
+ /**
105
+ * The four facts about nested sessions, kept in one field so a reader gets
106
+ * them together. `watched` false means the registry was absent, which makes an
107
+ * empty `new` say nothing at all rather than say the run dispatched nothing.
108
+ *
109
+ * `concurrent` is a witness rather than a verdict. A record present before and
110
+ * after the run rules that session out as this run's own dispatch and rules it
111
+ * in as a candidate explanation for a file `escapes` names with no attribution
112
+ * of its own, never a claim that it, rather than something else, made the
113
+ * write.
114
+ *
115
+ * `reap` carries `clear` for a group that was already empty, `reaped-term` or
116
+ * `reaped-kill` for one this run signalled, `survived` for one that outlived
117
+ * `SIGKILL`, `inherited` for a session that never led a group of its own, and
118
+ * `no-session` for a run that stopped before the session began.
119
+ */
120
+ export interface SessionsReport {
121
+ readonly watched: boolean
122
+ readonly new: readonly string[]
123
+ readonly concurrent: readonly string[]
124
+ readonly reap: ReapState | 'inherited' | 'no-session'
125
+ }
126
+
127
+ /**
128
+ * The envelope stays on stdout so existing readers keep working, with the
129
+ * verdict merged in. A session that returned output that does not parse as an
130
+ * object still emits its verdict, since a silent stdout would read as a run
131
+ * that never happened, and `isParsed` false is what forces the exit to 1.
132
+ *
133
+ * An escape rides alongside the verdict rather than inside it. The verdict
134
+ * answers what the sandbox contains, and a file the session put somewhere else
135
+ * is not in that tree to be asserted over.
136
+ */
137
+ export function mergeEnvelope(
138
+ out: string,
139
+ verdict: unknown,
140
+ escapes: readonly string[],
141
+ sessions: SessionsReport,
142
+ ): { readonly merged: Record<string, unknown>; readonly isParsed: boolean } {
143
+ const envelope = parseObject(out)
144
+ if (envelope === undefined)
145
+ return {
146
+ merged: { is_error: true, verdict, escapes, sessions },
147
+ isParsed: false,
148
+ }
149
+
150
+ return {
151
+ merged: { ...envelope, verdict, escapes, sessions },
152
+ isParsed: true,
153
+ }
154
+ }
155
+
156
+ function parseObject(text: string): Record<string, unknown> | undefined {
157
+ try {
158
+ const parsed: unknown = JSON.parse(text)
159
+ return typeof parsed === 'object' &&
160
+ parsed !== null &&
161
+ !Array.isArray(parsed)
162
+ ? (parsed as Record<string, unknown>)
163
+ : undefined
164
+ } catch {
165
+ return undefined
166
+ }
167
+ }
168
+
169
+ /** One envelope field as the result line prints it, `null` when absent. */
170
+ function envelopeField(
171
+ envelope: Record<string, unknown> | undefined,
172
+ key: string,
173
+ fallback: string,
174
+ ): string {
175
+ if (envelope === undefined) return fallback
176
+ const value = envelope[key]
177
+ if (value === undefined || value === null) return 'null'
178
+
179
+ return typeof value === 'string' ? value : JSON.stringify(value)
180
+ }
181
+
182
+ function denialCount(envelope: Record<string, unknown> | undefined): string {
183
+ if (envelope === undefined) return '?'
184
+ const denials = envelope.permission_denials
185
+ if (denials === undefined || denials === null) return '0'
186
+ if (Array.isArray(denials) || typeof denials === 'string')
187
+ return String(denials.length)
188
+ if (typeof denials === 'object') return String(Object.keys(denials).length)
189
+
190
+ return '?'
191
+ }
192
+
193
+ function stamp(now: Date): string {
194
+ const pad = (n: number): string => String(n).padStart(2, '0')
195
+
196
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}T${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
197
+ }
198
+
199
+ function recordPath(
200
+ projectRoot: string,
201
+ target: string,
202
+ arm: string | undefined,
203
+ ): { readonly dir: string; readonly file: string } {
204
+ const dir = join(projectRoot, '.canon/tmp/runs/sandbox')
205
+ const name = `${target}${arm === undefined ? '' : `-${arm}`}`.replaceAll(
206
+ ':',
207
+ '-',
208
+ )
209
+
210
+ return { dir, file: join(dir, `${name}-${stamp(new Date())}.json`) }
211
+ }
212
+
213
+ /**
214
+ * Writes one run record to stderr and disk only, since
215
+ * `docs/agents/output-shape.md` makes stdout the data contract, so a failure
216
+ * here warns and lets the verdict print regardless.
217
+ */
218
+ function writeRecord(
219
+ projectRoot: string,
220
+ target: string,
221
+ arm: string | undefined,
222
+ record: Record<string, unknown>,
223
+ noun: 'run' | 'dead run',
224
+ ): void {
225
+ const { dir, file } = recordPath(projectRoot, target, arm)
226
+ try {
227
+ mkdirSync(dir, { recursive: true })
228
+ } catch {
229
+ logWarn(`Could not create ${dir}. The ${noun} was not recorded.`)
230
+ return
231
+ }
232
+
233
+ try {
234
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
235
+ logInfo(
236
+ `${noun === 'run' ? 'Run' : 'Dead run'} recorded at ${relative(projectRoot, file)}`,
237
+ )
238
+ } catch {
239
+ rmSync(file, { force: true })
240
+ logWarn(`Could not record the ${noun} at ${file}.`)
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Exits through every path out of a run, the way a bash `trap ... EXIT` does:
246
+ * a provision failure, a dead session, a failed verdict, a thrown error, or an
247
+ * interrupt. Each step runs at most once and is synchronous, so a signal
248
+ * handler can run the whole of it before the process exits.
249
+ */
250
+ class RunCleanup {
251
+ reap: SessionsReport['reap'] = 'no-session'
252
+ sessionPgid: number | undefined
253
+ harnessPgid: string | undefined
254
+ shimDir: string | undefined
255
+ workDir: string | undefined
256
+ private isReaped = false
257
+ private isClosed = false
258
+
259
+ /**
260
+ * The one place that decides whether a group is safe to signal, so the
261
+ * ordinary path and an early exit cannot disagree about it. An absent group
262
+ * is `no-session` rather than a clean reap, since the two mean opposite
263
+ * things.
264
+ */
265
+ reapSession(): void {
266
+ if (this.isReaped) return
267
+ this.isReaped = true
268
+ if (this.sessionPgid === undefined) return
269
+
270
+ if (String(this.sessionPgid) === this.harnessPgid) {
271
+ this.reap = 'inherited'
272
+ logWarn(
273
+ "The session ran in this process's own group, so nothing was signalled.",
274
+ )
275
+ return
276
+ }
277
+
278
+ this.reap = reapProcessGroup(this.sessionPgid)
279
+ if (this.reap === 'reaped-term' || this.reap === 'reaped-kill')
280
+ logWarn(
281
+ `The session left a process behind. Reaped its group (${this.reap}).`,
282
+ )
283
+ else if (this.reap === 'survived')
284
+ logWarn(
285
+ `A process in the session's group survived SIGKILL. Check pgid ${this.sessionPgid} by hand.`,
286
+ )
287
+ else if (this.reap === 'refused-own-group')
288
+ logWarn(
289
+ "The reap refused a group matching this process's own. Nothing was signalled.",
290
+ )
291
+ }
292
+
293
+ removeShim(): void {
294
+ if (this.shimDir !== undefined)
295
+ rmSync(this.shimDir, { force: true, recursive: true })
296
+ this.shimDir = undefined
297
+ }
298
+
299
+ close(): void {
300
+ if (this.isClosed) return
301
+ this.isClosed = true
302
+ this.reapSession()
303
+ this.removeShim()
304
+ if (this.workDir !== undefined)
305
+ rmSync(this.workDir, { force: true, recursive: true })
306
+ outro()
307
+ }
308
+ }
309
+
310
+ export interface RunRequest {
311
+ readonly projectRoot: string
312
+ readonly target: string
313
+ readonly prompt: string | undefined
314
+ readonly arm: string | undefined
315
+ }
316
+
317
+ /**
318
+ * Provisions a scenario, drives one `claude -p` session over it, scores the
319
+ * result with `canon sandbox check`, and prints the envelope with the verdict
320
+ * merged in on stdout. Resolves to the exit code: provisioning's or the dead
321
+ * session's when either stopped the run, otherwise the verdict's.
322
+ */
323
+ export async function runHeadless(request: RunRequest): Promise<number> {
324
+ const { projectRoot, target, prompt } = request
325
+ const arm = request.arm === '' ? undefined : request.arm
326
+
327
+ intro('canon skill-test')
328
+ const cleanup = new RunCleanup()
329
+
330
+ // The session leads a group of its own and so takes no terminal signal, which
331
+ // leaves the runner to reap it on the way out.
332
+ const onSignal = (signal: NodeJS.Signals): void => {
333
+ cleanup.close()
334
+ process.exit(128 + constants.signals[signal])
335
+ }
336
+ process.once('SIGINT', onSignal)
337
+ process.once('SIGTERM', onSignal)
338
+
339
+ try {
340
+ return await drive(projectRoot, target, prompt, arm, cleanup)
341
+ } finally {
342
+ process.off('SIGINT', onSignal)
343
+ process.off('SIGTERM', onSignal)
344
+ cleanup.close()
345
+ }
346
+ }
347
+
348
+ async function drive(
349
+ projectRoot: string,
350
+ target: string,
351
+ rawPrompt: string | undefined,
352
+ arm: string | undefined,
353
+ cleanup: RunCleanup,
354
+ ): Promise<number> {
355
+ if (!process.cwd().startsWith(projectRoot)) {
356
+ logError('Run this from inside the toolkit repository.')
357
+ return 1
358
+ }
359
+ const realClaude = Bun.which('claude')
360
+ if (realClaude === null) {
361
+ logError('claude CLI not found on PATH.')
362
+ return 1
363
+ }
364
+ const args = parseRunArgs(target, rawPrompt)
365
+ if (!args.isValid) {
366
+ logError(args.reason)
367
+ return 1
368
+ }
369
+ const { prompt } = args
370
+
371
+ // The agent pushes from inside the sandbox on its own, in the session this
372
+ // spawns, so the guard has to reach that environment and not only
373
+ // provisioning.
374
+ process.env.GIT_TERMINAL_PROMPT = '0'
375
+
376
+ // Minted into this process's env before any child starts, so provisioning,
377
+ // the session, and the check all resolve one tree.
378
+ mintSandboxRunId()
379
+ const settings = runSettings(process.env)
380
+ const cli = join(projectRoot, 'src/cli.ts')
381
+
382
+ const sandbox = sandboxTree()
383
+ logStep(`Sandbox: ${sandbox}`)
384
+
385
+ // The session below loads the branch's skills whole through --plugin-dir. A
386
+ // copied SKILL.md would outrank that and cite references beside it that were
387
+ // never copied, so provisioning injects nothing for a headless run.
388
+ logStep(`Provisioning ${target}`)
389
+ const provision = spawnSync(
390
+ process.execPath,
391
+ [cli, 'sandbox', '--no-header', target, arm ?? ''],
392
+ {
393
+ env: { ...process.env, SANDBOX_SKIP_SKILL_INJECT: '1' },
394
+ stdio: ['inherit', 2, 'inherit'],
395
+ },
396
+ )
397
+ const provisionCode = provision.status ?? 1
398
+ if (provisionCode !== 0) {
399
+ logWarn(
400
+ `Provisioning exited ${provisionCode} before the session could start.`,
401
+ )
402
+ return provisionCode
403
+ }
404
+
405
+ const workDir = mkdtempSync(join(tmpdir(), 'canon-skill-test-'))
406
+ cleanup.workDir = workDir
407
+ const before = snapshotTree(sandbox)
408
+
409
+ let isEscapeWatched = false
410
+ const roots = escapeRoots(projectRoot)
411
+ .filter((root) => existsSync(root))
412
+ .map((root) => {
413
+ const snapshot = snapshotRoot(root)
414
+ isEscapeWatched ||= snapshot.isWatched
415
+ return { root, manifest: snapshot.manifest }
416
+ })
417
+
418
+ // Taken after provisioning, so a scenario that stages a live session record
419
+ // of its own is already in the before snapshot rather than reported as a
420
+ // dispatch this run made. `canon/context/sandbox/isolation.md` carries why
421
+ // such a scenario writes to the real registry at all.
422
+ const registry = sessionsDir()
423
+ const sessionsBefore = snapshotSessions(registry)
424
+
425
+ // The session calls the real binary by its resolved path and puts the shim on
426
+ // the PATH the session inherits, so a `claude --bg` from inside the run meets
427
+ // the refusal and this invocation does not. Routing the session through the
428
+ // shim too would read the arm's own prompt as arguments, so a prompt naming
429
+ // `--bg` would refuse the run the shim exists to allow.
430
+ cleanup.shimDir = mkdtempSync(join(tmpdir(), 'canon-dispatch-shim-'))
431
+ installDispatchShim(cleanup.shimDir, realClaude)
432
+
433
+ logStep(`Running ${prompt} on ${settings.model}`)
434
+
435
+ // Detached, so the session leads a process group of its own and the reap
436
+ // after the verdict signals a group this run created rather than the
437
+ // operator's. Stdout goes to a file rather than a pipe, since a child the
438
+ // session leaves behind would hold a pipe open and stall the read until it
439
+ // exits. Stdin is `/dev/null`, since no command here may wait on a terminal.
440
+ const sessionOut = join(workDir, 'session.out')
441
+ const outFd = openSync(sessionOut, 'w')
442
+ const session: ChildProcess = spawn(
443
+ realClaude,
444
+ [
445
+ '-p',
446
+ prompt,
447
+ '--plugin-dir',
448
+ join(projectRoot, 'claude'),
449
+ '--model',
450
+ settings.model,
451
+ '--output-format',
452
+ 'json',
453
+ '--permission-mode',
454
+ settings.permissionMode,
455
+ '--allowedTools',
456
+ settings.allowedTools,
457
+ '--max-turns',
458
+ settings.maxTurns,
459
+ ],
460
+ {
461
+ cwd: sandbox,
462
+ detached: true,
463
+ env: {
464
+ ...process.env,
465
+ PATH: `${cleanup.shimDir}:${process.env.PATH ?? ''}`,
466
+ },
467
+ stdio: ['ignore', outFd, 'inherit'],
468
+ },
469
+ )
470
+ closeSync(outFd)
471
+
472
+ const exit = new Promise<number>((resolve) => {
473
+ session.once('error', () => resolve(127))
474
+ session.once('exit', (code, signal) =>
475
+ resolve(code ?? 128 + constants.signals[signal ?? 'SIGTERM']),
476
+ )
477
+ })
478
+
479
+ // Read rather than assumed. A detached spawn makes the session lead its own
480
+ // group, so this equals its pid, and comparing it against this process's
481
+ // group before signalling is what keeps the reap off the operator's terminal.
482
+ if (session.pid !== undefined) {
483
+ cleanup.harnessPgid = processGroupOf(process.pid)
484
+ const pgid = processGroupOf(session.pid)
485
+ cleanup.sessionPgid = pgid === undefined ? session.pid : Number(pgid)
486
+ }
487
+
488
+ const sessionCode = await exit
489
+ const out = readFileSync(sessionOut, 'utf8').replace(/\n+$/, '')
490
+
491
+ if (sessionCode !== 0) {
492
+ logWarn(
493
+ `The session exited ${sessionCode} before a verdict could be taken.`,
494
+ )
495
+ // Stamped as text rather than as a parsed envelope, since a dying session's
496
+ // stdout can be partial or not JSON at all, which is why this run has no
497
+ // verdict to attach it to.
498
+ writeRecord(
499
+ projectRoot,
500
+ target,
501
+ arm,
502
+ { is_error: true, exit_code: sessionCode, raw_output: out },
503
+ 'dead run',
504
+ )
505
+ return sessionCode
506
+ }
507
+
508
+ const envelope = parseObject(out)
509
+ const isError = envelopeField(envelope, 'is_error', 'unknown')
510
+ const turns = envelopeField(envelope, 'num_turns', '?')
511
+ const cost = envelopeField(envelope, 'total_cost_usd', '?')
512
+ logStep(
513
+ `Result: error=${isError} turns=${turns} cost=$${cost} denials=${denialCount(envelope)}`,
514
+ )
515
+ process.stderr.write(`${envelopeField(envelope, 'result', '')}\n`)
516
+
517
+ const writes = writesBetween(before, snapshotTree(sandbox))
518
+
519
+ // Taken before the verdict runs, since `canon sandbox check` and the run
520
+ // record both write under a watched root themselves.
521
+ const escapes = roots.flatMap(({ root, manifest }) => {
522
+ const after = snapshotRoot(root)
523
+ isEscapeWatched ||= after.isWatched
524
+ return writesBetween(manifest, after.manifest).map(
525
+ (path) => `${root.replace(/\/+$/, '')}/${path}`,
526
+ )
527
+ })
528
+
529
+ // Beside the escape snapshot and ahead of the reap, so a session that
530
+ // outlived the run is recorded as what it was before anything signals it.
531
+ const sessionsAfter = snapshotSessions(registry)
532
+ const newSessions = sessionsBetween(sessionsBefore, sessionsAfter).map(
533
+ (name) => describeSession(registry, name),
534
+ )
535
+ const concurrent = sessionsConcurrent(sessionsBefore, sessionsAfter).map(
536
+ (name) => describeSession(registry, name),
537
+ )
538
+
539
+ const verdict = takeVerdict(cli, target, arm, workDir, {
540
+ envelope: out,
541
+ writes,
542
+ escapes,
543
+ concurrent,
544
+ isEscapeWatched,
545
+ })
546
+
547
+ // After the verdict, which is the last thing that reads what the run left
548
+ // behind. Nothing here fails the run: a survivor is a fact about the machine
549
+ // rather than a claim about the skill, and the verdict is already taken.
550
+ cleanup.reapSession()
551
+ cleanup.removeShim()
552
+
553
+ // An escape reports without failing, the same relationship `write_scope` has
554
+ // to the writes it names. Nothing here distinguishes the spawned session's
555
+ // writes from the operator's own, and on a machine running parallel sessions
556
+ // the watched directories are rarely quiet: of five runs on 2026-08-02, three
557
+ // reported an escape and every one named a file another session was writing.
558
+ // What enforces the scope is that the sandbox sits outside the repository, so
559
+ // a correct run has nothing to escape with.
560
+ if (escapes.length > 0) {
561
+ logWarn('Shared scratch changed under a toolkit root during this run:')
562
+ for (const path of escapes) logRemove(path)
563
+ if (concurrent.length > 0) {
564
+ logWarn(
565
+ 'A session was live throughout the run and may account for these:',
566
+ )
567
+ for (const record of concurrent) logRemove(record)
568
+ }
569
+ logWarn(
570
+ 'No assertion covers these. Check them against what else was running.',
571
+ )
572
+ }
573
+
574
+ if (newSessions.length > 0) {
575
+ logWarn('A session record appeared while this run was in flight:')
576
+ for (const record of newSessions) logRemove(record)
577
+ logWarn(
578
+ 'No arm dispatches a session. Read these against what else started.',
579
+ )
580
+ }
581
+
582
+ const { merged, isParsed } = mergeEnvelope(out, verdict.record, escapes, {
583
+ watched: sessionsBefore.isWatched || sessionsAfter.isWatched,
584
+ new: newSessions,
585
+ concurrent,
586
+ reap: cleanup.reap,
587
+ })
588
+ if (!isParsed)
589
+ logWarn('Envelope was not valid JSON. Emitting the verdict alone.')
590
+
591
+ writeRecord(projectRoot, target, arm, { ...merged, writes }, 'run')
592
+ cleanup.close()
593
+ process.stdout.write(`${JSON.stringify(merged, null, 2)}\n`)
594
+
595
+ return isParsed ? verdict.code : 1
596
+ }
597
+
598
+ /**
599
+ * The verdict decides the outcome. The envelope can only fail a run the
600
+ * expectations would otherwise have passed, never pass one on its own.
601
+ */
602
+ function takeVerdict(
603
+ cli: string,
604
+ target: string,
605
+ arm: string | undefined,
606
+ workDir: string,
607
+ run: {
608
+ readonly envelope: string
609
+ readonly writes: readonly string[]
610
+ readonly escapes: readonly string[]
611
+ readonly concurrent: readonly string[]
612
+ readonly isEscapeWatched: boolean
613
+ },
614
+ ): { readonly record: unknown; readonly code: number } {
615
+ const file = (name: string, body: string): string => {
616
+ const path = join(workDir, name)
617
+ writeFileSync(path, body, 'utf8')
618
+ return path
619
+ }
620
+ const lines = (list: readonly string[]): string =>
621
+ list.map((line) => `${line}\n`).join('')
622
+
623
+ const check = spawnSync(
624
+ process.execPath,
625
+ [
626
+ cli,
627
+ 'sandbox',
628
+ 'check',
629
+ target,
630
+ arm ?? '',
631
+ '--envelope',
632
+ file('envelope.json', run.envelope),
633
+ '--writes',
634
+ file('writes.txt', lines(run.writes)),
635
+ '--escapes',
636
+ file('escapes.txt', lines(run.escapes)),
637
+ '--concurrent-sessions',
638
+ file('concurrent.txt', lines(run.concurrent)),
639
+ ...(run.isEscapeWatched ? ['--escapes-watched'] : []),
640
+ '--json',
641
+ ],
642
+ { encoding: 'utf8', stdio: ['inherit', 'pipe', 'inherit'] },
643
+ )
644
+
645
+ let record: unknown = null
646
+ try {
647
+ record = JSON.parse(check.stdout ?? '')
648
+ } catch {
649
+ record = null
650
+ }
651
+
652
+ return { record, code: check.status ?? 1 }
653
+ }