@erclx/canon 5.25.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 (315) hide show
  1. package/README.md +4 -3
  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 +14 -1
  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/REQUIREMENT.md +6 -2
  20. package/claude/skills/context-fold/SKILL.md +9 -21
  21. package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
  22. package/claude/skills/context-fold/references/completion-report.md +26 -0
  23. package/claude/skills/create-rule/SKILL.md +2 -0
  24. package/claude/skills/create-skill/SKILL.md +2 -0
  25. package/claude/skills/create-standard/SKILL.md +2 -0
  26. package/claude/skills/decision-escalate/SKILL.md +2 -0
  27. package/claude/skills/deploy-app/SKILL.md +2 -0
  28. package/claude/skills/deprecation-migration/SKILL.md +2 -0
  29. package/claude/skills/design-extract/SKILL.md +2 -0
  30. package/claude/skills/design-taste/REQUIREMENT.md +1 -1
  31. package/claude/skills/design-taste/SKILL.md +6 -2
  32. package/claude/skills/design-taste/references/adopted.md +40 -0
  33. package/claude/skills/design-taste/references/kinds.md +1 -1
  34. package/claude/skills/design-taste/references/systems.md +1 -7
  35. package/claude/skills/design-taste/references/tells.md +2 -24
  36. package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
  37. package/claude/skills/docs-sync/SKILL.md +2 -0
  38. package/claude/skills/document-health/REQUIREMENT.md +2 -0
  39. package/claude/skills/document-health/SKILL.md +23 -0
  40. package/claude/skills/document-health/references/axes.md +12 -0
  41. package/claude/skills/draft-and-pick/SKILL.md +3 -1
  42. package/claude/skills/draft-doc/SKILL.md +2 -0
  43. package/claude/skills/draft-doc/references/wiki.md +1 -1
  44. package/claude/skills/draft-figure/SKILL.md +2 -0
  45. package/claude/skills/draft-identity/SKILL.md +3 -1
  46. package/claude/skills/draft-ready/SKILL.md +2 -0
  47. package/claude/skills/draft-screencast/SKILL.md +11 -7
  48. package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
  49. package/claude/skills/draft-slides/SKILL.md +33 -20
  50. package/claude/skills/git-branch/SKILL.md +2 -0
  51. package/claude/skills/git-commit/SKILL.md +2 -0
  52. package/claude/skills/git-followup/SKILL.md +2 -0
  53. package/claude/skills/git-issue/SKILL.md +2 -0
  54. package/claude/skills/git-issue/references/issue.md +10 -0
  55. package/claude/skills/git-pr/SKILL.md +2 -0
  56. package/claude/skills/git-pr/references/evidence.md +14 -6
  57. package/claude/skills/git-pr/references/labels.md +12 -0
  58. package/claude/skills/git-ship/SKILL.md +2 -0
  59. package/claude/skills/git-split/SKILL.md +21 -8
  60. package/claude/skills/git-stage/SKILL.md +2 -0
  61. package/claude/skills/git-worktree/SKILL.md +2 -0
  62. package/claude/skills/index-lookup/SKILL.md +2 -0
  63. package/claude/skills/markdown-propose/SKILL.md +2 -0
  64. package/claude/skills/markdown-propose/references/format.md +9 -0
  65. package/claude/skills/memory-capture/SKILL.md +2 -0
  66. package/claude/skills/memory-review/SKILL.md +2 -0
  67. package/claude/skills/memory-review/references/later-phases.md +7 -0
  68. package/claude/skills/plan-feature/SKILL.md +2 -0
  69. package/claude/skills/plan-groundwork/SKILL.md +2 -0
  70. package/claude/skills/plan-intake/SKILL.md +2 -0
  71. package/claude/skills/plan-intake-answer/SKILL.md +2 -0
  72. package/claude/skills/project-commands/SKILL.md +2 -0
  73. package/claude/skills/read-frames/SKILL.md +2 -0
  74. package/claude/skills/record-screencast/SKILL.md +2 -0
  75. package/claude/skills/repo-metadata/SKILL.md +2 -0
  76. package/claude/skills/restate-plainly/SKILL.md +2 -0
  77. package/claude/skills/review-address/SKILL.md +3 -7
  78. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  79. package/claude/skills/review-branch/SKILL.md +2 -0
  80. package/claude/skills/review-craft/SKILL.md +2 -0
  81. package/claude/skills/review-craft/references/ui.md +2 -2
  82. package/claude/skills/review-pr/SKILL.md +4 -8
  83. package/claude/skills/review-pr/references/body-variants.md +52 -0
  84. package/claude/skills/review-pr/references/key-changes.md +16 -0
  85. package/claude/skills/review-pr/references/post.md +15 -43
  86. package/claude/skills/review-ui/SKILL.md +3 -1
  87. package/claude/skills/review-ui/references/post.md +4 -2
  88. package/claude/skills/role-orchestrator/SKILL.md +5 -14
  89. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +8 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
  91. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  92. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  93. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
  94. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
  95. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  96. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  97. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  98. package/claude/skills/role-planner/SKILL.md +2 -0
  99. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  100. package/claude/skills/role-reviewer/SKILL.md +2 -0
  101. package/claude/skills/role-worker/SKILL.md +2 -0
  102. package/claude/skills/search-craft/REQUIREMENT.md +1 -1
  103. package/claude/skills/search-craft/SKILL.md +5 -1
  104. package/claude/skills/search-craft/references/adopted.md +30 -0
  105. package/claude/skills/seed-sync/SKILL.md +2 -0
  106. package/claude/skills/session-compact/SKILL.md +2 -0
  107. package/claude/skills/session-map/SKILL.md +2 -0
  108. package/claude/skills/session-relay/SKILL.md +2 -0
  109. package/claude/skills/session-resume/SKILL.md +2 -0
  110. package/claude/skills/session-worktree/SKILL.md +3 -11
  111. package/claude/skills/session-worktree/references/install-command.md +20 -0
  112. package/claude/skills/sketch-design/SKILL.md +2 -0
  113. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  114. package/claude/skills/target-check/SKILL.md +2 -0
  115. package/claude/skills/target-setup/SKILL.md +2 -0
  116. package/claude/skills/target-setup/references/detect.md +8 -0
  117. package/claude/skills/target-setup/references/indexes.md +13 -0
  118. package/claude/skills/target-setup/references/verify.md +8 -0
  119. package/claude/skills/task-board/SKILL.md +2 -0
  120. package/claude/skills/teach-workspace/SKILL.md +5 -15
  121. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  122. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  123. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  124. package/claude/skills/test-craft/SKILL.md +2 -0
  125. package/claude/skills/test-first/SKILL.md +4 -0
  126. package/claude/skills/test-first/references/adopted.md +36 -0
  127. package/claude/skills/ui-checklist/SKILL.md +2 -0
  128. package/claude/skills/ux-audit/SKILL.md +2 -0
  129. package/claude/skills/ux-measure/SKILL.md +2 -0
  130. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  131. package/claude/skills/video-craft/SKILL.md +2 -0
  132. package/claude/skills/write-human/SKILL.md +3 -1
  133. package/claude/skills/write-human/references/machine-tells.md +13 -0
  134. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  135. package/docs/agents/canvas.md +24 -6
  136. package/docs/agents/capture.md +6 -6
  137. package/docs/agents/commands.md +5 -5
  138. package/docs/agents/index.md +5 -5
  139. package/docs/agents/install-and-sync.md +7 -0
  140. package/docs/agents/key-changes.md +1 -1
  141. package/docs/agents/pr-evidence.md +38 -15
  142. package/docs/agents/pr-local.md +19 -13
  143. package/docs/agents/pr-preview.md +44 -21
  144. package/docs/agents/records-stale.md +22 -9
  145. package/docs/agents/records.md +1 -1
  146. package/docs/agents/sandbox.md +29 -5
  147. package/docs/agents/scripting.md +8 -1
  148. package/docs/agents/skills-audit.md +11 -3
  149. package/docs/agents/slides.md +15 -3
  150. package/docs/workflow/ai-workflow.md +1 -1
  151. package/docs/workflow/index.md +1 -0
  152. package/docs/workflow/operating-model.md +5 -6
  153. package/docs/workflow/parallel-features.md +23 -2
  154. package/docs/workflow/skill-map-on-request.md +42 -0
  155. package/docs/workflow/skill-map.md +3 -34
  156. package/docs/workflow/zshrc-aliases.md +1 -1
  157. package/package.json +1 -1
  158. package/scripts/core/regen-hero.ts +1 -1
  159. package/scripts/lib/sandbox-git.sh +1 -9
  160. package/scripts/lib/sandbox-path.sh +6 -162
  161. package/scripts/sandbox-hook.sh +99 -0
  162. package/src/audits/run.ts +1 -1
  163. package/src/canvas/address.ts +12 -3
  164. package/src/canvas/capture.ts +1 -9
  165. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  166. package/src/canvas/client/inspector/color-field.tsx +398 -0
  167. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  168. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  169. package/src/canvas/client/inspector/color-space.ts +240 -0
  170. package/src/canvas/client/inspector/field.tsx +5 -1
  171. package/src/canvas/client/inspector/fields.css +547 -17
  172. package/src/canvas/client/inspector/size-field.tsx +69 -0
  173. package/src/canvas/client/inspector/values.ts +166 -1
  174. package/src/canvas/client/inspector.tsx +236 -73
  175. package/src/canvas/client/selection.tsx +197 -0
  176. package/src/canvas/client/shell.css +104 -12
  177. package/src/canvas/client/state.ts +69 -9
  178. package/src/canvas/client/surface.tsx +147 -22
  179. package/src/canvas/content.ts +50 -1
  180. package/src/canvas/edit.ts +37 -4
  181. package/src/canvas/editing.ts +203 -0
  182. package/src/canvas/global.d.ts +15 -0
  183. package/src/canvas/server.ts +149 -12
  184. package/src/canvas/tokens.ts +59 -17
  185. package/src/capture/render.ts +45 -7
  186. package/src/capture/sources.ts +43 -0
  187. package/src/census/count.ts +2 -2
  188. package/src/claude/cases/workflow.ts +5 -0
  189. package/src/claude/seeds.ts +2 -2
  190. package/src/claude/skills-audit.ts +96 -14
  191. package/src/claude/skills-drift.ts +1 -1
  192. package/src/claude/skills-families.ts +36 -0
  193. package/src/claude/skills-list.ts +76 -15
  194. package/src/commands/audits.ts +2 -2
  195. package/src/commands/canvas.ts +179 -2
  196. package/src/commands/claude.ts +94 -11
  197. package/src/commands/design.ts +142 -2
  198. package/src/commands/feedback.ts +3 -3
  199. package/src/commands/gov.ts +1 -1
  200. package/src/commands/hooks.ts +1 -1
  201. package/src/commands/indexes.ts +1 -1
  202. package/src/commands/init.ts +1 -1
  203. package/src/commands/intake.ts +1 -1
  204. package/src/commands/migrate.ts +1 -1
  205. package/src/commands/pr.ts +105 -58
  206. package/src/commands/records.ts +89 -7
  207. package/src/commands/repo.ts +1 -1
  208. package/src/commands/sandbox.ts +103 -11
  209. package/src/commands/sessions.ts +1 -1
  210. package/src/commands/slides.ts +31 -77
  211. package/src/commands/sync.ts +1 -1
  212. package/src/commands/tasks.ts +2 -2
  213. package/src/commands/teach.ts +1 -1
  214. package/src/commands/transcripts.ts +1 -1
  215. package/src/commands/worktrees.ts +1 -1
  216. package/src/comments/scan.ts +1 -1
  217. package/src/comments/trend.ts +1 -1
  218. package/src/context/audit.ts +1 -1
  219. package/src/context/citations.ts +1 -1
  220. package/src/context/classify/extract.ts +2 -2
  221. package/src/design/project-fonts.ts +392 -0
  222. package/src/design/tokens.ts +2 -18
  223. package/src/docs/read.ts +1 -1
  224. package/src/{github.ts → feedback/github.ts} +2 -2
  225. package/src/{client-commands.ts → gate/client-commands.ts} +1 -1
  226. package/src/gate/measures.ts +53 -13
  227. package/src/gate/sequencer.ts +1 -1
  228. package/src/gate/stages.ts +9 -0
  229. package/src/{git-files.ts → git/files.ts} +2 -2
  230. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  231. package/src/{worktree.ts → git/worktree.ts} +1 -1
  232. package/src/gov/citations.ts +2 -2
  233. package/src/gov/install.ts +1 -1
  234. package/src/gov/payload.ts +1 -1
  235. package/src/gov/superseded.ts +3 -3
  236. package/src/gov/test-order.ts +2 -2
  237. package/src/help.ts +1 -1
  238. package/src/hooks/post-merge.ts +1 -1
  239. package/src/indexes/walk.ts +1 -1
  240. package/src/labels/audit.ts +1 -1
  241. package/src/markdown/ceiling.ts +1 -1
  242. package/src/markdown/files.ts +1 -1
  243. package/src/migrate/apply.ts +1 -1
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +96 -43
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/record-root.ts +1 -0
  249. package/src/records/backup.ts +2 -1
  250. package/src/records/push-guard.ts +1 -1
  251. package/src/records/stale-canonical.ts +146 -0
  252. package/src/records/stale.ts +1 -1
  253. package/src/sandbox/headless/dispatch.ts +264 -0
  254. package/src/sandbox/headless/run.ts +653 -0
  255. package/src/sandbox/headless/watch.ts +145 -0
  256. package/src/sandbox/hooks.ts +128 -0
  257. package/src/sandbox/provision.ts +607 -0
  258. package/src/sandbox/tree.ts +99 -4
  259. package/src/secrets/marker.ts +2 -2
  260. package/src/secrets/scan.ts +2 -2
  261. package/src/sessions/claim.ts +1 -1
  262. package/src/sessions/resolve.ts +1 -1
  263. package/src/sessions/transfer/bundle.ts +1 -1
  264. package/src/shipped/references.ts +2 -2
  265. package/src/slides/convert/export.ts +2 -16
  266. package/src/slides/decks.ts +110 -0
  267. package/src/slides/package/fonts.ts +6 -2
  268. package/src/standards/audit.ts +2 -2
  269. package/src/standards/read.ts +1 -1
  270. package/src/sync/check.ts +2 -2
  271. package/src/sync/engine.ts +2 -2
  272. package/src/sync/git.ts +1 -1
  273. package/src/sync/history.ts +1 -1
  274. package/src/sync/reverse.ts +1 -1
  275. package/src/sync/seeds-report.ts +1 -1
  276. package/src/sync/target.ts +1 -1
  277. package/src/sync/workflow.ts +1 -1
  278. package/src/targets/pulls.ts +2 -2
  279. package/src/targets/sweep.ts +2 -2
  280. package/src/tasks/answers.ts +1 -1
  281. package/src/tasks/archive.ts +1 -1
  282. package/src/tasks/reach.ts +1 -1
  283. package/src/tasks/trunk.ts +1 -1
  284. package/src/teach/nav.ts +1 -1
  285. package/src/tooling/inject.ts +2 -2
  286. package/src/tooling/subfolder.ts +1 -1
  287. package/src/web/readme-citations.ts +2 -2
  288. package/src/worktrees/bare-flag.ts +1 -1
  289. package/src/worktrees/reclaim.ts +2 -2
  290. package/src/worktrees/remove.ts +1 -1
  291. package/standards/architecture.md +1 -0
  292. package/standards/canvas.md +3 -2
  293. package/standards/requirements.md +2 -0
  294. package/standards/skill-practice.md +2 -1
  295. package/standards/skill.md +2 -0
  296. package/standards/wiki.md +12 -3
  297. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  298. package/scripts/lib/sandbox-dispatch.sh +0 -190
  299. package/scripts/manage-sandbox.sh +0 -472
  300. package/src/slides/layouts.ts +0 -478
  301. package/src/slides/parse.ts +0 -84
  302. package/src/slides/render.ts +0 -110
  303. package/src/slides/styles.ts +0 -96
  304. /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
  305. /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
  306. /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
  307. /package/src/{git-base.ts → git/base.ts} +0 -0
  308. /package/src/{git-env.ts → git/env.ts} +0 -0
  309. /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
  310. /package/src/{binary.ts → sweep/binary.ts} +0 -0
  311. /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
  312. /package/src/{copy.ts → sync/copy.ts} +0 -0
  313. /package/src/{target.ts → targets/validate.ts} +0 -0
  314. /package/src/{paths.ts → tasks/paths.ts} +0 -0
  315. /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
@@ -0,0 +1,607 @@
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
+ * A headless run skips it, since a copied body alone shadows the plugin's whole
277
+ * skill folder and its relative citations then resolve to nothing.
278
+ */
279
+ export function injectChangedSkills(
280
+ root: string,
281
+ sandbox: string,
282
+ isSkipAutoCommit: boolean,
283
+ isSkipInject = false,
284
+ ): string[] {
285
+ if (isSkipInject) {
286
+ logInfo(
287
+ 'Skipped dev skill injection: the run resolves skills through --plugin-dir',
288
+ )
289
+ return []
290
+ }
291
+
292
+ const base = (
293
+ readGit(root, ['merge-base', 'HEAD', 'origin/main']) ??
294
+ readGit(root, ['merge-base', 'HEAD', 'main']) ??
295
+ 'main'
296
+ ).trim()
297
+
298
+ const changed =
299
+ readGit(root, ['diff', base, '--name-only', '--', SKILL_PATHSPEC]) ?? ''
300
+ const untracked =
301
+ readGit(root, [
302
+ 'ls-files',
303
+ '--others',
304
+ '--exclude-standard',
305
+ '--',
306
+ SKILL_PATHSPEC,
307
+ ]) ?? ''
308
+ const paths = [
309
+ ...new Set(
310
+ `${changed}\n${untracked}`.split('\n').filter((p) => p.trim() !== ''),
311
+ ),
312
+ ]
313
+
314
+ const injected: string[] = []
315
+ for (const path of paths) {
316
+ // A deleted skill lists beside a changed one, and nothing is left to inject.
317
+ if (!existsSync(join(root, path))) continue
318
+
319
+ const name = basename(dirname(path))
320
+ const target = join(sandbox, '.claude', 'skills', name)
321
+ mkdirSync(target, { recursive: true })
322
+ copyFileSync(join(root, path), join(target, 'SKILL.md'))
323
+ if (isSkipAutoCommit)
324
+ appendFileSync(
325
+ join(sandbox, '.git', 'info', 'exclude'),
326
+ `.claude/skills/${name}/SKILL.md\n`,
327
+ )
328
+ logInfo(`Injected dev skill: ${name}`)
329
+ injected.push(name)
330
+ }
331
+
332
+ return injected
333
+ }
334
+
335
+ /**
336
+ * Commits what the scenario staged, unless it asked to stay uncommitted. A
337
+ * scenario that staged nothing fails the commit, and the status passes through
338
+ * as the bash harness's did.
339
+ */
340
+ export function commitScenarioChanges(
341
+ sandbox: string,
342
+ isSkipAutoCommit: boolean,
343
+ env: NodeJS.ProcessEnv,
344
+ ): number {
345
+ if (isSkipAutoCommit) {
346
+ logInfo('Skipping auto-commit')
347
+ return 0
348
+ }
349
+
350
+ logStep('Staging environment changes')
351
+ const quiet = {
352
+ cwd: sandbox,
353
+ env,
354
+ stdout: 'ignore',
355
+ stderr: 'ignore',
356
+ } as const
357
+ const added = run('git', ['add', '.'], quiet)
358
+ if (added !== 0) return added
359
+ const committed = run(
360
+ 'git',
361
+ [
362
+ 'commit',
363
+ '-m',
364
+ 'chore(sandbox): apply scenario specific setup',
365
+ '--no-verify',
366
+ ],
367
+ { cwd: sandbox, env, stdout: 'ignore' },
368
+ )
369
+ if (committed !== 0) return committed
370
+ logInfo('Git state clean after setup')
371
+
372
+ return 0
373
+ }
374
+
375
+ function tagBaseline(sandbox: string, env: NodeJS.ProcessEnv): void {
376
+ const quiet = {
377
+ cwd: sandbox,
378
+ env,
379
+ stdout: 'ignore',
380
+ stderr: 'ignore',
381
+ } as const
382
+ must(run('git', ['update-ref', 'refs/sandbox/baseline', 'HEAD'], quiet))
383
+ const tree = spawnSync('git', ['write-tree'], {
384
+ cwd: sandbox,
385
+ env,
386
+ encoding: 'utf8',
387
+ stdio: ['ignore', 'pipe', 'inherit'],
388
+ })
389
+ must(tree.status ?? 1)
390
+ must(
391
+ run(
392
+ 'git',
393
+ ['update-ref', 'refs/sandbox/baseline-index', tree.stdout.trim()],
394
+ quiet,
395
+ ),
396
+ )
397
+ }
398
+
399
+ interface Scenario {
400
+ readonly category: string
401
+ readonly command: string
402
+ readonly arm: string | undefined
403
+ }
404
+
405
+ function provisionScenario(
406
+ root: string,
407
+ sandbox: string,
408
+ scenario: Scenario,
409
+ baseEnv: NodeJS.ProcessEnv,
410
+ ): void {
411
+ const env: NodeJS.ProcessEnv = { ...baseEnv }
412
+ if (scenario.arm !== undefined) {
413
+ env.SANDBOX_SCENARIO = scenario.arm
414
+ env.CANON_NON_INTERACTIVE = '1'
415
+ }
416
+
417
+ const sandboxDir = join(root, 'sandbox')
418
+ const file = join(sandboxDir, scenario.category, `${scenario.command}.sh`)
419
+ if (!existsSync(file) || !statSync(file).isFile())
420
+ fail(`Sandbox script not found: ${scenario.category}/${scenario.command}`)
421
+
422
+ const probe = probeScenario(file, env)
423
+ if (!probe.ok) throw new Halt(probe.status)
424
+ Object.assign(env, probe.exports)
425
+
426
+ if (!existsSync(sandboxDir))
427
+ fail(`Sandbox directory not found at: ${sandboxDir}`)
428
+
429
+ const cwd = process.cwd()
430
+ if (cwd === sandbox || cwd.startsWith(`${sandbox}/`)) {
431
+ logWarn(
432
+ 'Detected execution inside the sandbox. Switching to project root...',
433
+ )
434
+ process.chdir(root)
435
+ }
436
+
437
+ logStep(`Provisioning ${scenario.category}:${scenario.command}`)
438
+ if (probe.isAnchor) stageAnchorTree(root, sandbox, env)
439
+ else initEmptyTree(sandbox, env)
440
+ configureCredentials(sandbox, env)
441
+ setupAssets(root, sandbox, env)
442
+
443
+ // A scenario that execs a verb as its last step hands the run to that verb,
444
+ // whose own frame is the last one written, so nothing follows it here.
445
+ const staged = stageScenario(file, env)
446
+ if (staged.ending === 'replaced') throw new Halt(staged.status, false)
447
+ if (staged.ending === 'exited') throw new Halt(staged.status)
448
+ Object.assign(env, staged.exports)
449
+
450
+ const isSkipAutoCommit = (env.SANDBOX_SKIP_AUTO_COMMIT ?? '') !== ''
451
+ const isSkipInject = (env.SANDBOX_SKIP_SKILL_INJECT ?? '') !== ''
452
+ injectChangedSkills(root, sandbox, isSkipAutoCommit, isSkipInject)
453
+ must(commitScenarioChanges(sandbox, isSkipAutoCommit, env))
454
+ tagBaseline(sandbox, env)
455
+
456
+ finish('✓ Sandbox Ready')
457
+ }
458
+
459
+ function cleanSandbox(sandbox: string): void {
460
+ logStep('Removing sandbox')
461
+ rmSync(sandbox, { recursive: true, force: true })
462
+ logRemove(sandbox)
463
+ finish('✓ Sandbox clean')
464
+ }
465
+
466
+ function isAtBaseline(sandbox: string): boolean {
467
+ const head = readGit(sandbox, ['rev-parse', 'HEAD'])
468
+ const baseline = readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline'])
469
+ if (head === undefined || head !== baseline) return false
470
+
471
+ const baselineIndex = readGit(sandbox, [
472
+ 'rev-parse',
473
+ 'refs/sandbox/baseline-index',
474
+ ])
475
+ const index = readGit(sandbox, ['write-tree'])
476
+ if (baselineIndex !== undefined && index !== baselineIndex) return false
477
+
478
+ const quiet = { cwd: sandbox, stdout: 'ignore', stderr: 'ignore' } as const
479
+ if (run('git', ['diff', '--quiet'], quiet) !== 0) return false
480
+
481
+ const untracked = readGit(sandbox, [
482
+ 'ls-files',
483
+ '--others',
484
+ '--exclude-standard',
485
+ ])
486
+
487
+ return untracked === undefined || untracked === ''
488
+ }
489
+
490
+ async function resetSandbox(sandbox: string): Promise<void> {
491
+ if (!existsSync(join(sandbox, '.git')))
492
+ fail('No sandbox found. Run `canon sandbox` first.')
493
+
494
+ logStep('Sandbox state')
495
+
496
+ if (readGit(sandbox, ['rev-parse', 'refs/sandbox/baseline']) === undefined)
497
+ fail('No baseline found. Re-provision with `canon sandbox <cat>:<cmd>`.')
498
+
499
+ if (isAtBaseline(sandbox)) {
500
+ logInfo('At baseline')
501
+ finish('✓ Sandbox at baseline')
502
+ return
503
+ }
504
+
505
+ logWarn('Uncommitted changes detected')
506
+
507
+ const isConfirmed = await select({
508
+ message: 'Reset sandbox to initial state?',
509
+ options: [
510
+ { value: true, label: 'Yes' },
511
+ { value: false, label: 'No' },
512
+ ],
513
+ nonInteractiveDefault: true,
514
+ })
515
+ if (!isConfirmed) {
516
+ logWarn('Reset cancelled')
517
+ outro()
518
+ return
519
+ }
520
+
521
+ logStep('Resetting sandbox')
522
+ const quiet = { cwd: sandbox } as const
523
+ must(
524
+ run('git', ['reset', '--hard', 'refs/sandbox/baseline', '--quiet'], quiet),
525
+ )
526
+ must(run('git', ['clean', '-fd', '--quiet'], quiet))
527
+ const baselineIndex = readGit(sandbox, [
528
+ 'rev-parse',
529
+ 'refs/sandbox/baseline-index',
530
+ ])?.trim()
531
+ const index = readGit(sandbox, ['write-tree'])?.trim()
532
+ if (baselineIndex !== undefined && baselineIndex !== index) {
533
+ must(run('git', ['read-tree', baselineIndex], quiet))
534
+ must(run('git', ['checkout-index', '-a', '-f'], quiet))
535
+ }
536
+ logInfo('Sandbox reset to baseline')
537
+ finish('✓ Sandbox reset complete')
538
+ }
539
+
540
+ /**
541
+ * Splits `<category>:<command>` at the first colon, as `IFS=: read` did, so a
542
+ * command name holding a colon keeps the rest.
543
+ */
544
+ function parseTarget(target: string): { category: string; command: string } {
545
+ const split = target.indexOf(':')
546
+ if (split < 0)
547
+ fail(
548
+ "Invalid format. Use <category>:<command>, 'reset', 'clean', or --help",
549
+ )
550
+
551
+ return { category: target.slice(0, split), command: target.slice(split + 1) }
552
+ }
553
+
554
+ export interface SandboxRequest {
555
+ /** False where a caller already opened the frame, as `run.sh` and the picker do. */
556
+ readonly isHeader: boolean
557
+ /** `<category>:<command>`, `reset`, or `clean`. */
558
+ readonly target: string
559
+ readonly arm: string | undefined
560
+ }
561
+
562
+ /**
563
+ * Runs one `canon sandbox` request and returns its exit status. Every stop
564
+ * closes the frame on the way out, success or not.
565
+ */
566
+ export async function runSandbox(
567
+ root: string,
568
+ request: SandboxRequest,
569
+ ): Promise<number> {
570
+ if (request.isHeader) intro('canon sandbox')
571
+
572
+ try {
573
+ if (!process.cwd().startsWith(root))
574
+ fail(
575
+ 'Context error: you must run this command from inside the toolkit repository.',
576
+ )
577
+
578
+ const sandbox = sandboxTree()
579
+ const unsafe = assertSandboxDirSafe(sandbox, sandboxRoots(root))
580
+ if (unsafe !== undefined) fail(unsafe)
581
+
582
+ if (request.target === 'reset') {
583
+ await resetSandbox(sandbox)
584
+ return 0
585
+ }
586
+
587
+ if (request.target === 'clean') {
588
+ cleanSandbox(sandbox)
589
+ return 0
590
+ }
591
+
592
+ const { category, command } = parseTarget(request.target)
593
+ provisionScenario(
594
+ root,
595
+ sandbox,
596
+ { category, command, arm: request.arm },
597
+ { ...process.env, PROJECT_ROOT: root, GIT_TERMINAL_PROMPT: '0' },
598
+ )
599
+
600
+ return 0
601
+ } catch (error) {
602
+ if (!(error instanceof Halt)) throw error
603
+ if (error.isFrameOpen) outro()
604
+
605
+ return error.status
606
+ }
607
+ }