@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,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
+ }
@@ -0,0 +1,128 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { PROJECT_ROOT } from '@/project-root'
6
+
7
+ /**
8
+ * The bash entry a scenario's hooks run through. Scenarios and the library they
9
+ * call stay bash, so this contract is the whole of what the TypeScript harness
10
+ * knows about one: which hooks it declares and what `use_config` exports.
11
+ */
12
+ const HOOK_SCRIPT = join(PROJECT_ROOT, 'scripts', 'sandbox-hook.sh')
13
+
14
+ const ANCHOR_RECORD = '@anchor='
15
+
16
+ export interface Probe {
17
+ readonly exports: Readonly<Record<string, string>>
18
+ readonly isAnchor: boolean
19
+ }
20
+
21
+ export type ProbeResult =
22
+ | ({ readonly ok: true } & Probe)
23
+ | { readonly ok: false; readonly status: number }
24
+
25
+ /**
26
+ * Reads the NUL-separated `NAME=value` pairs the probe prints. NUL rather than a
27
+ * newline, since an exported value can hold one. The anchor record closes every
28
+ * well-formed probe, so output missing it is a probe that did not finish.
29
+ */
30
+ export function parseProbe(stdout: string): Probe {
31
+ const { exports, anchor } = parsePairs(stdout)
32
+ if (anchor === undefined)
33
+ throw new Error('the probe printed no anchor record')
34
+
35
+ return { exports, isAnchor: anchor === '1' }
36
+ }
37
+
38
+ function parsePairs(text: string): {
39
+ exports: Record<string, string>
40
+ anchor: string | undefined
41
+ } {
42
+ const exports: Record<string, string> = {}
43
+ let anchor: string | undefined
44
+
45
+ for (const record of text.split('\0')) {
46
+ if (record.startsWith(ANCHOR_RECORD)) {
47
+ anchor = record.slice(ANCHOR_RECORD.length)
48
+ continue
49
+ }
50
+ const split = record.indexOf('=')
51
+ if (split > 0) exports[record.slice(0, split)] = record.slice(split + 1)
52
+ }
53
+
54
+ return { exports, anchor }
55
+ }
56
+
57
+ /**
58
+ * Runs `use_config` and `use_anchor` in a child and returns what they exported.
59
+ * Stderr is inherited by default, so a hook that fails narrates into the same
60
+ * frame the harness writes. A non-zero status passes through rather than being
61
+ * flattened, since the harness exits with it.
62
+ */
63
+ export function probeScenario(
64
+ file: string,
65
+ env: NodeJS.ProcessEnv,
66
+ stderr: 'inherit' | 'pipe' = 'inherit',
67
+ ): ProbeResult {
68
+ const result = spawnSync('bash', [HOOK_SCRIPT, 'probe', file], {
69
+ env,
70
+ encoding: 'utf8',
71
+ stdio: ['ignore', 'pipe', stderr],
72
+ })
73
+ const status = result.status ?? 1
74
+ if (status !== 0) return { ok: false, status }
75
+
76
+ return { ok: true, ...parseProbe(result.stdout) }
77
+ }
78
+
79
+ /**
80
+ * How `stage_setup` ended. The retired dispatcher ran it in its own shell, so
81
+ * an `exit` there ended provisioning through the frame-closing trap, and an
82
+ * `exec` replaced the harness outright, leaving its program's frame as the last
83
+ * one written. The harness reproduces both from this.
84
+ */
85
+ export type StageEnding = 'returned' | 'exited' | 'replaced'
86
+
87
+ export interface StageResult {
88
+ readonly status: number
89
+ readonly ending: StageEnding
90
+ readonly exports: Readonly<Record<string, string>>
91
+ }
92
+
93
+ const EXITED_RECORD = '@exited'
94
+
95
+ /**
96
+ * Runs `stage_setup` inside the tree with every stream inherited, so a scenario
97
+ * prompting through `select_or_route_scenario` still reaches a terminal and its
98
+ * narration lands in order with the harness's own.
99
+ *
100
+ * The exports `stage_setup` changed come back through a file rather than a
101
+ * pipe. Stdout belongs to the scenario, and a background process a scenario
102
+ * starts would hold an extra pipe open past the child's exit.
103
+ */
104
+ export function stageScenario(
105
+ file: string,
106
+ env: NodeJS.ProcessEnv,
107
+ stdio: 'inherit' | 'ignore' = 'inherit',
108
+ ): StageResult {
109
+ const scratch = mkdtempSync(join(tmpdir(), 'canon-sandbox-hook-'))
110
+ const report = join(scratch, 'exports')
111
+
112
+ try {
113
+ const result = spawnSync('bash', [HOOK_SCRIPT, 'stage', file, report], {
114
+ env,
115
+ stdio,
116
+ })
117
+ const status = result.status ?? 1
118
+ if (!existsSync(report)) return { status, ending: 'replaced', exports: {} }
119
+
120
+ const text = readFileSync(report, 'utf8')
121
+ if (text.split('\0').includes(EXITED_RECORD))
122
+ return { status, ending: 'exited', exports: {} }
123
+
124
+ return { status, ending: 'returned', exports: parsePairs(text).exports }
125
+ } finally {
126
+ rmSync(scratch, { recursive: true, force: true })
127
+ }
128
+ }