@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,588 @@
1
+ import { spawnSync, type StdioOptions } from 'node:child_process'
2
+ import {
3
+ appendFileSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { createRequire } from 'node:module'
13
+ import { dirname, join, relative } from 'node:path'
14
+ import { logError, logInfo, logStep, logWarn, palette, select } from '@/ui'
15
+
16
+ /**
17
+ * The TypeScript form of a sandbox scenario: `sandbox/<category>/<command>.ts`,
18
+ * whose default export declares what the bash form split across `use_config`,
19
+ * `use_anchor`, and `stage_setup`. Provisioning, the catalog, coverage, census,
20
+ * and equivalence all read a scenario through this module, so a stem holding
21
+ * either extension reads the same everywhere.
22
+ *
23
+ * Arms run in the harness process rather than a bash child, so nothing here
24
+ * spawns without the tree as its `cwd`. A scenario reaches the tree through the
25
+ * context alone.
26
+ */
27
+
28
+ /** Holds fixture content rather than scenarios, so no listing reads it as a category. */
29
+ export const FIXTURES_DIR = 'fixtures'
30
+
31
+ export const FIXTURE_SUFFIX = '.fixture'
32
+
33
+ /** A scenario whose only arm carries this name routes none, as a bash file with no picker did. */
34
+ export const DEFAULT_ARM_NAME = 'default'
35
+
36
+ /** Every bash scenario passed this prompt to `select_or_route_scenario`. */
37
+ const ROUTE_PROMPT = 'Which scenario?'
38
+
39
+ /** Every scenario that sets the anchor pushes to this one throwaway repository. */
40
+ export const SANDBOX_ANCHOR_REPO = 'canon-sandbox'
41
+
42
+ const EXTENSIONS = ['.ts', '.sh'] as const
43
+
44
+ export type ScenarioKind = 'ts' | 'sh'
45
+
46
+ export type ArmSetup = (ctx: StageContext) => void | Promise<void>
47
+
48
+ export interface ScenarioDefinition {
49
+ /** The `use_config` exports: presence flags such as `SANDBOX_SKIP_AUTO_COMMIT`. */
50
+ readonly config?: Readonly<Record<string, string>>
51
+ /** Stages the tree from the anchor fixture and names the throwaway remote. */
52
+ readonly anchor?: boolean
53
+ /** In routing order, so the first arm is the one a headless caller gets. */
54
+ readonly arms: Readonly<Record<string, ArmSetup>>
55
+ }
56
+
57
+ export function scenario(definition: ScenarioDefinition): ScenarioDefinition {
58
+ return definition
59
+ }
60
+
61
+ export interface ScenarioFile {
62
+ readonly command: string
63
+ readonly file: string
64
+ readonly kind: ScenarioKind
65
+ }
66
+
67
+ /**
68
+ * Lists the scenarios in one category folder, one per stem. A stem present in
69
+ * both forms is reported apart rather than listed twice, since picking one
70
+ * without saying so is the failure the refusal exists to prevent.
71
+ */
72
+ export function listScenarioFiles(categoryDir: string): {
73
+ scenarios: ScenarioFile[]
74
+ ambiguous: string[]
75
+ } {
76
+ const byStem = new Map<string, ScenarioFile[]>()
77
+ for (const entry of readdirSync(categoryDir, { withFileTypes: true })) {
78
+ if (!entry.isFile()) continue
79
+ const kind = EXTENSIONS.find((ext) => entry.name.endsWith(ext))
80
+ if (kind === undefined) continue
81
+ const command = entry.name.slice(0, -kind.length)
82
+ const found = byStem.get(command) ?? []
83
+ found.push({
84
+ command,
85
+ file: join(categoryDir, entry.name),
86
+ kind: kind === '.ts' ? 'ts' : 'sh',
87
+ })
88
+ byStem.set(command, found)
89
+ }
90
+
91
+ const scenarios: ScenarioFile[] = []
92
+ const ambiguous: string[] = []
93
+ for (const [command, found] of [...byStem].sort(([a], [b]) =>
94
+ a.localeCompare(b),
95
+ )) {
96
+ if (found.length > 1) ambiguous.push(command)
97
+ else if (found[0] !== undefined) scenarios.push(found[0])
98
+ }
99
+
100
+ return { scenarios, ambiguous }
101
+ }
102
+
103
+ /** Every category folder under `sandbox/`, fixtures excluded. */
104
+ export function listCategories(sandboxDir: string): string[] {
105
+ if (!existsSync(sandboxDir)) return []
106
+
107
+ return readdirSync(sandboxDir, { withFileTypes: true })
108
+ .filter((entry) => entry.isDirectory() && entry.name !== FIXTURES_DIR)
109
+ .map((entry) => entry.name)
110
+ .sort()
111
+ }
112
+
113
+ export type ResolvedScenario =
114
+ | ({ readonly ok: true } & ScenarioFile)
115
+ | { readonly ok: false; readonly reason: 'missing' | 'ambiguous' }
116
+
117
+ export function resolveScenarioFile(
118
+ sandboxDir: string,
119
+ category: string,
120
+ command: string,
121
+ ): ResolvedScenario {
122
+ const found = EXTENSIONS.map((ext) =>
123
+ join(sandboxDir, category, `${command}${ext}`),
124
+ ).filter((path) => existsSync(path) && statSync(path).isFile())
125
+ if (found.length > 1) return { ok: false, reason: 'ambiguous' }
126
+ const file = found[0]
127
+ if (file === undefined) return { ok: false, reason: 'missing' }
128
+
129
+ return { ok: true, command, file, kind: file.endsWith('.ts') ? 'ts' : 'sh' }
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === 'object' && value !== null
134
+ }
135
+
136
+ /**
137
+ * Imports a scenario module and checks its shape, throwing on one that would
138
+ * provision something other than what it declares. Synchronous, since the
139
+ * catalog readers that call it are.
140
+ */
141
+ export function loadScenario(file: string): ScenarioDefinition {
142
+ const module: unknown = createRequire(import.meta.url)(file)
143
+ const definition = isRecord(module) ? module.default : undefined
144
+ if (!isRecord(definition))
145
+ throw new Error(`${file} has no default export from scenario()`)
146
+
147
+ const { arms, config, anchor } = definition
148
+ if (!isRecord(arms) || Object.keys(arms).length === 0)
149
+ throw new Error(`${file} declares no arms`)
150
+ for (const [name, setup] of Object.entries(arms))
151
+ if (typeof setup !== 'function')
152
+ throw new Error(`${file} arm "${name}" is not a function`)
153
+ if (
154
+ config !== undefined &&
155
+ (!isRecord(config) ||
156
+ Object.values(config).some((v) => typeof v !== 'string'))
157
+ )
158
+ throw new Error(`${file} config holds a value that is not a string`)
159
+ if (anchor !== undefined && typeof anchor !== 'boolean')
160
+ throw new Error(`${file} anchor is not a boolean`)
161
+
162
+ return definition as unknown as ScenarioDefinition
163
+ }
164
+
165
+ /** The arm names a caller routes to, empty for a scenario that routes none. */
166
+ export function armNames(definition: ScenarioDefinition): string[] {
167
+ const names = Object.keys(definition.arms)
168
+
169
+ return names.length === 1 && names[0] === DEFAULT_ARM_NAME ? [] : names
170
+ }
171
+
172
+ /** Twin of `scripts/config.sh`, which derives the organization from the toolkit's own remote. */
173
+ function deriveGithubOrg(root: string, env: NodeJS.ProcessEnv): string {
174
+ if ((env.GITHUB_ORG ?? '') !== '') return env.GITHUB_ORG ?? ''
175
+
176
+ const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
177
+ cwd: root,
178
+ env,
179
+ encoding: 'utf8',
180
+ stdio: ['ignore', 'pipe', 'ignore'],
181
+ })
182
+ const url = origin.status === 0 ? origin.stdout.trim() : ''
183
+
184
+ return (
185
+ /^(?:git@github\.com:|https:\/\/github\.com\/)([^/]+)\/.+/.exec(url)?.[1] ??
186
+ ''
187
+ )
188
+ }
189
+
190
+ /**
191
+ * What the bash probe reported for a scenario: its config, the organization
192
+ * `config.sh` derives, and the anchor's repository name. These land in the one
193
+ * environment provisioning passes to every child, the seed and gov installs
194
+ * included.
195
+ */
196
+ export function scenarioExports(
197
+ definition: ScenarioDefinition,
198
+ root: string,
199
+ env: NodeJS.ProcessEnv,
200
+ ): Record<string, string> {
201
+ const exports: Record<string, string> = {
202
+ GITHUB_ORG: deriveGithubOrg(root, env),
203
+ ...definition.config,
204
+ }
205
+ if (definition.anchor === true) exports.ANCHOR_REPO = SANDBOX_ANCHOR_REPO
206
+
207
+ return exports
208
+ }
209
+
210
+ /**
211
+ * Ends an arm early. `exited` is a `stage_setup` that failed or called `exit`,
212
+ * and `replaced` is one that handed the run to a verb through `exec`.
213
+ */
214
+ export class ScenarioStop extends Error {
215
+ constructor(
216
+ readonly status: number,
217
+ readonly ending: 'exited' | 'replaced',
218
+ ) {
219
+ super(`scenario stopped with ${status}`)
220
+ }
221
+ }
222
+
223
+ export interface RunOptions {
224
+ readonly stdout?: 'inherit' | 'ignore'
225
+ readonly stderr?: 'inherit' | 'ignore'
226
+ /** Returns a failing status rather than stopping the arm, the `|| true` of the bash form. */
227
+ readonly allowFailure?: boolean
228
+ }
229
+
230
+ export interface StageContext {
231
+ /** The sandbox tree, which every command the context runs takes as its `cwd`. */
232
+ readonly dir: string
233
+ readonly root: string
234
+ /** Passed to every command, and read back by provisioning once the arm returns. */
235
+ readonly env: NodeJS.ProcessEnv
236
+ readonly arm: string | undefined
237
+ readonly log: {
238
+ info(message: string): void
239
+ step(message: string): void
240
+ warn(message: string): void
241
+ /** A bare frame line, the `echo -e "${GREY}│${NC}"` of the bash form. */
242
+ bar(): void
243
+ }
244
+ fixtures(category: string, command: string, arm: string, stage: string): void
245
+ git(...args: string[]): void
246
+ /** `git commit -m <subject> --no-verify -q`, over whatever is staged. */
247
+ commit(subject: string): void
248
+ run(command: string, args: readonly string[], options?: RunOptions): number
249
+ /** Runs a command and returns its stdout, stopping the arm on a failure. */
250
+ read(command: string, args: readonly string[], options?: RunOptions): string
251
+ write(path: string, content: string): void
252
+ append(path: string, content: string): void
253
+ mkdir(path: string): void
254
+ readFile(path: string): string
255
+ /** Sets the author `configure_sandbox_git_identity` did. */
256
+ identity(): void
257
+ /** Probes the anchor, sets the author, and points `origin` at it. */
258
+ anchorRemote(): void
259
+ fail(message: string): never
260
+ /** Hands the run to a verb whose own frame closes it. */
261
+ exec(command: string, args: readonly string[]): never
262
+ }
263
+
264
+ /** Every file under a folder, sorted, the order `find | sort` gave the bash form. */
265
+ export function listFixtureFiles(dir: string): string[] {
266
+ if (!existsSync(dir)) return []
267
+
268
+ return (readdirSync(dir, { recursive: true, encoding: 'utf8' }) as string[])
269
+ .map((path) => join(dir, path))
270
+ .filter((path) => statSync(path).isFile())
271
+ .sort()
272
+ }
273
+
274
+ interface ContextOptions {
275
+ readonly root: string
276
+ readonly dir: string
277
+ readonly env: NodeJS.ProcessEnv
278
+ readonly arm: string | undefined
279
+ }
280
+
281
+ export function createStageContext(options: ContextOptions): StageContext {
282
+ const { root, dir, env } = options
283
+
284
+ const fail = (message: string): never => {
285
+ logError(message)
286
+ throw new ScenarioStop(1, 'exited')
287
+ }
288
+
289
+ const spawn = (
290
+ command: string,
291
+ args: readonly string[],
292
+ stdout: 'inherit' | 'ignore' | 'pipe',
293
+ stderr: 'inherit' | 'ignore' | 'pipe',
294
+ ) => {
295
+ const stdio: StdioOptions = ['ignore', stdout, stderr]
296
+
297
+ return spawnSync(command, [...args], {
298
+ cwd: dir,
299
+ env,
300
+ encoding: 'utf8',
301
+ stdio,
302
+ })
303
+ }
304
+
305
+ const run = (
306
+ command: string,
307
+ args: readonly string[],
308
+ runOptions: RunOptions = {},
309
+ ): number => {
310
+ const result = spawn(
311
+ command,
312
+ args,
313
+ runOptions.stdout ?? 'inherit',
314
+ runOptions.stderr ?? 'inherit',
315
+ )
316
+ const status = result.status ?? 1
317
+ if (status !== 0 && runOptions.allowFailure !== true)
318
+ throw new ScenarioStop(status, 'exited')
319
+
320
+ return status
321
+ }
322
+
323
+ const read = (
324
+ command: string,
325
+ args: readonly string[],
326
+ runOptions: RunOptions = {},
327
+ ): string => {
328
+ const result = spawn(command, args, 'pipe', runOptions.stderr ?? 'inherit')
329
+ const status = result.status ?? 1
330
+ if (status !== 0 && runOptions.allowFailure !== true)
331
+ throw new ScenarioStop(status, 'exited')
332
+
333
+ return status === 0 ? result.stdout : ''
334
+ }
335
+
336
+ const git = (...args: string[]): void => {
337
+ run('git', args)
338
+ }
339
+
340
+ const inTree = (path: string): string => join(dir, path)
341
+
342
+ const identity = (): void => {
343
+ const globalValue = (key: string): string =>
344
+ read('git', ['config', '--global', key], {
345
+ stderr: 'ignore',
346
+ allowFailure: true,
347
+ }).trim()
348
+ env.SANDBOX_GIT_NAME =
349
+ env.SANDBOX_GIT_NAME || globalValue('user.name') || 'canon-sandbox'
350
+ env.SANDBOX_GIT_EMAIL =
351
+ env.SANDBOX_GIT_EMAIL ||
352
+ globalValue('user.email') ||
353
+ 'sandbox@example.com'
354
+ git('config', 'user.name', env.SANDBOX_GIT_NAME)
355
+ git('config', 'user.email', env.SANDBOX_GIT_EMAIL)
356
+ }
357
+
358
+ /**
359
+ * `gh` answers an absent repository and an unreachable host with one status,
360
+ * so the 404 alone separates them. Creating is the opt-in, for the reason
361
+ * `ensure_sandbox_anchor_repo` in `scripts/lib/sandbox-git.sh` records.
362
+ */
363
+ const ensureAnchorRepo = (org: string, repo: string): void => {
364
+ const probe = spawn(
365
+ 'gh',
366
+ ['api', `repos/${org}/${repo}`, '--silent'],
367
+ 'pipe',
368
+ 'pipe',
369
+ )
370
+ if (probe.status === 0) return
371
+
372
+ const output = `${probe.stdout ?? ''}${probe.stderr ?? ''}`.trim()
373
+ if (!output.includes('HTTP 404'))
374
+ fail(`Cannot reach ${org}/${repo}: ${output}`)
375
+ if (!['1', 'true'].includes(env.SANDBOX_ANCHOR_CREATE ?? ''))
376
+ fail(
377
+ `${org}/${repo} does not exist. Check GITHUB_ORG and whether a rename is pending, then create it with 'gh repo create ${org}/${repo} --private' or re-run with SANDBOX_ANCHOR_CREATE=true.`,
378
+ )
379
+
380
+ logWarn(
381
+ `${org}/${repo} does not exist and SANDBOX_ANCHOR_CREATE is set. Creating it as private.`,
382
+ )
383
+ const created = spawn(
384
+ 'gh',
385
+ ['repo', 'create', `${org}/${repo}`, '--private'],
386
+ 'pipe',
387
+ 'pipe',
388
+ )
389
+ if (created.status !== 0)
390
+ fail(
391
+ `Could not create ${org}/${repo}: ${`${created.stdout ?? ''}${created.stderr ?? ''}`.trim()}`,
392
+ )
393
+ logInfo(`Created ${org}/${repo} as a private repository.`)
394
+ }
395
+
396
+ const anchorRemote = (): void => {
397
+ const org = env.GITHUB_ORG ?? ''
398
+ const repo = env.ANCHOR_REPO ?? ''
399
+ if (org === '')
400
+ fail(
401
+ 'GITHUB_ORG is empty. Export GITHUB_ORG=<org>, or run from a clone whose remote.origin.url points at GitHub.',
402
+ )
403
+ if (repo === '')
404
+ fail(
405
+ 'The anchor remote needs a repository name. Set anchor: true on the scenario.',
406
+ )
407
+ ensureAnchorRepo(org, repo)
408
+ identity()
409
+ run('git', ['remote', 'remove', 'origin'], {
410
+ stderr: 'ignore',
411
+ allowFailure: true,
412
+ })
413
+ git('remote', 'add', 'origin', `https://github.com/${org}/${repo}.git`)
414
+ }
415
+
416
+ /** Twin of `stage_fixtures` in `scripts/lib/sandbox-fixtures.sh`. */
417
+ const fixtures = (
418
+ category: string,
419
+ command: string,
420
+ arm: string,
421
+ stage: string,
422
+ ): void => {
423
+ const stageDir = join(
424
+ root,
425
+ 'sandbox',
426
+ FIXTURES_DIR,
427
+ category,
428
+ command,
429
+ arm,
430
+ stage,
431
+ )
432
+ if (!existsSync(stageDir))
433
+ fail(`Fixture stage not found: ${category}/${command}/${arm}/${stage}`)
434
+
435
+ const createFiles = listFixtureFiles(join(stageDir, 'create'))
436
+ const appendFiles = listFixtureFiles(join(stageDir, 'append'))
437
+ for (const files of [createFiles, appendFiles]) {
438
+ const stray = files.filter((path) => !path.endsWith(FIXTURE_SUFFIX))
439
+ if (stray.length > 0)
440
+ fail(
441
+ `Fixture missing the ${FIXTURE_SUFFIX} suffix: ${stray.join(' ')} `,
442
+ )
443
+ }
444
+ if (createFiles.length + appendFiles.length === 0)
445
+ fail(
446
+ `Fixture stage provisions nothing: ${category}/${command}/${arm}/${stage}`,
447
+ )
448
+
449
+ const target = (base: string, file: string): string =>
450
+ relative(base, file).slice(0, -FIXTURE_SUFFIX.length)
451
+
452
+ for (const file of createFiles) {
453
+ const path = inTree(target(join(stageDir, 'create'), file))
454
+ mkdirSync(dirname(path), { recursive: true })
455
+ copyFileSync(file, path)
456
+ }
457
+ // An append extends a file the anchor or the seeds provide, so a missing
458
+ // target means that upstream shape changed rather than a file to create.
459
+ for (const file of appendFiles) {
460
+ const rel = target(join(stageDir, 'append'), file)
461
+ if (!existsSync(inTree(rel))) fail(`Append fixture has no target: ${rel}`)
462
+ appendFileSync(inTree(rel), readFileSync(file))
463
+ }
464
+ }
465
+
466
+ return {
467
+ dir,
468
+ root,
469
+ env,
470
+ arm: options.arm,
471
+ log: {
472
+ info: logInfo,
473
+ step: logStep,
474
+ warn: logWarn,
475
+ bar: () => {
476
+ const { GREY, NC } = palette(process.stderr)
477
+ process.stderr.write(`${GREY}│${NC}\n`)
478
+ },
479
+ },
480
+ fixtures,
481
+ git,
482
+ commit: (subject) => git('commit', '-m', subject, '--no-verify', '-q'),
483
+ run,
484
+ read,
485
+ write: (path, content) => {
486
+ mkdirSync(dirname(inTree(path)), { recursive: true })
487
+ writeFileSync(inTree(path), content)
488
+ },
489
+ append: (path, content) => appendFileSync(inTree(path), content),
490
+ mkdir: (path) => mkdirSync(inTree(path), { recursive: true }),
491
+ readFile: (path) => readFileSync(inTree(path), 'utf8'),
492
+ identity,
493
+ anchorRemote,
494
+ fail,
495
+ exec: (command, args) => {
496
+ const result = spawnSync(command, [...args], {
497
+ cwd: dir,
498
+ env,
499
+ stdio: 'inherit',
500
+ })
501
+ throw new ScenarioStop(result.status ?? 1, 'replaced')
502
+ },
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Picks the arm the way `select_or_route_scenario` did: a routed name first,
508
+ * echoed on the frame, then the first arm for a headless caller, then a prompt.
509
+ */
510
+ async function routeArm(
511
+ names: readonly string[],
512
+ env: NodeJS.ProcessEnv,
513
+ ): Promise<string> {
514
+ const echo = (name: string): string => {
515
+ const { GREY, NC, WHITE } = palette(process.stderr)
516
+ process.stderr.write(
517
+ `${GREY}│${NC}\n${GREY}◇${NC} ${ROUTE_PROMPT} ${WHITE}${name}${NC}\n`,
518
+ )
519
+
520
+ return name
521
+ }
522
+
523
+ const routed = env.SANDBOX_SCENARIO ?? ''
524
+ if (routed !== '') return echo(routed)
525
+
526
+ // Read off the scenario's environment rather than this process's, as
527
+ // `is_non_interactive` read the shell it ran in.
528
+ const isHeadless =
529
+ env.CANON_NON_INTERACTIVE === '1' ||
530
+ // canon-keep-retired
531
+ env.AITK_NON_INTERACTIVE === '1'
532
+ if (isHeadless) return echo(names[0] ?? DEFAULT_ARM_NAME)
533
+
534
+ if (!process.stdin.isTTY) {
535
+ logError(
536
+ `${ROUTE_PROMPT} requires a TTY. Pass an argument or set CANON_NON_INTERACTIVE=1.`,
537
+ )
538
+ throw new ScenarioStop(1, 'exited')
539
+ }
540
+
541
+ return select({
542
+ message: ROUTE_PROMPT,
543
+ options: names.map((name) => ({ value: name, label: name })),
544
+ })
545
+ }
546
+
547
+ export interface StageOutcome {
548
+ readonly status: number
549
+ readonly ending: 'returned' | 'exited' | 'replaced'
550
+ }
551
+
552
+ /**
553
+ * Routes and runs one arm in process. Any throw ends the arm the way a failing
554
+ * command ended `stage_setup` under `set -e`, so provisioning stops before the
555
+ * baseline refs are written.
556
+ */
557
+ export async function stageScenarioInProcess(
558
+ definition: ScenarioDefinition,
559
+ options: ContextOptions,
560
+ ): Promise<StageOutcome> {
561
+ try {
562
+ const names = armNames(definition)
563
+ const name =
564
+ names.length === 0 ? DEFAULT_ARM_NAME : await routeArm(names, options.env)
565
+ const setup = Object.hasOwn(definition.arms, name)
566
+ ? definition.arms[name]
567
+ : undefined
568
+ if (setup === undefined) {
569
+ logError(`Unknown scenario: ${name}`)
570
+ return { status: 1, ending: 'exited' }
571
+ }
572
+
573
+ await setup(
574
+ createStageContext({
575
+ ...options,
576
+ arm: names.length === 0 ? undefined : name,
577
+ }),
578
+ )
579
+
580
+ return { status: 0, ending: 'returned' }
581
+ } catch (error) {
582
+ if (error instanceof ScenarioStop)
583
+ return { status: error.status, ending: error.ending }
584
+ logError(error instanceof Error ? error.message : String(error))
585
+
586
+ return { status: 1, ending: 'exited' }
587
+ }
588
+ }
@@ -6,8 +6,8 @@ import { join } from 'node:path'
6
6
  /**
7
7
  * Mints a short per-run identifier the first time it is asked for and holds it
8
8
  * in `CANON_SANDBOX_RUN_ID` for the rest of this process, so a script that
9
- * spawns a child inheriting `process.env` — `run.sh` calling `canon sandbox` to
10
- * provision and then `canon sandbox check` — resolves the same tree in every one
9
+ * spawns a child inheriting `process.env` — `canon sandbox run` calling
10
+ * `canon sandbox` to provision and then `canon sandbox check` — resolves the same tree in every one
11
11
  * of them, as do the hook and installer children provisioning spawns. A process
12
12
  * that already carries the variable, inherited from such a parent, reuses it
13
13
  * rather than minting a new one.
@@ -49,9 +49,8 @@ export function sandboxTree(): string {
49
49
  }
50
50
 
51
51
  /**
52
- * Twin of `normalize_sandbox_path` in `scripts/lib/sandbox-path.sh`, which the
53
- * headless runner still sources. Lexical rather than `realpath`, since the guard
54
- * runs before the tree exists, so nothing here follows a symlink.
52
+ * Lexical rather than `realpath`, since the guard runs before the tree exists,
53
+ * so nothing here follows a symlink.
55
54
  */
56
55
  export function normalizeSandboxPath(path: string): string {
57
56
  if (path === '') return ''
@@ -110,9 +109,6 @@ export function sandboxRoots(projectRoot: string): SandboxRoots {
110
109
  * Provisioning removes the tree before staging, so the test is an allowlist: a
111
110
  * strict descendant of the home folder or the temp root, outside the main
112
111
  * worktree in both directions.
113
- *
114
- * Twin of `assert_sandbox_dir_safe` in `scripts/lib/sandbox-path.sh`, message
115
- * for message, since the headless runner still guards through that copy.
116
112
  */
117
113
  export function assertSandboxDirSafe(
118
114
  raw: string,
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { isBinary } from '@/sweep/binary'
4
- import { listRepositoryFiles } from '@/git-files'
4
+ import { listRepositoryFiles } from '@/git/files'
5
5
  import { isExempt } from '@/secrets/marker'
6
6
  import { matchLine } from '@/secrets/patterns'
7
7
  import { readShipEntries, selectShipped } from '@/secrets/shipped'
@@ -9,7 +9,7 @@ import {
9
9
  listWorktrees,
10
10
  type RefReport,
11
11
  type WorktreeEntry,
12
- } from '@/worktree'
12
+ } from '@/git/worktree'
13
13
 
14
14
  export interface ClaimReport {
15
15
  readonly claimed: boolean
@@ -61,9 +61,12 @@ export const SYSTEM_PROBES: LivenessProbes = { procStartOf, responds }
61
61
  * whose pid was reused and the record is discarded. Where it does not, the
62
62
  * probe alone stands, and every surviving row is marked so a caller never reads
63
63
  * a recycled pid as a confirmed identity.
64
+ *
65
+ * It reads two fields and asks for no more, so a record that is not a session,
66
+ * such as the gate's machine lock, carries the same proof.
64
67
  */
65
68
  export function liveness(
66
- record: SessionRecord,
69
+ record: Pick<SessionRecord, 'pid' | 'procStart'>,
67
70
  probes: LivenessProbes = SYSTEM_PROBES,
68
71
  ): Liveness {
69
72
  const started = record.procStart
@@ -1,6 +1,6 @@
1
1
  import { basename } from 'node:path'
2
2
  import { $ } from 'bun'
3
- import { gitEnv } from '@/git-env'
3
+ import { gitEnv } from '@/git/env'
4
4
  import {
5
5
  type Confidence,
6
6
  liveness,
@@ -10,8 +10,8 @@ import {
10
10
  } from 'node:fs'
11
11
  import { basename, dirname, isAbsolute, join, relative } from 'node:path'
12
12
  import { $ } from 'bun'
13
- import { gitEnv } from '@/git-env'
14
- import { recordDir, recordRoot, SCRATCH } from '@/record-root'
13
+ import { gitEnv } from '@/git/env'
14
+ import { recordDir, recordRoot, SCRATCH } from '@/roots/record'
15
15
  import { remoteIdentity } from '@/records/backup'
16
16
  import {
17
17
  encodeProjectPath,
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs'
2
2
  import { join, relative, resolve } from 'node:path'
3
- import { creationRel, recordDir } from '@/record-root'
3
+ import { creationRel, recordDir } from '@/roots/record'
4
4
  import { readDeck } from '@/slides/convert/deck'
5
5
 
6
6
  /**