@erclx/canon 5.24.0 → 5.26.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 (185) hide show
  1. package/README.md +2 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
  4. package/claude/skills/backlog-triage/SKILL.md +19 -6
  5. package/claude/skills/canvas/REQUIREMENT.md +49 -0
  6. package/claude/skills/canvas/SKILL.md +106 -0
  7. package/claude/skills/context-fold/REQUIREMENT.md +6 -2
  8. package/claude/skills/context-fold/SKILL.md +8 -6
  9. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  10. package/claude/skills/design-extract/REQUIREMENT.md +1 -1
  11. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  12. package/claude/skills/design-taste/SKILL.md +4 -2
  13. package/claude/skills/design-taste/references/adopted.md +40 -0
  14. package/claude/skills/design-taste/references/kinds.md +1 -1
  15. package/claude/skills/design-taste/references/systems.md +1 -7
  16. package/claude/skills/design-taste/references/tells.md +2 -24
  17. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  18. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  19. package/claude/skills/document-health/SKILL.md +21 -0
  20. package/claude/skills/document-health/references/axes.md +12 -0
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
  23. package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
  24. package/claude/skills/draft-figure/SKILL.md +24 -51
  25. package/claude/skills/draft-figure/references/architecture-set.md +35 -0
  26. package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
  27. package/claude/skills/draft-figure/references/mermaid.md +55 -0
  28. package/claude/skills/draft-identity/SKILL.md +1 -1
  29. package/claude/skills/draft-screencast/SKILL.md +9 -7
  30. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  31. package/claude/skills/draft-slides/SKILL.md +31 -20
  32. package/claude/skills/git-followup/SKILL.md +17 -6
  33. package/claude/skills/git-issue/references/issue.md +10 -0
  34. package/claude/skills/git-pr/references/evidence.md +12 -2
  35. package/claude/skills/git-pr/references/labels.md +12 -0
  36. package/claude/skills/git-split/SKILL.md +19 -8
  37. package/claude/skills/index-lookup/SKILL.md +2 -2
  38. package/claude/skills/markdown-propose/references/format.md +9 -0
  39. package/claude/skills/memory-review/references/later-phases.md +7 -0
  40. package/claude/skills/review-address/SKILL.md +6 -13
  41. package/claude/skills/review-address/references/rest-reads.md +43 -0
  42. package/claude/skills/review-craft/references/ui.md +7 -1
  43. package/claude/skills/review-pr/REQUIREMENT.md +1 -0
  44. package/claude/skills/review-pr/SKILL.md +9 -3
  45. package/claude/skills/review-pr/references/close-out.md +2 -2
  46. package/claude/skills/review-pr/references/post.md +17 -5
  47. package/claude/skills/review-pr/references/scope.md +10 -8
  48. package/claude/skills/review-ui/SKILL.md +12 -4
  49. package/claude/skills/review-ui/references/post.md +4 -2
  50. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
  51. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  52. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
  53. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
  54. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
  55. package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
  56. package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
  57. package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
  58. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  59. package/claude/skills/search-craft/SKILL.md +3 -1
  60. package/claude/skills/search-craft/references/adopted.md +30 -0
  61. package/claude/skills/target-setup/references/detect.md +8 -0
  62. package/claude/skills/target-setup/references/indexes.md +13 -0
  63. package/claude/skills/target-setup/references/verify.md +8 -0
  64. package/claude/skills/test-first/SKILL.md +2 -0
  65. package/claude/skills/test-first/references/adopted.md +36 -0
  66. package/claude/skills/write-human/SKILL.md +1 -1
  67. package/claude/skills/write-human/references/machine-tells.md +13 -0
  68. package/docs/agents/canvas.md +4 -2
  69. package/docs/agents/capture.md +1 -1
  70. package/docs/agents/commands.md +47 -47
  71. package/docs/agents/context-audit-checks.md +2 -2
  72. package/docs/agents/index.md +2 -3
  73. package/docs/agents/key-changes.md +3 -3
  74. package/docs/agents/pr-evidence.md +11 -2
  75. package/docs/agents/pr-reads.md +5 -4
  76. package/docs/agents/records-stale.md +22 -9
  77. package/docs/agents/records.md +1 -1
  78. package/docs/agents/sandbox.md +1 -1
  79. package/docs/agents/skills-audit.md +10 -3
  80. package/docs/agents/slides.md +15 -3
  81. package/docs/target/projects.md +4 -4
  82. package/docs/workflow/ai-workflow.md +1 -2
  83. package/docs/workflow/index.md +1 -0
  84. package/docs/workflow/parallel-features.md +1 -1
  85. package/docs/workflow/skill-map-on-request.md +42 -0
  86. package/docs/workflow/skill-map.md +3 -36
  87. package/docs/workflow/visual-design-workflow.md +1 -0
  88. package/governance/renames.toml +0 -4
  89. package/governance/rules/standards/markdown.md +1 -0
  90. package/package.json +1 -2
  91. package/scripts/core/install-check.sh +1 -1
  92. package/scripts/lib/sandbox-dispatch.sh +1 -1
  93. package/scripts/lib/sandbox-git.sh +1 -9
  94. package/scripts/lib/sandbox-path.sh +1 -1
  95. package/scripts/sandbox-hook.sh +99 -0
  96. package/src/audits/catalog.ts +2 -2
  97. package/src/canvas/capture.ts +1 -9
  98. package/src/canvas/client/inspector/color-field.tsx +368 -0
  99. package/src/canvas/client/inspector/field.tsx +200 -0
  100. package/src/canvas/client/inspector/fields.css +292 -0
  101. package/src/canvas/client/inspector/section.tsx +27 -0
  102. package/src/canvas/client/inspector/values.ts +183 -0
  103. package/src/canvas/client/inspector.tsx +176 -160
  104. package/src/canvas/client/shell.css +0 -101
  105. package/src/capture/render.ts +45 -7
  106. package/src/capture/sources.ts +43 -0
  107. package/src/census/count.ts +1 -1
  108. package/src/claude/cases/misc.ts +5 -0
  109. package/src/claude/cases/workflow.ts +6 -1
  110. package/src/claude/seeds.ts +1 -2
  111. package/src/claude/skills-audit.ts +68 -14
  112. package/src/client-commands.ts +1 -1
  113. package/src/commands/claude.ts +27 -2
  114. package/src/commands/design.ts +2 -64
  115. package/src/commands/feedback.ts +3 -3
  116. package/src/commands/pr.ts +219 -202
  117. package/src/commands/records.ts +88 -6
  118. package/src/commands/sandbox.ts +50 -10
  119. package/src/commands/slides.ts +31 -77
  120. package/src/context/audit.ts +8 -7
  121. package/src/design/tokens.ts +2 -18
  122. package/src/docs/read.ts +1 -1
  123. package/src/{github.ts → feedback/github.ts} +1 -1
  124. package/src/gate/measures.ts +6 -4
  125. package/src/gov/citations.ts +1 -1
  126. package/src/gov/payload.ts +1 -1
  127. package/src/gov/superseded.ts +2 -2
  128. package/src/help.ts +2 -2
  129. package/src/markdown/ceiling.ts +1 -1
  130. package/src/migrate/skill-names.ts +7 -1
  131. package/src/pr/evidence.ts +25 -5
  132. package/src/pr/rest.ts +137 -0
  133. package/src/record-root.ts +1 -0
  134. package/src/records/backup.ts +1 -0
  135. package/src/records/push-guard.ts +1 -1
  136. package/src/records/stale-canonical.ts +146 -0
  137. package/src/records/stale.ts +1 -1
  138. package/src/sandbox/hooks.ts +128 -0
  139. package/src/sandbox/provision.ts +596 -0
  140. package/src/sandbox/tree.ts +103 -4
  141. package/src/secrets/marker.ts +2 -2
  142. package/src/secrets/scan.ts +1 -1
  143. package/src/shipped/references.ts +2 -2
  144. package/src/slides/convert/export.ts +2 -16
  145. package/src/slides/decks.ts +110 -0
  146. package/src/standards/read.ts +1 -1
  147. package/src/sync/seeds-report.ts +1 -1
  148. package/src/tasks/answers.ts +1 -1
  149. package/src/tasks/archive.ts +1 -1
  150. package/src/teach/nav.ts +1 -1
  151. package/src/tooling/inject.ts +1 -1
  152. package/src/web/readme-citations.ts +1 -1
  153. package/standards/architecture.md +1 -0
  154. package/standards/canvas.md +112 -0
  155. package/standards/context.md +2 -1
  156. package/standards/figures.md +0 -1
  157. package/standards/index.md +1 -1
  158. package/standards/markdown.md +1 -0
  159. package/standards/mermaid.md +1 -7
  160. package/standards/requirements.md +2 -0
  161. package/standards/skill-practice.md +2 -1
  162. package/standards/skill.md +1 -0
  163. package/tooling/claude/reference.md +0 -5
  164. package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
  165. package/claude/skills/draft-diagram/SKILL.md +0 -177
  166. package/docs/agents/design-board.md +0 -33
  167. package/governance/rules/standards/diagrams.md +0 -14
  168. package/governance/rules/standards/mermaid.md +0 -15
  169. package/scripts/core/check-gallery-exclusion.sh +0 -50
  170. package/scripts/manage-sandbox.sh +0 -472
  171. package/src/design/board.ts +0 -576
  172. package/src/slides/layouts.ts +0 -478
  173. package/src/slides/parse.ts +0 -84
  174. package/src/slides/render.ts +0 -110
  175. package/src/slides/styles.ts +0 -96
  176. package/standards/diagrams.md +0 -132
  177. package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
  178. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  179. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  180. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  181. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  182. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  183. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  184. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  185. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,596 @@
1
+ import { spawnSync, type StdioOptions } from 'node:child_process'
2
+ import {
3
+ appendFileSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { basename, dirname, join, relative } from 'node:path'
13
+ import { probeScenario, stageScenario } from '@/sandbox/hooks'
14
+ import { assertSandboxDirSafe, sandboxRoots, sandboxTree } from '@/sandbox/tree'
15
+ import {
16
+ intro,
17
+ logError,
18
+ logInfo,
19
+ logRemove,
20
+ logStep,
21
+ logWarn,
22
+ outro,
23
+ palette,
24
+ select,
25
+ } from '@/ui'
26
+
27
+ /**
28
+ * Provisions one scenario into the sandbox tree, and resets or cleans that tree.
29
+ * Each scenario's hooks run in bash through `sandbox-hook.sh`, and everything
30
+ * between them runs here, in the order the retired bash dispatcher held.
31
+ *
32
+ * Narration is part of the contract. `canon sandbox equivalence` compares the
33
+ * log line for line against the bash harness, so every message, its frame
34
+ * glyph, and its position in the order stays as that harness wrote it.
35
+ */
36
+
37
+ const ANCHOR_FIXTURE_PARTS = ['sandbox', 'fixtures', 'anchor', 'create']
38
+ const FIXTURE_SUFFIX = '.fixture'
39
+ const SKILL_PATHSPEC = 'claude/skills/**/SKILL.md'
40
+
41
+ /**
42
+ * Ends the run with a status. The frame closes once, at the top, for every
43
+ * stop, which is what the bash harness's `trap close_timeline EXIT` did.
44
+ */
45
+ class Halt extends Error {
46
+ constructor(
47
+ readonly status: number,
48
+ readonly isFrameOpen = true,
49
+ ) {
50
+ super(`halted with ${status}`)
51
+ }
52
+ }
53
+
54
+ function fail(message: string): never {
55
+ logError(message)
56
+ throw new Halt(1)
57
+ }
58
+
59
+ function must(status: number): void {
60
+ if (status !== 0) throw new Halt(status)
61
+ }
62
+
63
+ interface RunOptions {
64
+ readonly cwd?: string
65
+ readonly env?: NodeJS.ProcessEnv
66
+ readonly stdout?: 'ignore' | 'inherit'
67
+ readonly stderr?: 'ignore' | 'inherit'
68
+ }
69
+
70
+ function run(command: string, args: string[], options: RunOptions): number {
71
+ const stdio: StdioOptions = [
72
+ 'ignore',
73
+ options.stdout ?? 'inherit',
74
+ options.stderr ?? 'inherit',
75
+ ]
76
+ const result = spawnSync(command, args, {
77
+ cwd: options.cwd,
78
+ env: options.env,
79
+ stdio,
80
+ })
81
+
82
+ return result.status ?? 1
83
+ }
84
+
85
+ function readGit(cwd: string, args: string[]): string | undefined {
86
+ const result = spawnSync('git', args, {
87
+ cwd,
88
+ encoding: 'utf8',
89
+ stdio: ['ignore', 'pipe', 'ignore'],
90
+ })
91
+
92
+ return result.status === 0 ? result.stdout : undefined
93
+ }
94
+
95
+ function finish(message: string): void {
96
+ const { GREEN, NC } = palette(process.stderr)
97
+ outro()
98
+ process.stderr.write(`\n${GREEN}${message}${NC}\n`)
99
+ }
100
+
101
+ function listFixtureFiles(dir: string): string[] {
102
+ if (!existsSync(dir)) return []
103
+
104
+ return (readdirSync(dir, { recursive: true, encoding: 'utf8' }) as string[])
105
+ .map((path) => join(dir, path))
106
+ .filter((path) => statSync(path).isFile())
107
+ .sort()
108
+ }
109
+
110
+ function stageAnchorTree(
111
+ root: string,
112
+ sandbox: string,
113
+ env: NodeJS.ProcessEnv,
114
+ ): void {
115
+ // The anchor scenarios build their remote URL inside a command substitution,
116
+ // where a failure would leave an empty remote, so the guard fires here first.
117
+ if ((env.GITHUB_ORG ?? '') === '')
118
+ fail(
119
+ 'GITHUB_ORG is empty. Export GITHUB_ORG=<org>, or run from a clone whose remote.origin.url points at GitHub.',
120
+ )
121
+
122
+ logStep('Staging anchor tree from fixture')
123
+
124
+ const fixtureDir = join(root, ...ANCHOR_FIXTURE_PARTS)
125
+ const files = listFixtureFiles(fixtureDir)
126
+ const stray = files.filter((path) => !path.endsWith(FIXTURE_SUFFIX))
127
+ if (stray.length > 0)
128
+ fail(`Fixture missing the ${FIXTURE_SUFFIX} suffix: ${stray.join(' ')} `)
129
+ if (files.length === 0)
130
+ fail(`Anchor fixture provisions nothing: ${fixtureDir}`)
131
+
132
+ rmSync(sandbox, { recursive: true, force: true })
133
+ mkdirSync(sandbox, { recursive: true })
134
+ for (const file of files) {
135
+ const target = join(
136
+ sandbox,
137
+ relative(fixtureDir, file).slice(0, -FIXTURE_SUFFIX.length),
138
+ )
139
+ mkdirSync(dirname(target), { recursive: true })
140
+ copyFileSync(file, target)
141
+ }
142
+
143
+ must(run('git', ['init'], { cwd: sandbox, env, stdout: 'ignore' }))
144
+ must(run('git', ['add', '.'], { cwd: sandbox, env }))
145
+ must(
146
+ run(
147
+ 'git',
148
+ [
149
+ 'commit',
150
+ '-m',
151
+ 'feat(sandbox): initial sandbox setup from anchor',
152
+ '--no-verify',
153
+ ],
154
+ { cwd: sandbox, env, stdout: 'ignore' },
155
+ ),
156
+ )
157
+ logInfo(`Anchor tree staged from fixture, remote stays ${env.ANCHOR_REPO}`)
158
+ }
159
+
160
+ function initEmptyTree(sandbox: string, env: NodeJS.ProcessEnv): void {
161
+ rmSync(sandbox, { recursive: true, force: true })
162
+ mkdirSync(sandbox, { recursive: true })
163
+ writeFileSync(join(sandbox, '.gitignore'), '.canon/tmp/\nnode_modules\n')
164
+
165
+ must(run('git', ['init'], { cwd: sandbox, env, stdout: 'ignore' }))
166
+ must(
167
+ run('git', ['add', '.gitignore'], {
168
+ cwd: sandbox,
169
+ env,
170
+ stdout: 'ignore',
171
+ stderr: 'ignore',
172
+ }),
173
+ )
174
+ must(
175
+ run(
176
+ 'git',
177
+ [
178
+ 'commit',
179
+ '-m',
180
+ 'feat(sandbox): initial empty sandbox setup',
181
+ '--no-verify',
182
+ ],
183
+ { cwd: sandbox, env, stdout: 'ignore' },
184
+ ),
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Scoped to the throwaway tree so the operator's global config is never
190
+ * written. The empty value resets the helper list git concatenates across
191
+ * scopes, so an operator's stale helper cannot win ahead of `gh`.
192
+ */
193
+ function configureCredentials(sandbox: string, env: NodeJS.ProcessEnv): void {
194
+ must(run('git', ['config', 'credential.helper', ''], { cwd: sandbox, env }))
195
+ must(
196
+ run(
197
+ 'git',
198
+ ['config', '--add', 'credential.helper', '!gh auth git-credential'],
199
+ { cwd: sandbox, env },
200
+ ),
201
+ )
202
+ }
203
+
204
+ /**
205
+ * Runs the real installer rather than copying the source tree, so a change to
206
+ * what install produces reaches the sandbox. Its own frame stays captured
207
+ * unless it fails, where it is the only thing naming the cause.
208
+ */
209
+ function install(
210
+ root: string,
211
+ label: string,
212
+ args: string[],
213
+ env: NodeJS.ProcessEnv,
214
+ ): void {
215
+ const result = spawnSync('bun', [join(root, 'src', 'cli.ts'), ...args], {
216
+ env: { ...env, CANON_NON_INTERACTIVE: '1' },
217
+ encoding: 'utf8',
218
+ stdio: ['ignore', 'ignore', 'pipe'],
219
+ })
220
+ if (result.status === 0) return
221
+
222
+ process.stderr.write(result.stderr ?? '')
223
+ fail(`Could not install ${label} into the sandbox.`)
224
+ }
225
+
226
+ /**
227
+ * No standards install. The corpus installs into no target, so a scenario
228
+ * driving a skill that reads one takes the `canon standards <name>` path a real
229
+ * project takes.
230
+ */
231
+ function setupAssets(
232
+ root: string,
233
+ sandbox: string,
234
+ env: NodeJS.ProcessEnv,
235
+ ): void {
236
+ if (
237
+ (env.SANDBOX_INJECT_SEEDS ?? '') !== '' &&
238
+ existsSync(join(root, 'tooling', 'claude', 'seeds'))
239
+ )
240
+ install(root, 'seeds', ['claude', 'init', sandbox], env)
241
+
242
+ if (
243
+ (env.SANDBOX_INJECT_GOV ?? '') !== '' &&
244
+ existsSync(join(root, 'governance', 'rules'))
245
+ )
246
+ install(
247
+ root,
248
+ 'gov rules',
249
+ ['gov', 'install', env.SANDBOX_GOV_STACK || 'base', sandbox],
250
+ env,
251
+ )
252
+
253
+ must(run('git', ['add', '.'], { cwd: sandbox, env }))
254
+ const isStaged =
255
+ run('git', ['diff', '--cached', '--quiet'], { cwd: sandbox, env }) !== 0
256
+ if (isStaged)
257
+ must(
258
+ run(
259
+ 'git',
260
+ [
261
+ 'commit',
262
+ '-m',
263
+ 'chore(sandbox): initial environment setup',
264
+ '--no-verify',
265
+ ],
266
+ { cwd: sandbox, env, stdout: 'ignore' },
267
+ ),
268
+ )
269
+ }
270
+
271
+ /**
272
+ * Copies every skill body this checkout changed against the merge base into the
273
+ * tree, so a scenario exercises the branch's skill rather than the installed
274
+ * plugin's. The merge base against `origin/main` comes first, so a local `main`
275
+ * trailing the remote does not pull in skills other merged branches changed.
276
+ */
277
+ export function injectChangedSkills(
278
+ root: string,
279
+ sandbox: string,
280
+ isSkipAutoCommit: boolean,
281
+ ): string[] {
282
+ const base = (
283
+ readGit(root, ['merge-base', 'HEAD', 'origin/main']) ??
284
+ readGit(root, ['merge-base', 'HEAD', 'main']) ??
285
+ 'main'
286
+ ).trim()
287
+
288
+ const changed =
289
+ readGit(root, ['diff', base, '--name-only', '--', SKILL_PATHSPEC]) ?? ''
290
+ const untracked =
291
+ readGit(root, [
292
+ 'ls-files',
293
+ '--others',
294
+ '--exclude-standard',
295
+ '--',
296
+ SKILL_PATHSPEC,
297
+ ]) ?? ''
298
+ const paths = [
299
+ ...new Set(
300
+ `${changed}\n${untracked}`.split('\n').filter((p) => p.trim() !== ''),
301
+ ),
302
+ ]
303
+
304
+ const injected: string[] = []
305
+ for (const path of paths) {
306
+ // A deleted skill lists beside a changed one, and nothing is left to inject.
307
+ if (!existsSync(join(root, path))) continue
308
+
309
+ const name = basename(dirname(path))
310
+ const target = join(sandbox, '.claude', 'skills', name)
311
+ mkdirSync(target, { recursive: true })
312
+ copyFileSync(join(root, path), join(target, 'SKILL.md'))
313
+ if (isSkipAutoCommit)
314
+ appendFileSync(
315
+ join(sandbox, '.git', 'info', 'exclude'),
316
+ `.claude/skills/${name}/SKILL.md\n`,
317
+ )
318
+ logInfo(`Injected dev skill: ${name}`)
319
+ injected.push(name)
320
+ }
321
+
322
+ return injected
323
+ }
324
+
325
+ /**
326
+ * Commits what the scenario staged, unless it asked to stay uncommitted. A
327
+ * scenario that staged nothing fails the commit, and the status passes through
328
+ * as the bash harness's did.
329
+ */
330
+ export function commitScenarioChanges(
331
+ sandbox: string,
332
+ isSkipAutoCommit: boolean,
333
+ env: NodeJS.ProcessEnv,
334
+ ): number {
335
+ if (isSkipAutoCommit) {
336
+ logInfo('Skipping auto-commit')
337
+ return 0
338
+ }
339
+
340
+ logStep('Staging environment changes')
341
+ const quiet = {
342
+ cwd: sandbox,
343
+ env,
344
+ stdout: 'ignore',
345
+ stderr: 'ignore',
346
+ } as const
347
+ const added = run('git', ['add', '.'], quiet)
348
+ if (added !== 0) return added
349
+ const committed = run(
350
+ 'git',
351
+ [
352
+ 'commit',
353
+ '-m',
354
+ 'chore(sandbox): apply scenario specific setup',
355
+ '--no-verify',
356
+ ],
357
+ { cwd: sandbox, env, stdout: 'ignore' },
358
+ )
359
+ if (committed !== 0) return committed
360
+ logInfo('Git state clean after setup')
361
+
362
+ return 0
363
+ }
364
+
365
+ function tagBaseline(sandbox: string, env: NodeJS.ProcessEnv): void {
366
+ const quiet = {
367
+ cwd: sandbox,
368
+ env,
369
+ stdout: 'ignore',
370
+ stderr: 'ignore',
371
+ } as const
372
+ must(run('git', ['update-ref', 'refs/sandbox/baseline', 'HEAD'], quiet))
373
+ const tree = spawnSync('git', ['write-tree'], {
374
+ cwd: sandbox,
375
+ env,
376
+ encoding: 'utf8',
377
+ stdio: ['ignore', 'pipe', 'inherit'],
378
+ })
379
+ must(tree.status ?? 1)
380
+ must(
381
+ run(
382
+ 'git',
383
+ ['update-ref', 'refs/sandbox/baseline-index', tree.stdout.trim()],
384
+ quiet,
385
+ ),
386
+ )
387
+ }
388
+
389
+ interface Scenario {
390
+ readonly category: string
391
+ readonly command: string
392
+ readonly arm: string | undefined
393
+ }
394
+
395
+ function provisionScenario(
396
+ root: string,
397
+ sandbox: string,
398
+ scenario: Scenario,
399
+ baseEnv: NodeJS.ProcessEnv,
400
+ ): void {
401
+ const env: NodeJS.ProcessEnv = { ...baseEnv }
402
+ if (scenario.arm !== undefined) {
403
+ env.SANDBOX_SCENARIO = scenario.arm
404
+ env.CANON_NON_INTERACTIVE = '1'
405
+ }
406
+
407
+ const sandboxDir = join(root, 'sandbox')
408
+ const file = join(sandboxDir, scenario.category, `${scenario.command}.sh`)
409
+ if (!existsSync(file) || !statSync(file).isFile())
410
+ fail(`Sandbox script not found: ${scenario.category}/${scenario.command}`)
411
+
412
+ const probe = probeScenario(file, env)
413
+ if (!probe.ok) throw new Halt(probe.status)
414
+ Object.assign(env, probe.exports)
415
+
416
+ if (!existsSync(sandboxDir))
417
+ fail(`Sandbox directory not found at: ${sandboxDir}`)
418
+
419
+ const cwd = process.cwd()
420
+ if (cwd === sandbox || cwd.startsWith(`${sandbox}/`)) {
421
+ logWarn(
422
+ 'Detected execution inside the sandbox. Switching to project root...',
423
+ )
424
+ process.chdir(root)
425
+ }
426
+
427
+ logStep(`Provisioning ${scenario.category}:${scenario.command}`)
428
+ if (probe.isAnchor) stageAnchorTree(root, sandbox, env)
429
+ else initEmptyTree(sandbox, env)
430
+ configureCredentials(sandbox, env)
431
+ setupAssets(root, sandbox, env)
432
+
433
+ // A scenario that execs a verb as its last step hands the run to that verb,
434
+ // whose own frame is the last one written, so nothing follows it here.
435
+ const staged = stageScenario(file, env)
436
+ if (staged.ending === 'replaced') throw new Halt(staged.status, false)
437
+ if (staged.ending === 'exited') throw new Halt(staged.status)
438
+ Object.assign(env, staged.exports)
439
+
440
+ const isSkipAutoCommit = (env.SANDBOX_SKIP_AUTO_COMMIT ?? '') !== ''
441
+ injectChangedSkills(root, sandbox, isSkipAutoCommit)
442
+ must(commitScenarioChanges(sandbox, isSkipAutoCommit, env))
443
+ tagBaseline(sandbox, env)
444
+
445
+ finish('✓ Sandbox Ready')
446
+ }
447
+
448
+ function cleanSandbox(sandbox: string): void {
449
+ logStep('Removing sandbox')
450
+ rmSync(sandbox, { recursive: true, force: true })
451
+ logRemove(sandbox)
452
+ finish('✓ Sandbox clean')
453
+ }
454
+
455
+ function isAtBaseline(sandbox: string): boolean {
456
+ const head = readGit(sandbox, ['rev-parse', 'HEAD'])
457
+ const baseline = readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline'])
458
+ if (head === undefined || head !== baseline) return false
459
+
460
+ const baselineIndex = readGit(sandbox, [
461
+ 'rev-parse',
462
+ 'refs/sandbox/baseline-index',
463
+ ])
464
+ const index = readGit(sandbox, ['write-tree'])
465
+ if (baselineIndex !== undefined && index !== baselineIndex) return false
466
+
467
+ const quiet = { cwd: sandbox, stdout: 'ignore', stderr: 'ignore' } as const
468
+ if (run('git', ['diff', '--quiet'], quiet) !== 0) return false
469
+
470
+ const untracked = readGit(sandbox, [
471
+ 'ls-files',
472
+ '--others',
473
+ '--exclude-standard',
474
+ ])
475
+
476
+ return untracked === undefined || untracked === ''
477
+ }
478
+
479
+ async function resetSandbox(sandbox: string): Promise<void> {
480
+ if (!existsSync(join(sandbox, '.git')))
481
+ fail('No sandbox found. Run `canon sandbox` first.')
482
+
483
+ logStep('Sandbox state')
484
+
485
+ if (readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline']) === undefined)
486
+ fail('No baseline found. Re-provision with `canon sandbox <cat>:<cmd>`.')
487
+
488
+ if (isAtBaseline(sandbox)) {
489
+ logInfo('At baseline')
490
+ finish('✓ Sandbox at baseline')
491
+ return
492
+ }
493
+
494
+ logWarn('Uncommitted changes detected')
495
+
496
+ const isConfirmed = await select({
497
+ message: 'Reset sandbox to initial state?',
498
+ options: [
499
+ { value: true, label: 'Yes' },
500
+ { value: false, label: 'No' },
501
+ ],
502
+ nonInteractiveDefault: true,
503
+ })
504
+ if (!isConfirmed) {
505
+ logWarn('Reset cancelled')
506
+ outro()
507
+ return
508
+ }
509
+
510
+ logStep('Resetting sandbox')
511
+ const quiet = { cwd: sandbox } as const
512
+ must(
513
+ run('git', ['reset', '--hard', 'refs/sandbox/baseline', '--quiet'], quiet),
514
+ )
515
+ must(run('git', ['clean', '-fd', '--quiet'], quiet))
516
+ const baselineIndex = readGit(sandbox, [
517
+ 'rev-parse',
518
+ 'refs/sandbox/baseline-index',
519
+ ])?.trim()
520
+ const index = readGit(sandbox, ['write-tree'])?.trim()
521
+ if (baselineIndex !== undefined && baselineIndex !== index) {
522
+ must(run('git', ['read-tree', baselineIndex], quiet))
523
+ must(run('git', ['checkout-index', '-a', '-f'], quiet))
524
+ }
525
+ logInfo('Sandbox reset to baseline')
526
+ finish('✓ Sandbox reset complete')
527
+ }
528
+
529
+ /**
530
+ * Splits `<category>:<command>` at the first colon, as `IFS=: read` did, so a
531
+ * command name holding a colon keeps the rest.
532
+ */
533
+ function parseTarget(target: string): { category: string; command: string } {
534
+ const split = target.indexOf(':')
535
+ if (split < 0)
536
+ fail(
537
+ "Invalid format. Use <category>:<command>, 'reset', 'clean', or --help",
538
+ )
539
+
540
+ return { category: target.slice(0, split), command: target.slice(split + 1) }
541
+ }
542
+
543
+ export interface SandboxRequest {
544
+ /** False where a caller already opened the frame, as `run.sh` and the picker do. */
545
+ readonly isHeader: boolean
546
+ /** `<category>:<command>`, `reset`, or `clean`. */
547
+ readonly target: string
548
+ readonly arm: string | undefined
549
+ }
550
+
551
+ /**
552
+ * Runs one `canon sandbox` request and returns its exit status. Every stop
553
+ * closes the frame on the way out, success or not.
554
+ */
555
+ export async function runSandbox(
556
+ root: string,
557
+ request: SandboxRequest,
558
+ ): Promise<number> {
559
+ if (request.isHeader) intro('canon sandbox')
560
+
561
+ try {
562
+ if (!process.cwd().startsWith(root))
563
+ fail(
564
+ 'Context error: you must run this command from inside the toolkit repository.',
565
+ )
566
+
567
+ const sandbox = sandboxTree()
568
+ const unsafe = assertSandboxDirSafe(sandbox, sandboxRoots(root))
569
+ if (unsafe !== undefined) fail(unsafe)
570
+
571
+ if (request.target === 'reset') {
572
+ await resetSandbox(sandbox)
573
+ return 0
574
+ }
575
+
576
+ if (request.target === 'clean') {
577
+ cleanSandbox(sandbox)
578
+ return 0
579
+ }
580
+
581
+ const { category, command } = parseTarget(request.target)
582
+ provisionScenario(
583
+ root,
584
+ sandbox,
585
+ { category, command, arm: request.arm },
586
+ { ...process.env, PROJECT_ROOT: root, GIT_TERMINAL_PROMPT: '0' },
587
+ )
588
+
589
+ return 0
590
+ } catch (error) {
591
+ if (!(error instanceof Halt)) throw error
592
+ if (error.isFrameOpen) outro()
593
+
594
+ return error.status
595
+ }
596
+ }
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from 'node:child_process'
1
2
  import { randomBytes } from 'node:crypto'
2
3
  import { homedir } from 'node:os'
3
4
  import { join } from 'node:path'
@@ -5,10 +6,11 @@ import { join } from 'node:path'
5
6
  /**
6
7
  * Mints a short per-run identifier the first time it is asked for and holds it
7
8
  * in `CANON_SANDBOX_RUN_ID` for the rest of this process, so a script that
8
- * spawns a child inheriting `process.env` — `run.sh` calling `manage-sandbox.sh`
9
- * and then `canon sandbox check` — resolves the same tree in every one of them.
10
- * A process that already carries the variable, inherited from such a parent,
11
- * reuses it rather than minting a new one.
9
+ * spawns a child inheriting `process.env` — `run.sh` calling `canon sandbox` to
10
+ * provision and then `canon sandbox check` — resolves the same tree in every one
11
+ * of them, as do the hook and installer children provisioning spawns. A process
12
+ * that already carries the variable, inherited from such a parent, reuses it
13
+ * rather than minting a new one.
12
14
  *
13
15
  * Twin of `mint_sandbox_run_id` in `scripts/lib/sandbox-path.sh`.
14
16
  */
@@ -45,3 +47,100 @@ export function sandboxTree(): string {
45
47
 
46
48
  return join(base, 'canon', `sandbox-${mintSandboxRunId()}`)
47
49
  }
50
+
51
+ /**
52
+ * Twin of `normalize_sandbox_path` in `scripts/lib/sandbox-path.sh`, which the
53
+ * headless runner still sources. Lexical rather than `realpath`, since the guard
54
+ * runs before the tree exists, so nothing here follows a symlink.
55
+ */
56
+ export function normalizeSandboxPath(path: string): string {
57
+ if (path === '') return ''
58
+
59
+ const isAbsolute = path.startsWith('/')
60
+ const resolved: string[] = []
61
+ let climbed = ''
62
+
63
+ for (const segment of path.split('/')) {
64
+ if (segment === '' || segment === '.') continue
65
+ if (segment !== '..') resolved.push(segment)
66
+ else if (resolved.length > 0) resolved.pop()
67
+ else if (!isAbsolute) climbed += '../'
68
+ }
69
+
70
+ if (isAbsolute) return `/${resolved.join('/')}`
71
+
72
+ const relative = `${climbed}${resolved.join('/')}`.replace(/\/+$/, '')
73
+
74
+ return relative === '' ? '.' : relative
75
+ }
76
+
77
+ /** Whether `candidate` is `target` or a directory containing it. */
78
+ function isAtOrAbove(candidate: string, target: string): boolean {
79
+ return candidate === target || target.startsWith(`${candidate}/`)
80
+ }
81
+
82
+ export interface SandboxRoots {
83
+ readonly home: string
84
+ readonly temp: string
85
+ readonly mainRoot: string
86
+ }
87
+
88
+ /**
89
+ * The roots the guard measures against. The repository test reads the main
90
+ * worktree rather than `PROJECT_ROOT`, since a linked worktree sits inside it.
91
+ */
92
+ export function sandboxRoots(projectRoot: string): SandboxRoots {
93
+ const listing = spawnSync('git', ['worktree', 'list', '--porcelain'], {
94
+ cwd: projectRoot,
95
+ encoding: 'utf8',
96
+ })
97
+ const line = (listing.stdout ?? '')
98
+ .split('\n')
99
+ .find((l) => l.startsWith('worktree '))
100
+
101
+ return {
102
+ home: process.env.HOME || '/root',
103
+ temp: process.env.TMPDIR || '/tmp',
104
+ mainRoot: line === undefined ? projectRoot : line.slice('worktree '.length),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns the reason a location cannot hold the tree, or undefined when it can.
110
+ * Provisioning removes the tree before staging, so the test is an allowlist: a
111
+ * strict descendant of the home folder or the temp root, outside the main
112
+ * worktree in both directions.
113
+ *
114
+ * Twin of `assert_sandbox_dir_safe` in `scripts/lib/sandbox-path.sh`, message
115
+ * for message, since the headless runner still guards through that copy.
116
+ */
117
+ export function assertSandboxDirSafe(
118
+ raw: string,
119
+ roots: SandboxRoots,
120
+ ): string | undefined {
121
+ if (!raw.startsWith('/'))
122
+ return `CANON_SANDBOX_DIR must be an absolute path, got: ${raw || '<empty>'}`
123
+
124
+ if (raw.length > 4096)
125
+ return `Refusing the sandbox path. It is ${raw.length} characters, past the longest path any filesystem here accepts.`
126
+
127
+ const dir = normalizeSandboxPath(raw)
128
+ const home = normalizeSandboxPath(roots.home)
129
+ const temp = normalizeSandboxPath(roots.temp)
130
+ const mainRoot = normalizeSandboxPath(roots.mainRoot)
131
+ const resolution = dir === raw ? '' : ` It resolves to ${dir}.`
132
+
133
+ if (!isAtOrAbove(home, dir) && !isAtOrAbove(temp, dir))
134
+ return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, so the path has to sit under ${home} or ${temp}.${resolution}`
135
+
136
+ if (dir === home || dir === temp)
137
+ return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, so the path has to sit under ${dir} rather than be it.`
138
+
139
+ if (isAtOrAbove(dir, mainRoot))
140
+ return `Refusing ${raw} as the sandbox. Provisioning removes the tree first, and that path contains ${mainRoot}.${resolution}`
141
+
142
+ if (dir.startsWith(`${mainRoot}/`))
143
+ return `Sandbox at ${raw} sits inside ${mainRoot}, which puts the toolkit CLAUDE.md back on the session ancestor chain.${resolution} Point CANON_SANDBOX_DIR outside the repository.`
144
+
145
+ return undefined
146
+ }