@erclx/canon 5.26.0 → 5.28.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 (320) 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 +5 -1
  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 +7 -7
  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/REQUIREMENT.md +4 -2
  63. package/claude/skills/review-address/SKILL.md +19 -14
  64. package/claude/skills/review-address/references/post-review-findings.md +16 -0
  65. package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
  66. package/claude/skills/review-branch/SKILL.md +2 -0
  67. package/claude/skills/review-craft/SKILL.md +2 -0
  68. package/claude/skills/review-craft/references/ui.md +6 -6
  69. package/claude/skills/review-pr/SKILL.md +4 -8
  70. package/claude/skills/review-pr/references/body-variants.md +52 -0
  71. package/claude/skills/review-pr/references/key-changes.md +16 -0
  72. package/claude/skills/review-pr/references/post.md +15 -43
  73. package/claude/skills/review-ui/REQUIREMENT.md +2 -2
  74. package/claude/skills/review-ui/SKILL.md +16 -12
  75. package/claude/skills/review-ui/references/driving.md +8 -8
  76. package/claude/skills/review-ui/references/post.md +13 -3
  77. package/claude/skills/review-ui/references/tick.md +22 -0
  78. package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
  79. package/claude/skills/role-orchestrator/SKILL.md +31 -42
  80. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
  81. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
  82. package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
  83. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
  84. package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
  85. package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
  86. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
  87. package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
  88. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
  89. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
  90. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
  91. package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
  92. package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
  93. package/claude/skills/role-planner/REQUIREMENT.md +1 -1
  94. package/claude/skills/role-planner/SKILL.md +4 -2
  95. package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
  96. package/claude/skills/role-reviewer/SKILL.md +3 -1
  97. package/claude/skills/role-worker/SKILL.md +5 -2
  98. package/claude/skills/search-craft/SKILL.md +2 -0
  99. package/claude/skills/seed-sync/SKILL.md +2 -0
  100. package/claude/skills/session-compact/SKILL.md +2 -0
  101. package/claude/skills/session-map/SKILL.md +2 -0
  102. package/claude/skills/session-relay/SKILL.md +2 -0
  103. package/claude/skills/session-resume/SKILL.md +2 -0
  104. package/claude/skills/session-worktree/SKILL.md +3 -11
  105. package/claude/skills/session-worktree/references/install-command.md +20 -0
  106. package/claude/skills/sketch-design/SKILL.md +2 -0
  107. package/claude/skills/systematic-debugging/SKILL.md +2 -0
  108. package/claude/skills/target-check/SKILL.md +2 -0
  109. package/claude/skills/target-setup/SKILL.md +2 -0
  110. package/claude/skills/task-board/SKILL.md +2 -0
  111. package/claude/skills/teach-workspace/SKILL.md +5 -15
  112. package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
  113. package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
  114. package/claude/skills/teach-workspace/references/revisit.md +22 -0
  115. package/claude/skills/test-craft/SKILL.md +2 -0
  116. package/claude/skills/test-first/SKILL.md +2 -0
  117. package/claude/skills/ui-checklist/SKILL.md +3 -1
  118. package/claude/skills/ux-audit/SKILL.md +2 -0
  119. package/claude/skills/ux-measure/SKILL.md +2 -0
  120. package/claude/skills/ux-walkthrough/SKILL.md +2 -0
  121. package/claude/skills/video-craft/SKILL.md +2 -0
  122. package/claude/skills/write-human/SKILL.md +2 -0
  123. package/claude/skills/youtube-transcripts/SKILL.md +2 -0
  124. package/docs/agents/canvas.md +24 -8
  125. package/docs/agents/capture.md +5 -5
  126. package/docs/agents/commands.md +2 -2
  127. package/docs/agents/gate.md +3 -1
  128. package/docs/agents/index.md +5 -4
  129. package/docs/agents/install-and-sync.md +7 -0
  130. package/docs/agents/key-changes.md +1 -1
  131. package/docs/agents/pr-evidence.md +47 -23
  132. package/docs/agents/pr-local.md +19 -13
  133. package/docs/agents/pr-preview.md +44 -21
  134. package/docs/agents/pr-tick.md +72 -0
  135. package/docs/agents/sandbox.md +30 -6
  136. package/docs/agents/scripting.md +8 -1
  137. package/docs/agents/skills-audit.md +2 -1
  138. package/docs/agents/tasks-plans.md +1 -1
  139. package/docs/agents/tasks-validate.md +18 -18
  140. package/docs/agents/tasks.md +1 -1
  141. package/docs/workflow/operating-model.md +15 -21
  142. package/docs/workflow/parallel-features.md +24 -3
  143. package/docs/workflow/skill-map.md +3 -1
  144. package/docs/workflow/zshrc-aliases.md +1 -1
  145. package/package.json +1 -1
  146. package/scripts/core/regen-hero.ts +1 -1
  147. package/scripts/lib/sandbox-path.sh +6 -162
  148. package/src/audits/baseline.ts +1 -1
  149. package/src/audits/run.ts +2 -2
  150. package/src/canvas/address.ts +12 -3
  151. package/src/canvas/capture.ts +1 -1
  152. package/src/canvas/client/inspector/align-grid.tsx +162 -0
  153. package/src/canvas/client/inspector/color-field.tsx +201 -171
  154. package/src/canvas/client/inspector/color-inputs.tsx +139 -0
  155. package/src/canvas/client/inspector/color-picker.tsx +668 -0
  156. package/src/canvas/client/inspector/color-space.ts +240 -0
  157. package/src/canvas/client/inspector/field.tsx +66 -3
  158. package/src/canvas/client/inspector/fields.css +473 -6
  159. package/src/canvas/client/inspector/size-field.tsx +69 -0
  160. package/src/canvas/client/inspector/token-select.tsx +109 -0
  161. package/src/canvas/client/inspector/values.ts +117 -3
  162. package/src/canvas/client/inspector.tsx +311 -15
  163. package/src/canvas/client/keys.ts +54 -0
  164. package/src/canvas/client/selection.tsx +199 -0
  165. package/src/canvas/client/shell.css +210 -12
  166. package/src/canvas/client/state.ts +165 -14
  167. package/src/canvas/client/surface.tsx +341 -39
  168. package/src/canvas/client/tools.tsx +64 -0
  169. package/src/canvas/content.ts +51 -2
  170. package/src/canvas/edit.ts +49 -6
  171. package/src/canvas/editing.ts +203 -0
  172. package/src/canvas/global.d.ts +15 -0
  173. package/src/canvas/server.ts +149 -12
  174. package/src/canvas/tokens.ts +60 -18
  175. package/src/census/count.ts +1 -1
  176. package/src/claude/seeds.ts +3 -3
  177. package/src/claude/skills-audit.ts +28 -0
  178. package/src/claude/skills-drift.ts +1 -1
  179. package/src/claude/skills-families.ts +36 -0
  180. package/src/claude/skills-list.ts +76 -15
  181. package/src/claude/skills-reach.ts +1 -1
  182. package/src/commands/audits.ts +2 -2
  183. package/src/commands/canvas.ts +180 -3
  184. package/src/commands/claude.ts +70 -12
  185. package/src/commands/design.ts +145 -5
  186. package/src/commands/docs.ts +1 -1
  187. package/src/commands/feedback.ts +2 -2
  188. package/src/commands/gate.ts +9 -2
  189. package/src/commands/gov.ts +3 -3
  190. package/src/commands/hooks.ts +1 -1
  191. package/src/commands/indexes.ts +1 -1
  192. package/src/commands/init.ts +2 -2
  193. package/src/commands/intake.ts +1 -1
  194. package/src/commands/migrate.ts +2 -2
  195. package/src/commands/pr.ts +288 -65
  196. package/src/commands/records.ts +1 -1
  197. package/src/commands/repo.ts +1 -1
  198. package/src/commands/sandbox.ts +64 -15
  199. package/src/commands/sessions.ts +1 -1
  200. package/src/commands/slides.ts +1 -1
  201. package/src/commands/sync.ts +2 -2
  202. package/src/commands/tasks.ts +21 -4
  203. package/src/commands/teach.ts +1 -1
  204. package/src/commands/tooling.ts +1 -1
  205. package/src/commands/transcripts.ts +2 -2
  206. package/src/commands/upgrade.ts +1 -1
  207. package/src/commands/wiki.ts +1 -1
  208. package/src/commands/worktrees.ts +1 -1
  209. package/src/comments/scan.ts +1 -1
  210. package/src/comments/trend.ts +1 -1
  211. package/src/context/architecture.ts +1 -1
  212. package/src/context/citations.ts +3 -3
  213. package/src/context/classify/extract.ts +3 -3
  214. package/src/context/folders.ts +2 -2
  215. package/src/context/requirements.ts +1 -1
  216. package/src/design/project-fonts.ts +392 -0
  217. package/src/docs/read.ts +1 -1
  218. package/src/exec.ts +1 -1
  219. package/src/feedback/github.ts +2 -2
  220. package/src/gate/measures.ts +48 -10
  221. package/src/gate/sequencer.ts +56 -4
  222. package/src/gate/stages.ts +17 -0
  223. package/src/gate/suite-lock.ts +214 -0
  224. package/src/{git-files.ts → git/files.ts} +2 -2
  225. package/src/{git-ignore.ts → git/ignore.ts} +1 -1
  226. package/src/{worktree.ts → git/worktree.ts} +1 -1
  227. package/src/gov/citations.ts +1 -1
  228. package/src/gov/install.ts +1 -1
  229. package/src/gov/superseded.ts +1 -1
  230. package/src/gov/test-order.ts +2 -2
  231. package/src/help.ts +1 -1
  232. package/src/hooks/post-merge.ts +1 -1
  233. package/src/indexes/walk.ts +1 -1
  234. package/src/intake/folder.ts +1 -1
  235. package/src/labels/audit.ts +1 -1
  236. package/src/labels/map.ts +1 -1
  237. package/src/labels/phase.ts +3 -3
  238. package/src/markdown/files.ts +1 -1
  239. package/src/migrate/apply.ts +1 -1
  240. package/src/migrate/record-layout.ts +1 -1
  241. package/src/migrate/records.ts +11 -11
  242. package/src/migrate/scratch-evidence.ts +1 -1
  243. package/src/migrate/surface-roots.ts +6 -6
  244. package/src/pr/bijection.ts +1 -1
  245. package/src/pr/evidence.ts +250 -59
  246. package/src/pr/local.ts +16 -10
  247. package/src/pr/preview.ts +78 -2
  248. package/src/pr/tick.ts +56 -0
  249. package/src/process/harness.ts +1 -1
  250. package/src/records/backup.ts +2 -2
  251. package/src/records/ordinal.ts +1 -1
  252. package/src/records/prune.ts +1 -1
  253. package/src/records/size.ts +1 -1
  254. package/src/records/stale-canonical.ts +2 -2
  255. package/src/records/stale.ts +1 -1
  256. package/src/records/validate.ts +2 -2
  257. package/src/{project-root.ts → roots/project.ts} +1 -1
  258. package/src/{record-root.ts → roots/record.ts} +1 -1
  259. package/src/sandbox/coverage.ts +11 -12
  260. package/src/sandbox/equivalence.ts +57 -26
  261. package/src/sandbox/headless/dispatch.ts +264 -0
  262. package/src/sandbox/headless/run.ts +653 -0
  263. package/src/sandbox/headless/watch.ts +145 -0
  264. package/src/sandbox/hooks.ts +1 -1
  265. package/src/sandbox/provision.ts +90 -29
  266. package/src/sandbox/scenario.ts +588 -0
  267. package/src/sandbox/tree.ts +4 -8
  268. package/src/secrets/scan.ts +1 -1
  269. package/src/sessions/claim.ts +1 -1
  270. package/src/sessions/live.ts +4 -1
  271. package/src/sessions/resolve.ts +1 -1
  272. package/src/sessions/transfer/bundle.ts +2 -2
  273. package/src/slides/decks.ts +1 -1
  274. package/src/slides/package/fonts.ts +6 -2
  275. package/src/standards/audit.ts +2 -2
  276. package/src/standards/read.ts +1 -1
  277. package/src/sync/check.ts +2 -2
  278. package/src/sync/engine.ts +3 -3
  279. package/src/sync/git.ts +1 -1
  280. package/src/sync/history.ts +1 -1
  281. package/src/sync/layout.ts +2 -2
  282. package/src/sync/reverse.ts +1 -1
  283. package/src/sync/stamp.ts +1 -1
  284. package/src/sync/target.ts +1 -1
  285. package/src/sync/workflow.ts +1 -1
  286. package/src/targets/pulls.ts +2 -2
  287. package/src/targets/sweep.ts +2 -2
  288. package/src/{target.ts → targets/validate.ts} +1 -1
  289. package/src/tasks/answers.ts +1 -1
  290. package/src/tasks/archive.ts +1 -1
  291. package/src/tasks/label.ts +1 -1
  292. package/src/tasks/reach.ts +2 -2
  293. package/src/tasks/trunk.ts +1 -1
  294. package/src/tasks/validate.ts +38 -14
  295. package/src/teach/nav.ts +1 -1
  296. package/src/teach/workspace.ts +1 -1
  297. package/src/tooling/inject.ts +2 -2
  298. package/src/tooling/read.ts +1 -1
  299. package/src/tooling/scan.ts +1 -1
  300. package/src/tooling/subfolder.ts +1 -1
  301. package/src/version/installed.ts +1 -1
  302. package/src/version/skew.ts +1 -1
  303. package/src/web/readme-citations.ts +1 -1
  304. package/src/worktrees/bare-flag.ts +1 -1
  305. package/src/worktrees/reclaim.ts +2 -2
  306. package/src/worktrees/remove.ts +1 -1
  307. package/standards/board.md +3 -3
  308. package/standards/canvas.md +2 -1
  309. package/standards/plan.md +13 -7
  310. package/standards/skill.md +1 -0
  311. package/standards/wiki.md +12 -3
  312. package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
  313. package/tsconfig.json +1 -1
  314. package/scripts/lib/sandbox-dispatch.sh +0 -190
  315. /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
  316. /package/src/{git-base.ts → git/base.ts} +0 -0
  317. /package/src/{git-env.ts → git/env.ts} +0 -0
  318. /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
  319. /package/src/{surface-root.ts → roots/surface.ts} +0 -0
  320. /package/src/{copy.ts → sync/copy.ts} +0 -0
@@ -0,0 +1,145 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
4
+ import { join, relative } from 'node:path'
5
+
6
+ /** Path to content hash, one entry per regular file. */
7
+ export type Manifest = ReadonlyMap<string, string>
8
+
9
+ /**
10
+ * Hashes every regular file under `base`, keyed by its path relative to it, for
11
+ * the top-level entries `keep` admits. A symlink is neither followed nor
12
+ * hashed, matching `find -type f`.
13
+ */
14
+ function hashFiles(base: string, keep: (name: string) => boolean): Manifest {
15
+ const manifest = new Map<string, string>()
16
+ const pending = readdirSync(base, { withFileTypes: true })
17
+ .filter((entry) => keep(entry.name))
18
+ .map((entry) => ({ entry, dir: base }))
19
+
20
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
21
+ const path = join(next.dir, next.entry.name)
22
+ if (next.entry.isDirectory()) {
23
+ for (const entry of readdirSync(path, { withFileTypes: true }))
24
+ pending.push({ entry, dir: path })
25
+ } else if (next.entry.isFile()) {
26
+ const hash = createHash('sha1').update(readFileSync(path)).digest('hex')
27
+ manifest.set(relative(base, path), hash)
28
+ }
29
+ }
30
+
31
+ return new Map([...manifest].sort(([a], [b]) => (a < b ? -1 : 1)))
32
+ }
33
+
34
+ /**
35
+ * Records a hash per file so the post-run comparison can name what the session
36
+ * wrote. A diff against `refs/sandbox/baseline` cannot stand in for this: an arm
37
+ * that sets SANDBOX_SKIP_AUTO_COMMIT leaves its last stage uncommitted, so the
38
+ * fixtures the harness staged would read as session writes.
39
+ */
40
+ export function snapshotTree(dir: string): Manifest {
41
+ return hashFiles(dir, (name) => name !== '.git')
42
+ }
43
+
44
+ /**
45
+ * Reports both sides of a change, so a deletion is a write. Reporting only the
46
+ * after side would let a file removed outside the declared scope produce no
47
+ * assertion at all, which is the one way this check stayed weaker than the
48
+ * permission scoping it replaced. A modified file differs on both sides and
49
+ * reports once.
50
+ */
51
+ export function writesBetween(before: Manifest, after: Manifest): string[] {
52
+ const paths = new Set([...before.keys(), ...after.keys()])
53
+
54
+ return [...paths]
55
+ .filter((path) => before.get(path) !== after.get(path))
56
+ .sort()
57
+ }
58
+
59
+ /**
60
+ * The roots a run must not write shared session scratch to. `snapshotTree`
61
+ * reads the sandbox alone, so before this a session that resolved that scratch
62
+ * against a toolkit root wrote where no manifest looked: the run reported
63
+ * success, the write list came back empty, and `write_scope` had nothing to
64
+ * assert against.
65
+ *
66
+ * It makes an escape visible rather than impossible. A session can write to a
67
+ * home directory, to a sibling worktree, or to any path outside these roots, and
68
+ * none of that is watched. The standing limits in
69
+ * `canon/context/sandbox/overview.md` carry what stays invisible.
70
+ *
71
+ * A linked worktree is the normal place to develop this repository, and the
72
+ * rule an escaping session follows sends scratch to the main root rather than to
73
+ * the worktree it was launched from, so the main root joins the list whenever it
74
+ * differs.
75
+ */
76
+ export function escapeRoots(projectRoot: string): string[] {
77
+ const listing = spawnSync('git', ['worktree', 'list', '--porcelain'], {
78
+ cwd: projectRoot,
79
+ encoding: 'utf8',
80
+ })
81
+ const line = (listing.stdout ?? '')
82
+ .split('\n')
83
+ .find((l) => l.startsWith('worktree '))
84
+ const mainRoot = line?.slice('worktree '.length)
85
+
86
+ return mainRoot === undefined || mainRoot === '' || mainRoot === projectRoot
87
+ ? [projectRoot]
88
+ : [projectRoot, mainRoot]
89
+ }
90
+
91
+ /**
92
+ * The four directories the seed's shared-scratch rule names, and the whole of
93
+ * where an escape lands. A session that resolves that rule against a toolkit
94
+ * root writes here and nowhere else, so watching these rather than the whole
95
+ * root costs no detection.
96
+ *
97
+ * It buys the difference between a signal and noise. Nothing distinguishes the
98
+ * spawned session's writes from the operator's own, so a watch over the whole
99
+ * root reports ordinary editing during a run as an escape. Measured 2026-08-02:
100
+ * three runs against the relocated sandbox all wrote their report to the right
101
+ * place, and two of the three still reported an escape under the wide watch,
102
+ * naming a context entry being edited in the session that launched them.
103
+ *
104
+ * The scope stated for an arm author: `escapeRoots` times two, these times four.
105
+ * This watch reaches nothing past those eight destinations, so a nested run's
106
+ * own escape is invisible here whether or not an arm declares `escape_scope`. A
107
+ * nested background dispatch reports through `sessions` instead, from
108
+ * `dispatch.ts`, so the key defined against this file-write watch keeps one
109
+ * meaning. An arm whose skill is meant to reach past the sandbox tree declares
110
+ * `escape_scope` in its `expect.toml`, which bounds a legitimate write rather
111
+ * than the run. Read `canon/context/sandbox/isolation.md` and
112
+ * `canon/context/sandbox/coverage/workflow-arms.md` before writing a claim past
113
+ * that.
114
+ */
115
+ export const ESCAPE_SCRATCH_DIRS = [
116
+ '.canon/plans',
117
+ '.canon/review',
118
+ '.canon/memory',
119
+ '.canon/tasks',
120
+ ] as const
121
+
122
+ export interface RootSnapshot {
123
+ readonly manifest: Manifest
124
+ /**
125
+ * The root held at least one of the four directories. A root with none of
126
+ * them contributes no manifest and no diff, which reads identically to a watch
127
+ * that ran clean, so `checkEscapeScope` needs this to tell "watched and clean"
128
+ * from "nothing to watch" apart.
129
+ */
130
+ readonly isWatched: boolean
131
+ }
132
+
133
+ export function snapshotRoot(dir: string): RootSnapshot {
134
+ const targets = ESCAPE_SCRATCH_DIRS.filter((sub) =>
135
+ existsSync(join(dir, sub)),
136
+ )
137
+ if (targets.length === 0) return { manifest: new Map(), isWatched: false }
138
+
139
+ const manifest = new Map<string, string>()
140
+ for (const sub of targets)
141
+ for (const [path, hash] of hashFiles(join(dir, sub), () => true))
142
+ manifest.set(`${sub}/${path}`, hash)
143
+
144
+ return { manifest, isWatched: true }
145
+ }
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
2
2
  import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { PROJECT_ROOT } from '@/project-root'
5
+ import { PROJECT_ROOT } from '@/roots/project'
6
6
 
7
7
  /**
8
8
  * The bash entry a scenario's hooks run through. Scenarios and the library they
@@ -4,13 +4,21 @@ import {
4
4
  copyFileSync,
5
5
  existsSync,
6
6
  mkdirSync,
7
- readdirSync,
8
7
  rmSync,
9
- statSync,
10
8
  writeFileSync,
11
9
  } from 'node:fs'
12
10
  import { basename, dirname, join, relative } from 'node:path'
13
11
  import { probeScenario, stageScenario } from '@/sandbox/hooks'
12
+ import {
13
+ FIXTURE_SUFFIX,
14
+ FIXTURES_DIR,
15
+ listFixtureFiles,
16
+ loadScenario,
17
+ resolveScenarioFile,
18
+ type ScenarioDefinition,
19
+ scenarioExports,
20
+ stageScenarioInProcess,
21
+ } from '@/sandbox/scenario'
14
22
  import { assertSandboxDirSafe, sandboxRoots, sandboxTree } from '@/sandbox/tree'
15
23
  import {
16
24
  intro,
@@ -26,16 +34,16 @@ import {
26
34
 
27
35
  /**
28
36
  * 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.
37
+ * A bash scenario's hooks run through `sandbox-hook.sh` and a TypeScript one's
38
+ * arm runs in process through `scenario.ts`, and everything between them runs
39
+ * here, in the order the retired bash dispatcher held.
31
40
  *
32
41
  * Narration is part of the contract. `canon sandbox equivalence` compares the
33
42
  * log line for line against the bash harness, so every message, its frame
34
43
  * glyph, and its position in the order stays as that harness wrote it.
35
44
  */
36
45
 
37
- const ANCHOR_FIXTURE_PARTS = ['sandbox', 'fixtures', 'anchor', 'create']
38
- const FIXTURE_SUFFIX = '.fixture'
46
+ const ANCHOR_FIXTURE_PARTS = ['sandbox', FIXTURES_DIR, 'anchor', 'create']
39
47
  const SKILL_PATHSPEC = 'claude/skills/**/SKILL.md'
40
48
 
41
49
  /**
@@ -98,15 +106,6 @@ function finish(message: string): void {
98
106
  process.stderr.write(`\n${GREEN}${message}${NC}\n`)
99
107
  }
100
108
 
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
109
  function stageAnchorTree(
111
110
  root: string,
112
111
  sandbox: string,
@@ -273,12 +272,22 @@ function setupAssets(
273
272
  * tree, so a scenario exercises the branch's skill rather than the installed
274
273
  * plugin's. The merge base against `origin/main` comes first, so a local `main`
275
274
  * trailing the remote does not pull in skills other merged branches changed.
275
+ * A headless run skips it, since a copied body alone shadows the plugin's whole
276
+ * skill folder and its relative citations then resolve to nothing.
276
277
  */
277
278
  export function injectChangedSkills(
278
279
  root: string,
279
280
  sandbox: string,
280
281
  isSkipAutoCommit: boolean,
282
+ isSkipInject = false,
281
283
  ): string[] {
284
+ if (isSkipInject) {
285
+ logInfo(
286
+ 'Skipped dev skill injection: the run resolves skills through --plugin-dir',
287
+ )
288
+ return []
289
+ }
290
+
282
291
  const base = (
283
292
  readGit(root, ['merge-base', 'HEAD', 'origin/main']) ??
284
293
  readGit(root, ['merge-base', 'HEAD', 'main']) ??
@@ -392,12 +401,57 @@ interface Scenario {
392
401
  readonly arm: string | undefined
393
402
  }
394
403
 
395
- function provisionScenario(
404
+ /**
405
+ * Reads what a scenario declares ahead of provisioning: its config exports and
406
+ * whether it stages from the anchor. A bash scenario answers through the probe,
407
+ * and a TypeScript one from its module, which is also what its stage runs.
408
+ */
409
+ function declareScenario(
410
+ root: string,
411
+ scenario: Scenario,
412
+ env: NodeJS.ProcessEnv,
413
+ ):
414
+ | { kind: 'sh'; file: string; isAnchor: boolean }
415
+ | { kind: 'ts'; definition: ScenarioDefinition; isAnchor: boolean } {
416
+ const name = `${scenario.category}/${scenario.command}`
417
+ const resolved = resolveScenarioFile(
418
+ join(root, 'sandbox'),
419
+ scenario.category,
420
+ scenario.command,
421
+ )
422
+ if (!resolved.ok && resolved.reason === 'ambiguous')
423
+ fail(
424
+ `Sandbox script ${name} exists as both .sh and .ts. Delete the one the port replaced.`,
425
+ )
426
+ if (!resolved.ok) fail(`Sandbox script not found: ${name}`)
427
+
428
+ if (resolved.kind === 'sh') {
429
+ const probe = probeScenario(resolved.file, env)
430
+ if (!probe.ok) throw new Halt(probe.status)
431
+ Object.assign(env, probe.exports)
432
+
433
+ return { kind: 'sh', file: resolved.file, isAnchor: probe.isAnchor }
434
+ }
435
+
436
+ let definition: ScenarioDefinition
437
+ try {
438
+ definition = loadScenario(resolved.file)
439
+ } catch (error) {
440
+ fail(
441
+ `Could not load scenario ${name}: ${error instanceof Error ? error.message : String(error)}`,
442
+ )
443
+ }
444
+ Object.assign(env, scenarioExports(definition, root, env))
445
+
446
+ return { kind: 'ts', definition, isAnchor: definition.anchor === true }
447
+ }
448
+
449
+ async function provisionScenario(
396
450
  root: string,
397
451
  sandbox: string,
398
452
  scenario: Scenario,
399
453
  baseEnv: NodeJS.ProcessEnv,
400
- ): void {
454
+ ): Promise<void> {
401
455
  const env: NodeJS.ProcessEnv = { ...baseEnv }
402
456
  if (scenario.arm !== undefined) {
403
457
  env.SANDBOX_SCENARIO = scenario.arm
@@ -405,13 +459,7 @@ function provisionScenario(
405
459
  }
406
460
 
407
461
  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)
462
+ const declared = declareScenario(root, scenario, env)
415
463
 
416
464
  if (!existsSync(sandboxDir))
417
465
  fail(`Sandbox directory not found at: ${sandboxDir}`)
@@ -425,20 +473,33 @@ function provisionScenario(
425
473
  }
426
474
 
427
475
  logStep(`Provisioning ${scenario.category}:${scenario.command}`)
428
- if (probe.isAnchor) stageAnchorTree(root, sandbox, env)
476
+ if (declared.isAnchor) stageAnchorTree(root, sandbox, env)
429
477
  else initEmptyTree(sandbox, env)
430
478
  configureCredentials(sandbox, env)
431
479
  setupAssets(root, sandbox, env)
432
480
 
433
481
  // 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)
482
+ // whose own frame is the last one written, so nothing follows it here. An
483
+ // in-process arm writes its exports straight into `env`.
484
+ const staged =
485
+ declared.kind === 'sh'
486
+ ? stageScenario(declared.file, env)
487
+ : {
488
+ ...(await stageScenarioInProcess(declared.definition, {
489
+ root,
490
+ dir: sandbox,
491
+ env,
492
+ arm: scenario.arm,
493
+ })),
494
+ exports: {},
495
+ }
436
496
  if (staged.ending === 'replaced') throw new Halt(staged.status, false)
437
497
  if (staged.ending === 'exited') throw new Halt(staged.status)
438
498
  Object.assign(env, staged.exports)
439
499
 
440
500
  const isSkipAutoCommit = (env.SANDBOX_SKIP_AUTO_COMMIT ?? '') !== ''
441
- injectChangedSkills(root, sandbox, isSkipAutoCommit)
501
+ const isSkipInject = (env.SANDBOX_SKIP_SKILL_INJECT ?? '') !== ''
502
+ injectChangedSkills(root, sandbox, isSkipAutoCommit, isSkipInject)
442
503
  must(commitScenarioChanges(sandbox, isSkipAutoCommit, env))
443
504
  tagBaseline(sandbox, env)
444
505
 
@@ -579,7 +640,7 @@ export async function runSandbox(
579
640
  }
580
641
 
581
642
  const { category, command } = parseTarget(request.target)
582
- provisionScenario(
643
+ await provisionScenario(
583
644
  root,
584
645
  sandbox,
585
646
  { category, command, arm: request.arm },