@archmax-ai/harness 0.0.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 (351) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/dist/agent.d.ts +174 -0
  4. package/dist/agent.js +140 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/assembly/compose.d.ts +170 -0
  7. package/dist/assembly/compose.js +391 -0
  8. package/dist/assembly/compose.js.map +1 -0
  9. package/dist/assembly/delegation-registry.d.ts +5 -0
  10. package/dist/assembly/delegation-registry.js +118 -0
  11. package/dist/assembly/delegation-registry.js.map +1 -0
  12. package/dist/assembly/index.d.ts +192 -0
  13. package/dist/assembly/index.js +265 -0
  14. package/dist/assembly/index.js.map +1 -0
  15. package/dist/assembly/plain.d.ts +7 -0
  16. package/dist/assembly/plain.js +41 -0
  17. package/dist/assembly/plain.js.map +1 -0
  18. package/dist/authoring-skill/archmax-harness/README.md +76 -0
  19. package/dist/authoring-skill/archmax-harness/SKILL.md +241 -0
  20. package/dist/authoring-skill/archmax-harness/references/backend-integration.md +626 -0
  21. package/dist/authoring-skill/archmax-harness/references/cases.md +41 -0
  22. package/dist/authoring-skill/archmax-harness/references/diagnostics.md +51 -0
  23. package/dist/authoring-skill/archmax-harness/references/hook-and-test-scripts.md +1070 -0
  24. package/dist/authoring-skill/archmax-harness/references/hooks-and-scripts.md +82 -0
  25. package/dist/authoring-skill/archmax-harness/references/wiring.md +101 -0
  26. package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +1631 -0
  27. package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +241 -0
  28. package/dist/behaviour/support.d.ts +232 -0
  29. package/dist/behaviour/support.js +339 -0
  30. package/dist/behaviour/support.js.map +1 -0
  31. package/dist/cli/banner.d.ts +11 -0
  32. package/dist/cli/banner.js +29 -0
  33. package/dist/cli/banner.js.map +1 -0
  34. package/dist/cli/bootstrap.d.ts +33 -0
  35. package/dist/cli/bootstrap.js +153 -0
  36. package/dist/cli/bootstrap.js.map +1 -0
  37. package/dist/cli/command.d.ts +39 -0
  38. package/dist/cli/command.js +97 -0
  39. package/dist/cli/command.js.map +1 -0
  40. package/dist/cli/state-flow.d.ts +46 -0
  41. package/dist/cli/state-flow.js +268 -0
  42. package/dist/cli/state-flow.js.map +1 -0
  43. package/dist/cli/style.d.ts +41 -0
  44. package/dist/cli/style.js +64 -0
  45. package/dist/cli/style.js.map +1 -0
  46. package/dist/cli/turn.d.ts +41 -0
  47. package/dist/cli/turn.js +161 -0
  48. package/dist/cli/turn.js.map +1 -0
  49. package/dist/cli.d.ts +10 -0
  50. package/dist/cli.js +426 -0
  51. package/dist/cli.js.map +1 -0
  52. package/dist/core/code-description.d.ts +22 -0
  53. package/dist/core/code-description.js +38 -0
  54. package/dist/core/code-description.js.map +1 -0
  55. package/dist/core/deepagents.d.ts +183 -0
  56. package/dist/core/deepagents.js +128 -0
  57. package/dist/core/deepagents.js.map +1 -0
  58. package/dist/core/events.d.ts +350 -0
  59. package/dist/core/events.js +205 -0
  60. package/dist/core/events.js.map +1 -0
  61. package/dist/core/frontmatter.d.ts +15 -0
  62. package/dist/core/frontmatter.js +32 -0
  63. package/dist/core/frontmatter.js.map +1 -0
  64. package/dist/core/listing-redact.d.ts +37 -0
  65. package/dist/core/listing-redact.js +88 -0
  66. package/dist/core/listing-redact.js.map +1 -0
  67. package/dist/core/match.d.ts +26 -0
  68. package/dist/core/match.js +75 -0
  69. package/dist/core/match.js.map +1 -0
  70. package/dist/core/message-readers.d.ts +84 -0
  71. package/dist/core/message-readers.js +159 -0
  72. package/dist/core/message-readers.js.map +1 -0
  73. package/dist/core/messages.d.ts +49 -0
  74. package/dist/core/messages.js +85 -0
  75. package/dist/core/messages.js.map +1 -0
  76. package/dist/core/mount-prompt.d.ts +28 -0
  77. package/dist/core/mount-prompt.js +55 -0
  78. package/dist/core/mount-prompt.js.map +1 -0
  79. package/dist/core/mount-redact.d.ts +28 -0
  80. package/dist/core/mount-redact.js +32 -0
  81. package/dist/core/mount-redact.js.map +1 -0
  82. package/dist/core/mounts.d.ts +88 -0
  83. package/dist/core/mounts.js +147 -0
  84. package/dist/core/mounts.js.map +1 -0
  85. package/dist/core/path-mapping.d.ts +52 -0
  86. package/dist/core/path-mapping.js +113 -0
  87. package/dist/core/path-mapping.js.map +1 -0
  88. package/dist/core/platform-prompt.generated.d.ts +2 -0
  89. package/dist/core/platform-prompt.generated.js +4 -0
  90. package/dist/core/platform-prompt.generated.js.map +1 -0
  91. package/dist/core/prompt.d.ts +42 -0
  92. package/dist/core/prompt.js +75 -0
  93. package/dist/core/prompt.js.map +1 -0
  94. package/dist/core/session-id.d.ts +38 -0
  95. package/dist/core/session-id.js +61 -0
  96. package/dist/core/session-id.js.map +1 -0
  97. package/dist/core/session-store.d.ts +85 -0
  98. package/dist/core/session-store.js +149 -0
  99. package/dist/core/session-store.js.map +1 -0
  100. package/dist/core/session-zone.d.ts +54 -0
  101. package/dist/core/session-zone.js +82 -0
  102. package/dist/core/session-zone.js.map +1 -0
  103. package/dist/core/skill-prompt.d.ts +31 -0
  104. package/dist/core/skill-prompt.js +54 -0
  105. package/dist/core/skill-prompt.js.map +1 -0
  106. package/dist/core/skill-redact.d.ts +15 -0
  107. package/dist/core/skill-redact.js +27 -0
  108. package/dist/core/skill-redact.js.map +1 -0
  109. package/dist/core/skills.d.ts +172 -0
  110. package/dist/core/skills.js +214 -0
  111. package/dist/core/skills.js.map +1 -0
  112. package/dist/core/tool-mocks.d.ts +23 -0
  113. package/dist/core/tool-mocks.js +39 -0
  114. package/dist/core/tool-mocks.js.map +1 -0
  115. package/dist/core/tool-telemetry.d.ts +16 -0
  116. package/dist/core/tool-telemetry.js +40 -0
  117. package/dist/core/tool-telemetry.js.map +1 -0
  118. package/dist/core/usage.d.ts +114 -0
  119. package/dist/core/usage.js +226 -0
  120. package/dist/core/usage.js.map +1 -0
  121. package/dist/core/workspace-context.d.ts +136 -0
  122. package/dist/core/workspace-context.js +178 -0
  123. package/dist/core/workspace-context.js.map +1 -0
  124. package/dist/core/workspace-prompt.d.ts +7 -0
  125. package/dist/core/workspace-prompt.js +102 -0
  126. package/dist/core/workspace-prompt.js.map +1 -0
  127. package/dist/core/workspace-router.d.ts +61 -0
  128. package/dist/core/workspace-router.js +145 -0
  129. package/dist/core/workspace-router.js.map +1 -0
  130. package/dist/core/workspace.d.ts +85 -0
  131. package/dist/core/workspace.js +193 -0
  132. package/dist/core/workspace.js.map +1 -0
  133. package/dist/core/zones.d.ts +181 -0
  134. package/dist/core/zones.js +202 -0
  135. package/dist/core/zones.js.map +1 -0
  136. package/dist/env.d.ts +99 -0
  137. package/dist/env.js +271 -0
  138. package/dist/env.js.map +1 -0
  139. package/dist/index.d.ts +265 -0
  140. package/dist/index.js +197 -0
  141. package/dist/index.js.map +1 -0
  142. package/dist/kernel/kernel.d.ts +192 -0
  143. package/dist/kernel/kernel.js +751 -0
  144. package/dist/kernel/kernel.js.map +1 -0
  145. package/dist/lifecycle/hook-shape.d.ts +44 -0
  146. package/dist/lifecycle/hook-shape.js +66 -0
  147. package/dist/lifecycle/hook-shape.js.map +1 -0
  148. package/dist/lifecycle/runner.d.ts +177 -0
  149. package/dist/lifecycle/runner.js +372 -0
  150. package/dist/lifecycle/runner.js.map +1 -0
  151. package/dist/lifecycle/transcript.d.ts +27 -0
  152. package/dist/lifecycle/transcript.js +43 -0
  153. package/dist/lifecycle/transcript.js.map +1 -0
  154. package/dist/machine/allow.d.ts +65 -0
  155. package/dist/machine/allow.js +107 -0
  156. package/dist/machine/allow.js.map +1 -0
  157. package/dist/machine/delegation.d.ts +9 -0
  158. package/dist/machine/delegation.js +10 -0
  159. package/dist/machine/delegation.js.map +1 -0
  160. package/dist/machine/diagnostic.d.ts +14 -0
  161. package/dist/machine/diagnostic.js +2 -0
  162. package/dist/machine/diagnostic.js.map +1 -0
  163. package/dist/machine/lint-spec.d.ts +4 -0
  164. package/dist/machine/lint-spec.js +266 -0
  165. package/dist/machine/lint-spec.js.map +1 -0
  166. package/dist/machine/load-spec.d.ts +90 -0
  167. package/dist/machine/load-spec.js +120 -0
  168. package/dist/machine/load-spec.js.map +1 -0
  169. package/dist/machine/machine.d.ts +273 -0
  170. package/dist/machine/machine.js +551 -0
  171. package/dist/machine/machine.js.map +1 -0
  172. package/dist/machine/mount-grants.d.ts +25 -0
  173. package/dist/machine/mount-grants.js +24 -0
  174. package/dist/machine/mount-grants.js.map +1 -0
  175. package/dist/machine/slug.d.ts +14 -0
  176. package/dist/machine/slug.js +17 -0
  177. package/dist/machine/slug.js.map +1 -0
  178. package/dist/machine/spec-schema.d.ts +662 -0
  179. package/dist/machine/spec-schema.js +717 -0
  180. package/dist/machine/spec-schema.js.map +1 -0
  181. package/dist/machine/tool-names.d.ts +98 -0
  182. package/dist/machine/tool-names.js +144 -0
  183. package/dist/machine/tool-names.js.map +1 -0
  184. package/dist/machine/triggers.d.ts +114 -0
  185. package/dist/machine/triggers.js +165 -0
  186. package/dist/machine/triggers.js.map +1 -0
  187. package/dist/machine/types.d.ts +186 -0
  188. package/dist/machine/types.js +12 -0
  189. package/dist/machine/types.js.map +1 -0
  190. package/dist/machine/validate-spec.d.ts +42 -0
  191. package/dist/machine/validate-spec.js +29 -0
  192. package/dist/machine/validate-spec.js.map +1 -0
  193. package/dist/machine/variables.d.ts +196 -0
  194. package/dist/machine/variables.js +415 -0
  195. package/dist/machine/variables.js.map +1 -0
  196. package/dist/public/cli.d.ts +15 -0
  197. package/dist/public/cli.js +14 -0
  198. package/dist/public/cli.js.map +1 -0
  199. package/dist/public/messages.d.ts +33 -0
  200. package/dist/public/messages.js +33 -0
  201. package/dist/public/messages.js.map +1 -0
  202. package/dist/public/sandbox.d.ts +99 -0
  203. package/dist/public/sandbox.js +32 -0
  204. package/dist/public/sandbox.js.map +1 -0
  205. package/dist/public/spec.d.ts +147 -0
  206. package/dist/public/spec.js +149 -0
  207. package/dist/public/spec.js.map +1 -0
  208. package/dist/public/testing.d.ts +71 -0
  209. package/dist/public/testing.js +39 -0
  210. package/dist/public/testing.js.map +1 -0
  211. package/dist/rubrics/rubrics.d.ts +135 -0
  212. package/dist/rubrics/rubrics.js +145 -0
  213. package/dist/rubrics/rubrics.js.map +1 -0
  214. package/dist/runtime/contract.d.ts +73 -0
  215. package/dist/runtime/contract.js +96 -0
  216. package/dist/runtime/contract.js.map +1 -0
  217. package/dist/sandbox/assets/parts/core.js +22 -0
  218. package/dist/sandbox/assets/parts/lifecycle-hook.js +47 -0
  219. package/dist/sandbox/bridge.d.ts +16 -0
  220. package/dist/sandbox/bridge.js +95 -0
  221. package/dist/sandbox/bridge.js.map +1 -0
  222. package/dist/sandbox/executor.d.ts +46 -0
  223. package/dist/sandbox/executor.js +119 -0
  224. package/dist/sandbox/executor.js.map +1 -0
  225. package/dist/sandbox/imports.d.ts +33 -0
  226. package/dist/sandbox/imports.js +67 -0
  227. package/dist/sandbox/imports.js.map +1 -0
  228. package/dist/sandbox/prelude.d.ts +12 -0
  229. package/dist/sandbox/prelude.js +51 -0
  230. package/dist/sandbox/prelude.js.map +1 -0
  231. package/dist/sandbox/ptc-gateway.d.ts +104 -0
  232. package/dist/sandbox/ptc-gateway.js +167 -0
  233. package/dist/sandbox/ptc-gateway.js.map +1 -0
  234. package/dist/sandbox/runtime.d.ts +59 -0
  235. package/dist/sandbox/runtime.js +51 -0
  236. package/dist/sandbox/runtime.js.map +1 -0
  237. package/dist/sandbox/tools.d.ts +34 -0
  238. package/dist/sandbox/tools.js +147 -0
  239. package/dist/sandbox/tools.js.map +1 -0
  240. package/dist/sessions/operations.d.ts +79 -0
  241. package/dist/sessions/operations.js +121 -0
  242. package/dist/sessions/operations.js.map +1 -0
  243. package/dist/sessions/resolve.d.ts +97 -0
  244. package/dist/sessions/resolve.js +145 -0
  245. package/dist/sessions/resolve.js.map +1 -0
  246. package/dist/sessions/resume.d.ts +242 -0
  247. package/dist/sessions/resume.js +288 -0
  248. package/dist/sessions/resume.js.map +1 -0
  249. package/dist/sessions/scope.d.ts +99 -0
  250. package/dist/sessions/scope.js +145 -0
  251. package/dist/sessions/scope.js.map +1 -0
  252. package/dist/sessions/summary.d.ts +65 -0
  253. package/dist/sessions/summary.js +56 -0
  254. package/dist/sessions/summary.js.map +1 -0
  255. package/dist/testing/assertions.d.ts +30 -0
  256. package/dist/testing/assertions.js +231 -0
  257. package/dist/testing/assertions.js.map +1 -0
  258. package/dist/testing/case-document.d.ts +141 -0
  259. package/dist/testing/case-document.js +51 -0
  260. package/dist/testing/case-document.js.map +1 -0
  261. package/dist/testing/case-schema.d.ts +29 -0
  262. package/dist/testing/case-schema.js +519 -0
  263. package/dist/testing/case-schema.js.map +1 -0
  264. package/dist/testing/discovery.d.ts +10 -0
  265. package/dist/testing/discovery.js +24 -0
  266. package/dist/testing/discovery.js.map +1 -0
  267. package/dist/testing/driver.d.ts +78 -0
  268. package/dist/testing/driver.js +117 -0
  269. package/dist/testing/driver.js.map +1 -0
  270. package/dist/testing/grade.d.ts +116 -0
  271. package/dist/testing/grade.js +261 -0
  272. package/dist/testing/grade.js.map +1 -0
  273. package/dist/testing/interpreter.d.ts +55 -0
  274. package/dist/testing/interpreter.js +103 -0
  275. package/dist/testing/interpreter.js.map +1 -0
  276. package/dist/testing/mock-middleware.d.ts +5 -0
  277. package/dist/testing/mock-middleware.js +33 -0
  278. package/dist/testing/mock-middleware.js.map +1 -0
  279. package/dist/testing/runner.d.ts +205 -0
  280. package/dist/testing/runner.js +266 -0
  281. package/dist/testing/runner.js.map +1 -0
  282. package/dist/testing/target.d.ts +27 -0
  283. package/dist/testing/target.js +21 -0
  284. package/dist/testing/target.js.map +1 -0
  285. package/dist/validate/validate.d.ts +41 -0
  286. package/dist/validate/validate.js +669 -0
  287. package/dist/validate/validate.js.map +1 -0
  288. package/dist/workflow/agent-tools.d.ts +10 -0
  289. package/dist/workflow/agent-tools.js +10 -0
  290. package/dist/workflow/agent-tools.js.map +1 -0
  291. package/dist/workflow/checkpointer.d.ts +52 -0
  292. package/dist/workflow/checkpointer.js +200 -0
  293. package/dist/workflow/checkpointer.js.map +1 -0
  294. package/dist/workflow/control-tools.d.ts +174 -0
  295. package/dist/workflow/control-tools.js +468 -0
  296. package/dist/workflow/control-tools.js.map +1 -0
  297. package/dist/workflow/governance.d.ts +104 -0
  298. package/dist/workflow/governance.js +546 -0
  299. package/dist/workflow/governance.js.map +1 -0
  300. package/dist/workflow/middleware.d.ts +85 -0
  301. package/dist/workflow/middleware.js +283 -0
  302. package/dist/workflow/middleware.js.map +1 -0
  303. package/dist/workflow/on-error.d.ts +25 -0
  304. package/dist/workflow/on-error.js +58 -0
  305. package/dist/workflow/on-error.js.map +1 -0
  306. package/dist/workflow/parks.d.ts +46 -0
  307. package/dist/workflow/parks.js +513 -0
  308. package/dist/workflow/parks.js.map +1 -0
  309. package/dist/workflow/paths.d.ts +67 -0
  310. package/dist/workflow/paths.js +108 -0
  311. package/dist/workflow/paths.js.map +1 -0
  312. package/dist/workflow/prompt-cache.d.ts +70 -0
  313. package/dist/workflow/prompt-cache.js +103 -0
  314. package/dist/workflow/prompt-cache.js.map +1 -0
  315. package/dist/workflow/prompt-pruning.d.ts +20 -0
  316. package/dist/workflow/prompt-pruning.js +83 -0
  317. package/dist/workflow/prompt-pruning.js.map +1 -0
  318. package/dist/workflow/render-prompt.d.ts +45 -0
  319. package/dist/workflow/render-prompt.js +140 -0
  320. package/dist/workflow/render-prompt.js.map +1 -0
  321. package/dist/workflow/runtime-config.d.ts +7 -0
  322. package/dist/workflow/runtime-config.js +9 -0
  323. package/dist/workflow/runtime-config.js.map +1 -0
  324. package/dist/workflow/session-artifacts.d.ts +90 -0
  325. package/dist/workflow/session-artifacts.js +134 -0
  326. package/dist/workflow/session-artifacts.js.map +1 -0
  327. package/dist/workflow/session-view.d.ts +31 -0
  328. package/dist/workflow/session-view.js +94 -0
  329. package/dist/workflow/session-view.js.map +1 -0
  330. package/dist/workflow/snapshot.d.ts +14 -0
  331. package/dist/workflow/snapshot.js +97 -0
  332. package/dist/workflow/snapshot.js.map +1 -0
  333. package/dist/workflow/state.d.ts +347 -0
  334. package/dist/workflow/state.js +322 -0
  335. package/dist/workflow/state.js.map +1 -0
  336. package/dist/workflow/sub-workflow.d.ts +174 -0
  337. package/dist/workflow/sub-workflow.js +392 -0
  338. package/dist/workflow/sub-workflow.js.map +1 -0
  339. package/dist/workflow/tool-service.d.ts +48 -0
  340. package/dist/workflow/tool-service.js +291 -0
  341. package/dist/workflow/tool-service.js.map +1 -0
  342. package/dist/workflow/turn-boundary.d.ts +39 -0
  343. package/dist/workflow/turn-boundary.js +128 -0
  344. package/dist/workflow/turn-boundary.js.map +1 -0
  345. package/dist/workflow/turn-runner.d.ts +40 -0
  346. package/dist/workflow/turn-runner.js +122 -0
  347. package/dist/workflow/turn-runner.js.map +1 -0
  348. package/dist/workflow/workflow-tools.d.ts +46 -0
  349. package/dist/workflow/workflow-tools.js +90 -0
  350. package/dist/workflow/workflow-tools.js.map +1 -0
  351. package/package.json +119 -0
@@ -0,0 +1,1070 @@
1
+ # Hook scripts & cases — full reference
2
+
3
+ The complete contract and worked examples for the two authored surfaces this
4
+ reference covers:
5
+
6
+ 1. **Lifecycle hook scripts** (`workflows/<slug>/hooks/*.js` — beside the spec
7
+ that wires them, on the **authoring plane**) — deterministic `before`/`after`
8
+ gates, **hand-authored** `.js` files. `workflow.yaml` wires
9
+ `{ script: hooks/<name>.js }`: the path is **workflow-relative** and confined
10
+ to that workflow's `hooks/` directory, so it resolves to
11
+ `workflows/<slug>/hooks/<name>.js`. An absolute path, a `..` climb, or a path
12
+ outside `hooks/` is a load error and a `validate` error. They run in the
13
+ QuickJS sandbox: plain JavaScript with **top-level `await`**, no State APIs, no
14
+ filesystem beyond the `tools.*` bridge.
15
+
16
+ **A hook script is not a runtime script.** The runtime runs a hook; the agent
17
+ runs an `archmax_run` source. The two live in different places on purpose —
18
+ hooks in `workflows/<slug>/hooks/`, runtime scripts in
19
+ `skills/<capability>/scripts/` — and the classes cannot meet: the agent
20
+ cannot read `workflows/**` at all, and `archmax_run` is confined to skill
21
+ bundles by a non-overridable kernel rule (`script.skill-only`). An agent that
22
+ could execute its own guard could probe it for a bypass, which is the whole
23
+ reason for the split. A hook's source also never reaches the model: not in a
24
+ prompt, not in an event, not in a session artifact — a veto reports its
25
+ verdict and reason, never its code.
26
+ 2. **Cases** (`workflows/<name>/tests/*.test.yaml`) — declarative
27
+ YAML documents run by `archmax test` and interpreted **host-side**: no
28
+ sandbox executes a case. Suite config is the `tests:` block in
29
+ `workflow.yaml` (there is no `tests.config.js`).
30
+
31
+ Sandbox scripts are self-contained: the only permitted imports are **typed
32
+ `@archmax-ai/harness/*` carriers** (stripped before evaluation — see
33
+ [Typed imports](#typed-imports)); importing from any other specifier is an
34
+ **error**. Each sandbox context gets its own globals — nothing leaks between
35
+ them (`SANDBOX_CONTRACT.context` is `"lifecycle-hook"` for a hook, or `"ptc"`
36
+ for the agent's own `archmax_eval`/`archmax_run` code, which gets **no** hook
37
+ vocabulary at all; `SANDBOX_CONTRACT.version` is `2`).
38
+
39
+ ## Contents
40
+
41
+ - [Typed imports](#typed-imports)
42
+ - [Part 1 — Lifecycle hook scripts](#part-1--lifecycle-hook-scripts)
43
+ - [Authoring model: hand-authored](#authoring-model-hand-authored)
44
+ - [The hook contract](#the-hook-contract)
45
+ - [Governance of `tools.*` calls](#governance-of-tools-calls)
46
+ - [`archmax_run` scripts: the `args` contract](#archmax_run-scripts-the-args-contract)
47
+ - [Scripts and sub-workflow fan-out](#scripts-and-sub-workflow-fan-out)
48
+ - [Worked example: a `before` gate](#worked-example-a-before-gate)
49
+ - [Worked example: an `after` policy check](#worked-example-an-after-policy-check)
50
+ - [When to use a grading rubric instead](#when-to-use-a-grading-rubric-instead)
51
+ - [Part 2 — Cases (declarative YAML)](#part-2--cases-cases-declarative-yaml)
52
+ - [Layout and the prose rules](#layout-and-the-prose-rules)
53
+ - [Suite config: the `tests:` block](#suite-config-the-tests-block)
54
+ - [Top-level keys](#top-level-keys)
55
+ - [Step entries: one flat script](#step-entries-one-flat-script)
56
+ - [Assertion grammar](#assertion-grammar)
57
+ - [Structural assertions halt the case](#structural-assertions-halt-the-case)
58
+ - [Start conditions: trigger, variables, and workspace](#start-conditions-trigger-variables-and-workspace)
59
+ - [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs)
60
+ - [File fixtures: `from:` references](#file-fixtures-from-references)
61
+ - [Settle the mocking depth before writing a case](#settle-the-mocking-depth-before-writing-a-case)
62
+ - [Worked example: happy path + grade](#worked-example-happy-path--grade)
63
+ - [Worked example: veto, leak check, `noTraversal`](#worked-example-veto-leak-check-notraversal)
64
+ - [Worked example: mocking tool results](#worked-example-mocking-tool-results)
65
+ - [Worked example: human-in-the-loop decisions (refine loop)](#worked-example-human-in-the-loop-decisions-refine-loop)
66
+ - [Worked example: triggered start + fixture file](#worked-example-triggered-start--fixture-file)
67
+ - [Migrating a JS case to YAML](#migrating-a-js-case-to-yaml)
68
+
69
+ ---
70
+
71
+ ## Typed imports
72
+
73
+ A hook script may open with a **typed import** — a type carrier that gives IDE
74
+ completion for the hook input and the verdict helpers, and is **stripped before
75
+ QuickJS evaluation** (the runtime implementation is an identity):
76
+
77
+ ```js
78
+ import { ok, veto, correct } from "@archmax-ai/harness/sandbox";
79
+ /** … */
80
+ export default async function hook({ state, variables, messages, tools }) {
81
+ /* ... */
82
+ }
83
+ ```
84
+
85
+ Importing from any **non-`@archmax-ai/harness/*`** specifier is an **error** — sandbox
86
+ scripts are self-contained. **Bare-globals authoring** (no import) is equally
87
+ valid: `ok`, `veto`, `correct` and `defineHook` are prelude globals.
88
+
89
+ ---
90
+
91
+ ## Part 1 — Lifecycle hook scripts
92
+
93
+ ### Authoring model: hand-authored
94
+
95
+ A script hook references an **ordinary hand-authored** `.js` file. You DESIGN
96
+ AND WRITE the script directly — there is no generation step. `workflow.yaml`
97
+ only wires the hook, one or a list run in order (the first veto stops the list):
98
+
99
+ ```yaml
100
+ states:
101
+ identify-case:
102
+ triggers:
103
+ manual:
104
+ before:
105
+ - script: hooks/check-requester.js
106
+ - script: hooks/check-tenancy.js
107
+ ```
108
+
109
+ A hook script **MUST** open with a leading JSDoc block — a title line, a blank
110
+ line, and a description of what the check does _and how_: the inputs it reads,
111
+ the sequence of checks, and the condition producing **each** verdict. It is the
112
+ only human-facing contract the file has (admin UIs display it instead of the
113
+ code), and it is what a reviewer refines when the gate is wrong. No
114
+ `@specification` tag, no `// archmax:generated` marker, no generation semantics.
115
+ See [SKILL.md → Code files are description-first](../SKILL.md#code-files-are-description-first).
116
+
117
+ The `specification` sidecar in `workflow.yaml` is **not a recognized key**:
118
+ `{ script: x.js, specification: … }` is a malformed two-key hook and a
119
+ validation **error**.
120
+
121
+ - `archmax validate` reports a hook-script problem only when the script **file
122
+ is missing** or it has a **foreign (non-`@archmax-ai/harness/*`) import**. It does
123
+ **not** check the JSDoc block on any script — the header is an authoring
124
+ standard you uphold yourself. (Cases are YAML; there `archmax validate`
125
+ **errors** on a missing `description` instead.)
126
+ - The runtime never generates scripts: a missing script file at run time
127
+ errors and vetoes (fail-closed).
128
+
129
+ ### The hook contract
130
+
131
+ A hook is a **default-export function**. The runtime calls it with **one
132
+ object** and reads its **return value** as the verdict:
133
+
134
+ ```js
135
+ /**
136
+ * Requester gate — reject a run whose requester is not a known customer.
137
+ *
138
+ * Reads the first user message from `messages`, extracts the first email
139
+ * address in it, loads skills/order-data/assets/orders.json through the tool
140
+ * bridge, and looks the address up against every order's `email`.
141
+ *
142
+ * Vetoes when the request carries no email address, and when the address
143
+ * matches no order — each with a reason naming what was missing. Otherwise ok.
144
+ */
145
+ export default async function hook({ state, phase, trigger, variables, messages, tools }) {
146
+ const orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
147
+ const request = messages.find((m) => m.role === "user")?.text ?? "";
148
+ const email = request.match(/[\w.+-]+@[\w.-]+/)?.[0];
149
+ if (!email) return veto("No email address found in your request.");
150
+ if (!orders.some((o) => o.email === email)) return veto(`"${email}" is not in the order database.`);
151
+ console.log(`Allowed: ${email}`);
152
+ return ok();
153
+ }
154
+ ```
155
+
156
+ **The input** (`HookInput` in `@archmax-ai/harness/sandbox`; the same fields minus
157
+ `tools` are also the global `args`):
158
+
159
+ | Field | Meaning |
160
+ | --- | --- |
161
+ | `state`, `phase` | The state whose hook is running; `"before"` or `"after"`. |
162
+ | `trigger` | The trigger **id string** the run started from. It carries no payload — the run's input is in `variables`. |
163
+ | `variables` | The run's variables as a plain `name → value` map, including the built-in `trigger`. Structured values are present whole (`variables.order.items[0].sku`). **Read-only**: assigning changes nothing, and the variable tools are absent from `tools.*` — a hook that could write variables would be editing the governance inputs of the state it judges. |
164
+ | `messages` | The recent transcript (last 24 messages), newest last, as `{ role, text, toolCalls? }`. `role` is `user`, `assistant`, `tool` (with `tool: <name>`), `system`, or **`runtime`** — a note the runtime wrote (an arrival, a decision, an error route, a completion check; `note` names the kind). A hook that reacts to narration matches `role === "runtime"`, never a `[bracket]` prefix in a user message. |
165
+ | `from`, `to`, `reason` | Present on `after` hooks: the transition the agent is attempting (`reason` is the agent's `archmax_advance` reason). |
166
+ | `tools` | The privileged tool-call bridge (async), camelCased: `await tools.readFile({ file_path: "skills/order-data/assets/orders.json" })` → file text. See [Governance](#governance-of-tools-calls). |
167
+
168
+ **The verdict** — the three helpers are globals:
169
+
170
+ | Return | Effect |
171
+ | --- | --- |
172
+ | `ok()` (or nothing) | Proceed. |
173
+ | `veto(reason)` | Block. `before`: entry to the state (for a start state, the whole run). `after`: the `archmax_advance` transition, or the completion of a terminal state. The reason is shown to the agent — make it precise and actionable. |
174
+ | `correct(reason)` | **`after` hooks only**: the agent gets another attempt with the reason as correction, bounded by the hook-level `max_iterations` sidecar (`after: { script: hooks/review.js, max_iterations: 2 }`; default 0 — a `correct` with no budget is a hard veto). The budget is counted per hook, so a passing sibling hook does not reset it, and a `correct` refused on a spent budget consumes nothing. A `before` `correct` is treated as a veto and is a validation error. |
175
+ | `false` | A veto (`"precondition not met"`) — the shorthand for `return isAuthorized(x)`. |
176
+ | any other object | A **veto**, with the object's keys named in the reason. A hook's return value *is* its verdict, so a misspelled `verdict` or a shape like `{ ok: false, reason }` is a verdict got wrong; reading it as `ok` would silently permit what the hook meant to block. |
177
+ | `true`, a number, a string | Proceed — a non-object says nothing about the verdict, including a bare body's incidental completion value. |
178
+
179
+ A hook that **throws** — or whose file is missing — **vetoes** with the error
180
+ as the reason (fail-closed). Parse JSON defensively (`try`/`catch`) and turn
181
+ malformed inputs into a `veto` with a good message instead of letting them
182
+ throw; conversely, if a failure should *not* block, catch it and return `ok()`.
183
+
184
+ Other globals: `console.log(...)` (captured into the trail; shown in verbose
185
+ runs), and `defineHook(fn)`, an alias
186
+ for `export default fn`. A file with **no default export** is evaluated as a
187
+ plain script and its **completion value** is the verdict, so a short gate can be
188
+ `orders.length ? ok() : veto("no orders")` on its last line.
189
+
190
+ `${{…}}` does not apply inside a hook: the runtime substitutes references in the
191
+ arguments the *model* writes. A hook already has the values in `variables` and
192
+ composes strings in JavaScript, so `${{…}}` text in a hook's `tools.*` call is
193
+ ordinary data.
194
+
195
+ ### Governance of `tools.*` calls
196
+
197
+ Every `tools.*` call is checked by the decision kernel at the moment it runs —
198
+ the same kernel, with the same rules, that governs a tool call the model makes
199
+ directly. What differs is only _whose authority_ the script runs on:
200
+
201
+ | Script kind | The state's own governance (`tools.allow`, `tools.forbid`, `skills.*`) | Safety rules, workflow-wide `forbid_always`, consumer rules |
202
+ | ----------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
203
+ | Lifecycle hook (`before:`/`after:`) | **Not applied** — a gate must read evidence the state never grants, and a state's denial does not reach it either | Applied |
204
+ | `archmax_run` script (the model chose to run it) | **Applied** — it runs on the model's authority | Applied |
205
+
206
+ A hook runs on **runtime authority**: outside the active state's tool surface in
207
+ *both* directions. So a hook may
208
+ `await tools.readFile({ file_path: "skills/order-data/assets/orders.json" })`
209
+ even in a state whose `allow` list never mentions `read_file`, one that enables
210
+ no skill at all, and one whose `tools.forbid` names the very tool it calls — while
211
+ the workflow's `tools.forbid_always` and `skills.forbid_always` bind it like
212
+ anything else, and no script of either kind may write into the read-only authored
213
+ zone.
214
+
215
+ Neither kind may **read** the authoring plane at all. A `tools.*` call naming
216
+ `workflows/**` is blocked (`zone.governance-plane`) with a
217
+ diagnostic naming the prefix — a hook cannot read its own spec, a sibling's, or
218
+ the criteria of the rubric declared in it. There is no `task()` in any sandbox
219
+ context: a script that wants a model verdict gets a `rubric` hook declared beside
220
+ it
221
+ instead.
222
+
223
+ A refused call **throws** inside the script, carrying the governance reason.
224
+ The underlying tool never runs whether or not you catch it:
225
+
226
+ ```js
227
+ try {
228
+ await tools.writeFile({ file_path: "scratchpad/report.md", content: body });
229
+ } catch (err) {
230
+ console.log(`could not write the report: ${err.message}`);
231
+ }
232
+ ```
233
+
234
+ **Authoring rule for `archmax_run` scripts:** if sandboxed code calls `tools.X`,
235
+ the state's `allow` list must cover `tool: X` (with any argument constraints the
236
+ script's call satisfies), or the call is blocked. Grant it per state with
237
+ `tools.allow`, or workflow-wide with `tools.allow_always`. Lifecycle hooks need
238
+ no such grant.
239
+
240
+ ### `archmax_run` scripts: the `args` contract
241
+
242
+ An `archmax_run` source (`skills/<capability>/scripts/*.js`) is a plain script:
243
+ top-level `await`, `tools.*`, `console.log`, and the **last expression** is its
244
+ result — returned to the model as the tool result, never into the variable
245
+ store. It reads its input from the global **`args`**:
246
+
247
+ - **the arguments the model passed** to `archmax_run` (`args.<name>`), plus
248
+ - **`args.variables`** — the session's variables as a flat `name → value` map,
249
+ the same map a hook gets. The runtime supplies it on every call, so a script
250
+ reads the run's input (a seeded list, a delivered payload) without the model
251
+ having to retype it.
252
+
253
+ ```js
254
+ /** One sub-run per requested order. */
255
+ const orders = args.variables?.orders_to_enrich ?? [];
256
+ ```
257
+
258
+ `args.variables` is a read-only snapshot; a script that must persist a result
259
+ returns it and the state's `requires:` makes the model record it with
260
+ `archmax_set_variables`. There is **no `task()`** in an `archmax_run` script or in
261
+ `archmax_eval`. The model has no `task` tool at all: it is disclosed in no state
262
+ and grantable by nothing, because a rubric grades the agent rather than serving it.
263
+
264
+ ### Scripts and sub-workflow fan-out
265
+
266
+ A script **is** the deterministic way to drive a fan-out. Delegation tools are on the `tools.*`
267
+ surface — a delegation does work and returns a value, unlike the control tools a script may never
268
+ call — so a script decides how many sub-runs to start, with what inputs, and what to do with each
269
+ answer:
270
+
271
+ ```js
272
+ /** One sub-run per delayed order, all in flight at once. */
273
+ const orders = args.variables?.orders_to_enrich ?? [];
274
+
275
+ const enriched = await Promise.all(
276
+ // The bridge camelCases tool names, and a tool result arrives as text.
277
+ orders.map(async (o) => {
278
+ const answer = await tools.archmaxWorkflowEnrichOrder({ order_id: o.order_id });
279
+ return { order_id: o.order_id, ...JSON.parse(answer).returns };
280
+ }),
281
+ );
282
+
283
+ enriched;
284
+ ```
285
+
286
+ `Promise.all` genuinely parallelizes: the bridge starts each host call detached and returns its
287
+ promise handle immediately. The runtime still bounds how many sub-runs are in flight
288
+ (the dispatcher's `maxConcurrent`), queueing the rest. The list never enters the model's
289
+ context.
290
+
291
+ Two limits still hold, both inherited rather than special to delegation. A script may only call a
292
+ delegation tool its **calling state** allows — the same kernel verdict an agent's call gets. And a
293
+ script's delegation **cannot park**: a QuickJS frame is not durable, so a child that reaches a human
294
+ state fails that call closed with `parked`.
295
+
296
+ Scripts still may not call `archmax_advance` (moving the state machine is the model's governed
297
+ decision) or `archmax_set_variables`. Concurrent calls succeed or fail **independently**, so a script
298
+ can act on what worked — `Promise.allSettled` is the honest shape when partial success is
299
+ meaningful. Give each sub-run its own output path: they share one run zone.
300
+
301
+ ### Worked example: a `before` gate
302
+
303
+ The tenancy half of the bundled example's entry gate
304
+ (`workflows/order-lookup/hooks/check-tenancy.js`), the second hook in
305
+ `identify-case`'s `before` list — so a missing or unknown email has already been
306
+ vetoed by `check-requester.js` before this runs:
307
+
308
+ ```js
309
+ /**
310
+ * Tenancy gate — a requester may only reach their own company's orders.
311
+ *
312
+ * Binds the requester's email (the first one in the first user message) to the
313
+ * customers whose orders carry it in skills/order-data/assets/orders.json, then
314
+ * checks the request two ways: it names another customer, or it references an
315
+ * order id (`ORD-…`) owned by another customer. Matching is case-insensitive.
316
+ *
317
+ * Vetoes with the customers or order ids the requester reached for when either
318
+ * check finds one; otherwise ok. If the order database cannot be read, vetoes
319
+ * too (fail-closed).
320
+ */
321
+ export default async function hook({ messages, tools }) {
322
+ let orders;
323
+ try {
324
+ orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
325
+ if (!Array.isArray(orders)) throw new Error("not an array");
326
+ } catch {
327
+ return veto("Could not load skills/order-data/assets/orders.json - unable to check tenancy.");
328
+ }
329
+
330
+ const request = messages.find((m) => m.role === "user")?.text ?? "";
331
+ const email = (request.match(/[\w.+-]+@[\w.-]+\.\w{2,}/)?.[0] ?? "").toLowerCase();
332
+ const own = new Set(
333
+ orders.filter((o) => String(o.email).toLowerCase() === email).map((o) => String(o.customer)),
334
+ );
335
+
336
+ const lower = request.toLowerCase();
337
+ const foreignByName = [...new Set(orders.map((o) => String(o.customer)))].filter(
338
+ (name) => !own.has(name) && lower.includes(name.toLowerCase()),
339
+ );
340
+ const referenced = new Set((request.match(/\bORD-\d+\b/gi) ?? []).map((id) => id.toUpperCase()));
341
+ const foreignById = orders
342
+ .filter((o) => referenced.has(String(o.id).toUpperCase()) && !own.has(String(o.customer)))
343
+ .map((o) => String(o.id));
344
+
345
+ if (foreignByName.length > 0 || foreignById.length > 0) {
346
+ const detail = (foreignByName.length > 0 ? foreignByName : foreignById).join(", ");
347
+ return veto(
348
+ `The email address "${email}" is not authorized to access orders for ${detail}. You may only view orders for your own account.`,
349
+ );
350
+ }
351
+ return ok();
352
+ }
353
+ ```
354
+
355
+ The pattern to internalize: **one `return veto(reason)` per veto condition,
356
+ each with a human-readable reason that names the fix**; `console.log` for
357
+ anything worth seeing in a verbose run; `return ok()` at the end.
358
+
359
+ ### Worked example: an `after` policy check
360
+
361
+ An `after` hook re-derives policy deterministically so the agent can never
362
+ record a decision the policy forbids. Wiring (on `refund-request` in the
363
+ bundled example's `workflow.yaml`):
364
+
365
+ ```yaml
366
+ after:
367
+ script: hooks/check-refund.js
368
+ ```
369
+
370
+ The check (`workflows/order-lookup/hooks/check-refund.js`):
371
+
372
+ ```js
373
+ /**
374
+ * Refund policy gate — the agent may not record a refund the policy forbids.
375
+ *
376
+ * Re-derives the refund decision from the order database instead of trusting
377
+ * the one the agent wrote. Reads the recorded ticket from scratchpad/refund.json
378
+ * ({ orderId, decision }), then reads skills/order-data/assets/orders.json and
379
+ * looks the order id up, case-insensitively, against each record's `id`.
380
+ *
381
+ * Policy: a refund is "approved" only when the order exists with status
382
+ * delivered or delayed; an unknown order and any other status must be "denied".
383
+ *
384
+ * Vetoes, each with a reason that names the fix, when: the ticket file is
385
+ * missing or not valid JSON; `decision` is not exactly "approved" or "denied";
386
+ * the order database cannot be read; or the recorded decision contradicts the
387
+ * derived one. Otherwise ok.
388
+ */
389
+ export default async function hook({ tools }) {
390
+ let refund;
391
+ try {
392
+ refund = JSON.parse(await tools.readFile({ file_path: "scratchpad/refund.json" }));
393
+ } catch (e) {
394
+ return veto(
395
+ `scratchpad/refund.json could not be read or parsed (${e.message}) - write it as a JSON object with "orderId" and "decision" before leaving this state`,
396
+ );
397
+ }
398
+
399
+ const decision = refund?.decision;
400
+ if (decision !== "approved" && decision !== "denied") {
401
+ return veto(`scratchpad/refund.json "decision" must be exactly "approved" or "denied"; got ${JSON.stringify(decision)}`);
402
+ }
403
+
404
+ let orders;
405
+ try {
406
+ orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
407
+ } catch {
408
+ return veto("skills/order-data/assets/orders.json could not be read - the order database must be accessible to validate the refund decision");
409
+ }
410
+
411
+ const wanted = typeof refund.orderId === "string" ? refund.orderId.trim().toLowerCase() : "";
412
+ const order = orders.find((o) => typeof o?.id === "string" && o.id.toLowerCase() === wanted);
413
+ const derived = order && ["delivered", "delayed"].includes(order.status) ? "approved" : "denied";
414
+ console.log(`Refund policy check - derived "${derived}", recorded "${decision}"`);
415
+
416
+ if (decision === derived) return ok();
417
+ const why = !order
418
+ ? "it does not exist in the order database"
419
+ : derived === "approved"
420
+ ? `its status is "${order.status}"`
421
+ : `its status "${order.status}" is not delivered or delayed`;
422
+ return veto(
423
+ `Policy requires "${derived}" for order ${JSON.stringify(refund.orderId)} because ${why}, but the recorded decision is "${decision}" - update scratchpad/refund.json to decision: "${derived}"`,
424
+ );
425
+ }
426
+ ```
427
+
428
+ Note how the veto reasons carry the _fix_: the agent reads the reason,
429
+ rewrites `scratchpad/refund.json`, and retries the transition.
430
+
431
+ ### When to use a grading rubric instead
432
+
433
+ Use a `script:` hook when the check is **deterministic** — derivable from
434
+ workspace data with plain code (policy tables, schema checks, cross-file
435
+ consistency). Use a `rubric:` hook when the check requires **judgment**
436
+ (is the reply complete? is the tone right?):
437
+
438
+ ```yaml
439
+ states:
440
+ orders-question:
441
+ after:
442
+ - rubric:
443
+ max_iterations: 2 # `correct` verdicts allowed before a hard veto
444
+ instructions: |-
445
+ You review the agent's final reply about the user's orders...
446
+ Return { "verdict": "ok" | "correct" | "veto", "reason": "..." }.
447
+ ```
448
+
449
+ A rubric may return `correct` — the agent gets another attempt with the
450
+ grader's reason as correction, budgeted by its own `max_iterations`. Script hooks
451
+ get the exact same flow when they return `correct(reason)` under a
452
+ `max_iterations` sidecar. A rubric whose response cannot be parsed
453
+ into a verdict **fails closed** (veto with a snippet of the raw output). A rubric
454
+ receives a compact JSON payload (`userRequest`, `history`, `state`, `phase`) —
455
+ its `instructions` are written against that, not against a hook's `messages`.
456
+
457
+ ---
458
+
459
+ ## Part 2 — Cases (declarative YAML)
460
+
461
+ ### Layout and the prose rules
462
+
463
+ ```
464
+ workflows/<name>/tests/
465
+ <case>.test.yaml # ONE case per file (.test.yml also accepted)
466
+ fixtures/… # fixture files, reachable via `workspace: { <path>: { from: … } }`
467
+ ```
468
+
469
+ - **One YAML document per file, one case per document — one conversation on
470
+ one session.** The case id is the tests/-relative path minus the extension
471
+ (`tests/refunds/approved.test.yaml` → case `refunds/approved`); the
472
+ `archmax test` filter argument matches that path as a substring.
473
+ - **Cases are data, not code.** The host parses, validates, and interprets
474
+ them directly — no QuickJS sandbox is created for a case. The sandbox still
475
+ runs lifecycle hooks and `archmax_run` *inside the driven agent*, unchanged.
476
+ - **A case reads like a script.** `steps` is one flat sequential list where
477
+ actions (`send`, `decide`) and assertions are peers; each assertion
478
+ evaluates against the view of the nearest action above it.
479
+ - **Only YAML files are cases.** Discovery matches `*.test.yaml`/`*.test.yml`
480
+ and nothing else: a leftover `*.test.js`/`*.test.ts` or `tests.config.js` is
481
+ ignored **without a diagnostic**, so it neither runs nor warns. Delete such
482
+ files once migrated (see
483
+ [Migrating a JS case to YAML](#migrating-a-js-case-to-yaml)).
484
+ - **Unknown keys are errors.** Unknown top-level keys and step-entry keys
485
+ fail validation — a typo'd `reachedstate:` can never silently pass. So do
486
+ an assertion with no preceding action, a nested `expect:` block (assertions
487
+ are steps themselves), and any multi-session key (`sessions`, `session`,
488
+ `parallel` — "not part of test spec version 1"). `archmax validate`
489
+ statically checks every case: schema and unknown keys, a missing or
490
+ over-long `title`/`description`, malformed regex strings, unknown trigger
491
+ ids, and `from:` fixture existence.
492
+ - **The title and description rules.** Every case declares two mandatory
493
+ prose fields, both **length-capped and enforced** — over budget is an error,
494
+ exactly like a missing field:
495
+ - **`title`** — a short one-line label, **at most 60 characters**. It is
496
+ what `archmax test` prints beside the verdict and what an admin UI lists,
497
+ so it has to fit a line: name the behavior under test
498
+ (`Unknown requester is rejected at entry`), not the mechanics.
499
+ - **`description`** — **at most 200 characters**: one or two sentences
500
+ stating the scenario driven and what is asserted. Not a transcript of the
501
+ steps, which are right below it and read as a script already.
502
+ - Everything longer goes in a **YAML comment above the document** — why the
503
+ case exists, what a particular assertion is really pinning, what was
504
+ deliberately mocked vs. left real, how the fixture is wired. The comment
505
+ has no budget; use it freely.
506
+ - Both budgets are measured on whitespace-collapsed text, so a folded
507
+ (`>-`) and a literal (`|`) block of the same prose score alike.
508
+ - Work title-first: write `title` and `description` before the steps, and
509
+ never leave them contradicting each other. This replaces the JSDoc-header
510
+ rule that applied to JS test files; JSDoc headers now apply to hook
511
+ scripts and `archmax_run` sources only.
512
+
513
+ Run with `archmax test <workflow> [filter]`. A minimal case:
514
+
515
+ ```yaml
516
+ # The route assertion is the point: without it the case would still pass if
517
+ # the agent answered the order question straight from the start state.
518
+ title: Order question routed into handle-order
519
+ description: >-
520
+ One turn asking about an order: the run succeeds and the audit trail
521
+ committed the route into handle-order.
522
+ steps:
523
+ - send: "What is the status of order ORD-1001?"
524
+ - succeeded: true
525
+ - reachedState: handle-order
526
+ ```
527
+
528
+ ### Suite config: the `tests:` block
529
+
530
+ Declarative data in `workflow.yaml` — never executed code:
531
+
532
+ ```yaml
533
+ tests:
534
+ maxConcurrency: 1 # cases run at a time (only 1 or absent is accepted)
535
+ caseTimeoutMs:
536
+ 120000 # per-case WALL-CLOCK budget (default 120000) —
537
+ # deliberately named differently from settings.timeoutMs,
538
+ # the per-script sandbox budget
539
+ judge: {} # grader settings ({} = env-configured default model;
540
+ # or { model, modelOptions })
541
+ ```
542
+
543
+ There is no `reporters` option. A
544
+ `judge:` entry is required for cases that declare `grade:` assertions —
545
+ without it the assertion records an actionable failure instead of silently
546
+ passing.
547
+
548
+ ### Top-level keys
549
+
550
+ Unknown keys are validation **errors**. All keys except `title` and
551
+ `description` are optional:
552
+
553
+ | Key | Shape | Meaning |
554
+ | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
555
+ | `title` | string (**required**, ≤ 60 chars, one line) | Short label naming the behavior under test — what `archmax test` prints beside the verdict. Missing, multi-line, or over budget ⇒ `archmax validate` **error**. |
556
+ | `description` | string (**required**, ≤ 200 chars) | One or two sentences: the scenario driven and what is asserted. Missing or over budget ⇒ `archmax validate` **error**. Longer rationale belongs in a YAML comment above the document. |
557
+ | `skip` | string | A reason; the case reports a skipped verdict and drives nothing. |
558
+ | `variables` | `{ <name>: <value> }` | Session variables seeded for every driven turn, supplied as the runtime's `variables` and therefore **locked**. Values may be structured. This is where a case puts the run's input — a trigger carries none. Recover the real payload from a previous run's `artifacts/variables.json` rather than inventing one: see [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs). |
559
+ | `trigger` | `{ id }` | The trigger every driven turn of the case runs under. Carries **only an id**; `args` is rejected with a pointer to `variables:`. Validated against the workflow machine before any step — an unknown id fails the case (even with no steps), listing the workflow's declared triggers. Omitted ⇒ the manual trigger. See [Start conditions](#start-conditions-trigger-variables-and-workspace). |
560
+ | `workspace` | `{ <path>: <content> }` | Files seeded into the case's run workspace **before any step**. Strings are written verbatim; non-string YAML values are JSON-serialized; a single-key `{ from: <tests/-relative path> }` mapping copies a file byte-for-byte. Only agent-visible run-zone paths are seedable (root-level files like `trigger.json`, or `scratchpad/…`); authored (`skills/…`), runtime-internal, and escaping paths are rejected before anything is written. |
561
+ | `mocks` | `[{ tool, whenInput?, result }]` | Declarative tool mocks: when the driven agent calls `tool` with input partially matching `whenInput`, return `result` instead of executing. Intercepts **agent-initiated and PTC** (script `tools.*`) calls alike. See [the mocking section](#settle-the-mocking-depth-before-writing-a-case). |
562
+ | `steps` | flat list of step entries | The case script: actions and assertions as peers (below). |
563
+
564
+ There is **no multi-session surface** in test spec version 1 — no `sessions:`,
565
+ no `session:` on a step, no `parallel:`, and no case-level `expect:` list;
566
+ each is rejected with an actionable error ("not part of test spec version 1").
567
+ A case is one conversation on one session; per-session isolation is an engine
568
+ invariant covered by the SDK's own unit tests (`src/testing/driver.test.ts`),
569
+ not something workflow authors re-test per workflow.
570
+
571
+ ### Step entries: one flat script
572
+
573
+ `steps` is a single flat, sequential list — a case reads like a script. Every
574
+ entry is a **single-key mapping**: an **action**, or an **assertion** that
575
+ evaluates against the view of the **nearest action above it** (the completed
576
+ turn that action produced). An assertion before the first action is a schema
577
+ error. The two actions:
578
+
579
+ | Action | Shape | Meaning |
580
+ | -------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
581
+ | `send` | `send: "<message>"` | One user message — a plain string, nothing else (no transport fields; start conditions live at case scope). Runs the agent until it stops (final answer, park, or failure), under the case's declared trigger. Against a run **parked at a human state** it is a message instead: answered on a tool-free turn, leaving the run parked at the same state. |
582
+ | `decide` | `decide: { to, comment? }` | Resume a **parked** run: the simulated human picks the outgoing transition `to` (a state slug), optionally with a review comment (delivered to the agent as correction on `refine` edges). |
583
+
584
+ ```yaml
585
+ steps:
586
+ - send: "I'd like a refund for order ORD-1001."
587
+ - parked: true # <- asserts against the send's turn
588
+ - reachedState: refund-review # <- same turn
589
+ - send: "Any news?" # a message to the parked run, not a new turn
590
+ - parked: decision # still parked: nothing it says routes the run
591
+ - decide: { to: refund-closed, comment: "ok" }
592
+ - succeeded: true # <- asserts against the decide's turn
593
+ ```
594
+
595
+ `usedNoTools`/`calledTool` read the **whole conversation's** tool calls, not one
596
+ turn's, so do not use them to assert that a reply-only turn called nothing — in a
597
+ multi-step case they still see the earlier turns' calls.
598
+
599
+ There is no nested `expect:` block — writing one is an error with guidance
600
+ that assertions are steps themselves.
601
+
602
+ ### Assertion grammar
603
+
604
+ Each assertion is a single-key step entry (repeatable; order-preserving),
605
+ evaluated against the nearest action above it. **Every assertion is hard** —
606
+ `grade` included: the `atLeast` you declare is the bar, and a score under it
607
+ fails the case and the run.
608
+
609
+ Assertions marked **structural** below assert *where the run went*, and a
610
+ failure in one of them **halts the case** — see
611
+ [Structural assertions halt the case](#structural-assertions-halt-the-case).
612
+
613
+ | Assertion | Meaning |
614
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
615
+ | `succeeded: true` | **Structural.** The run finished: not failed **and not parked**. |
616
+ | `parked: true` | **Structural.** The run is parked at a human state awaiting a decision. |
617
+ | `variables: { expect, path?, locked? }` | Every named variable is set to the expected value — deep equality, so a structured expectation compares by value. `path` addresses into a value per name (`items.-1.sku` is the last element); `locked` asserts the lock state. |
618
+ | `reachedState: <slug>` | **Structural.** A **committed** (non-trigger) audit-trail step entered the state — agent `archmax_advance`, human decision, `archmax_reset`, or `on_error` route. The trigger-arrival step never counts (it is recorded before the entry state's `before` gate runs, so a vetoed-entry run never "reaches" its start state). |
619
+ | `reply: { includes?, excludes? }` | `includes`: substrings or `/pattern/flags` regex strings that must **all** match; `excludes`: none may match (leak checks — a refusal that quotes the data it refuses still leaks it). Each accepts a **single token or a list** (`includes: "ORD-1003"` works). Matched against the turn's **whole assistant transcript**, not just the final message (a parked run often closes with a short "moving on" message while the substantive answer came earlier). |
620
+ | `calledTool: { name, input? }` | The turn recorded a call to tool `name`; `input` is a **partial** match — mappings match on the declared keys (nested mappings partially), arrays by deep equality, and `/pattern/flags` string values match as **regexes** (recommended for file paths, so the model's exact spelling doesn't matter). |
621
+ | `notCalledTool: { name, input? }` | No matching call occurred. |
622
+ | `blockedTool: { name, input? }` | A matching call was made **and refused by governance** — the assertion for testing a guard. Distinct from `notCalledTool` (nothing was tried, which proves nothing about the guard) and from a tool that ran and errored (only a policy rejection counts). Use it to test a state's `tools.allow` narrowing or its `skills.allow` set. |
623
+ | `usedNoTools: true` | The turn used no tools at all. |
624
+ | `trail: { to?, kind?, reason?, count }` | **Structural.** The number of audit-trail steps matching **all** given fields equals `count` (declare at least one of `to`/`kind`/`reason`). `kind`: `trigger` \| `agent` \| `human` \| `on_error` \| `branch` \| `join`. The trail accumulates across turns — each turn starts with a `trigger` step. |
625
+ | `noTraversal: true` | **Structural.** The trail holds nothing but trigger arrivals — a veto at entry committed no transitions. |
626
+ | `triggerArrival: <trigger-id>` | **Structural.** Some trail step has `kind: trigger` and that trigger id as its reason — the run really started from the declared trigger. |
627
+ | `grade: { closedQA, atLeast }` | LLM-grade the turn's **run evidence** against the `closedQA` criterion — its final reply *plus* a bounded chronological record of the assistant messages and tool calls behind it (each call with input, output, and status; mocked calls appear like real ones). The two are weighed differently: a criterion about what the user was **told** is met only by the reply, one about what the agent **did** may be met by the record — so `closedQA: looked the order up before answering` is a legitimate criterion, not only phrasing checks. **`atLeast` decides the outcome** — score ≥ `atLeast` passes, below it **fails the case**; the judge model's own opinion never overrides your declared bar, so the record can't disagree with the verdict. The judge is instructed to keep its reason to 1–3 sentences, so it fits on the verdict line. Requires `tests.judge` in `workflow.yaml`. |
628
+
629
+ **Regex form**: a string shaped `/pattern/flags` is compiled as a regular
630
+ expression; anything else matches as a plain substring (in `reply` tokens) or
631
+ by equality (in tool-input matchers). Malformed regex strings are **static
632
+ validation errors**, never silently matched as literals. Quote them in YAML —
633
+ `"/approv/i"`, `"/output\\/report\\.json/"`.
634
+
635
+ **There is no generic predicate.** No `satisfies`, no inline JS — that is the
636
+ point of the format. Bespoke logic belongs in a vitest unit test against the
637
+ engine or in a lifecycle hook.
638
+
639
+ ### Structural assertions halt the case
640
+
641
+ A failed **structural** assertion (`succeeded`, `parked`, `reachedState`,
642
+ `trail`, `noTraversal`, `triggerArrival`) stops the case where it stands: no
643
+ further `send`/`decide` is driven and no further assertion is evaluated. Once
644
+ the run took a path the case did not describe, every later step would drive or
645
+ grade the wrong run — a `decide` after a failed `parked` cannot succeed, and a
646
+ `reply` check after a failed `reachedState` grades a state the run never
647
+ entered.
648
+
649
+ Content assertions (`reply`, `calledTool`, `notCalledTool`, `blockedTool`, `usedNoTools`,
650
+ `grade`) do **not** halt: a wrong reply is a defect in the run, not evidence the
651
+ run stopped being the one under test.
652
+
653
+ **What this means for how you author a case.** Put the structural assertion you
654
+ depend on immediately after the action that should have satisfied it, before the
655
+ content assertions that only make sense in that state:
656
+
657
+ ```yaml
658
+ steps:
659
+ - send: "I want a refund for ORD-1001"
660
+ - reachedState: refund-review # ← structural: if this misses, stop here
661
+ - parked: true
662
+ - reply: # ← only meaningful in refund-review
663
+ includes: "approved"
664
+ - decide: { to: refund-closed }
665
+ - succeeded: true
666
+ ```
667
+
668
+ A halt is reported as an ordinary failed verdict naming the assertion that
669
+ missed — **no error is set**, because a halt is a verdict the case reached, not
670
+ a failure of the machinery. Every assertion step that never ran is reported with
671
+ `status: "not-executed"` (one record per step, carrying its `steps` index), so a
672
+ stopped case still accounts for everything it declared: `archmax test` prints an
673
+ `N assertions not executed` line, and a host reading records can grey them out
674
+ instead of showing nothing and implying they passed.
675
+
676
+ Each record's `status` — `passed` \| `failed` \| `not-executed` — is the
677
+ authoritative outcome; the boolean `pass` is the alias `status === "passed"`.
678
+
679
+ ### Start conditions: trigger, variables, and workspace
680
+
681
+ A case declares its **start conditions** — which trigger its driven runs start
682
+ from, what input those runs carry, and which files exist in the run workspace
683
+ before anything happens — at **case scope, never per step**:
684
+
685
+ ```yaml
686
+ title: Delayed-orders report compiled from a trigger
687
+ description: >-
688
+ The report_requested trigger starts every driven turn, carrying the company
689
+ the firing named as a locked run variable.
690
+ trigger:
691
+ id: report_requested
692
+ variables:
693
+ company: "Acme Corp" # locked, exactly as a host's variables would be
694
+ workspace:
695
+ trigger.json: { company: "Acme Corp" } # non-string ⇒ JSON-serialized
696
+ steps:
697
+ - send: "A delayed-orders report was requested."
698
+ - succeeded: true
699
+ - triggerArrival: report_requested
700
+ ```
701
+
702
+ - **`trigger`** — every driven turn of the case runs under this trigger
703
+ (each turn restarts the run at that trigger's start state; the trail
704
+ records `{ kind: trigger, reason: <id> }` per turn). Omit it for the
705
+ `manual` trigger. It is validated **before any step executes**: an unknown
706
+ id fails the case with the workflow's declared triggers, even when the case
707
+ has no steps — and `archmax validate` reports the same statically. There is
708
+ deliberately no per-step trigger: a follow-up `send` cannot run under a
709
+ different trigger than the case declares. **When the workflow declares
710
+ non-manual triggers, every case exercising one MUST declare it.**
711
+ - **`variables`** — the run's input, seeded on every driven turn as the
712
+ runtime's `variables` and therefore **locked**, values structured or
713
+ scalar. A seed here is indistinguishable from a production host seed to
714
+ everything downstream: a `${{…}}` reference in a `tools.allow` glob resolves
715
+ from it, a state's `requires:` gate is satisfied by it, a lifecycle hook reads
716
+ it in `variables`, and `archmax_set_variables` refuses to rewrite it.
717
+ **Recover the real payload rather than inventing one** — see
718
+ [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs).
719
+ - **`workspace`** — input files seeded before any step. Keys are run-zone
720
+ paths (root-level files like `trigger.json`, or `scratchpad/…`); authored
721
+ paths (`skills/…`) and runtime areas are rejected before anything is
722
+ written. This mirrors the host convention of materializing a trigger's
723
+ payload as a workspace file — declare the payload exactly as production
724
+ would write it.
725
+
726
+ Two operational notes:
727
+
728
+ - Seeding writes the file; **reading it is still governed** — the triggered
729
+ start state must allow the read tool the agent needs (`read_file` is an
730
+ always-on tools unless a state narrows it).
731
+ - Hosts that pin case sessions via `sessionIdForCase` reuse sessions across test
732
+ runs: declared files are overwritten each run, but a file seeded by an
733
+ _earlier_ run whose declaration was since removed can linger. The default
734
+ (per-run unique sessions) starts clean.
735
+
736
+ ### Recovering a case's inputs from previous runs
737
+
738
+ A trigger carries only an id, so everything a firing passes arrives as
739
+ variables — and those names, shapes, and values live in the host's call site and
740
+ in sessions that already ran, not in the workflow. Read a real run rather than
741
+ guessing:
742
+
743
+ ```bash
744
+ archmax sessions # ids, status, state, parked state, `vars=` names
745
+ archmax sessions <sessionId> # that session's variables: value + locked state
746
+ ```
747
+
748
+ That reads the session's **checkpoint**, so it works for any session that has run,
749
+ including one driven by `archmax run` (the CLI emits no run artifacts). A host that
750
+ calls `emitRunArtifacts` leaves the same record on disk as
751
+ `sessions/<sessionId>/artifacts/variables.json`
752
+ (`{ runId, workflow, variables: { name: { value, locked } } }`).
753
+
754
+ Pick the newest session whose recorded `trigger` matches the trigger the case
755
+ drives, then map its store onto the case:
756
+
757
+ | Recorded entry | Where it belongs |
758
+ | --- | --- |
759
+ | `trigger` (always locked) | the case's `trigger: { id: … }`, never `variables:` |
760
+ | any other `locked: true` | the case's `variables:` block — the firing's payload |
761
+ | `locked: false` | nothing: the agent established it, so **assert** it with `variables: { expect: … }` |
762
+
763
+ Never copy the whole store into `variables:`. Case seeds are locked, so seeding a
764
+ name the agent should establish hides a missing `archmax_set_variables` call *and*
765
+ makes the agent's own write fail against the lock — the case then exercises a run
766
+ production never performs.
767
+
768
+ `locked` is the whole attribution, and it is sufficient: a locked entry came from
769
+ the host (an `variables` seed, a delivery's seeds, or the built-in
770
+ `trigger`), an unlocked one from the agent's own `set_variables`. What you read is
771
+ the store as the session's **latest** turn left it, so locked entries are also the
772
+ turn-invariant part.
773
+
774
+ To produce a payload to read back, drive one yourself:
775
+ `archmax run <workflow> "<prompt>" --variables '{"from_email":"a@b.c"}'` then
776
+ `archmax sessions <id>`.
777
+
778
+ With no previous run to read, derive the same facts statically: the `${{…}}`
779
+ references in `tools.allow`/`allow_always` and the names in every state's
780
+ `requires:` are the variables the workflow depends on (`archmax validate` warns
781
+ about references nothing guarantees), and the host's
782
+ `createAgent({ variables })` / `deliver(sessionId, { trigger,
783
+ variables })` call sites name what production supplies.
784
+
785
+ ### File fixtures: `from:` references
786
+
787
+ Bulk fixtures (a captured `trigger.json`, a large orders dump) live as real
788
+ files under `tests/` rather than inline YAML, and a case names each one it
789
+ wants: any `workspace` entry may declare `{ from: <path> }` instead of inline
790
+ content. The path resolves relative to the workflow's `tests/` directory, so
791
+ `from: fixtures/trigger.json` and `from: shared/orders-dump.json` (a fixture
792
+ shared across cases) both work. There is **no auto-seeded sibling data
793
+ directory**: a file is seeded only when a case's `workspace:` says so.
794
+
795
+ Fail-closed rules — reported by `archmax validate` and enforced again at run
796
+ bootstrap: a `from:` path that escapes `tests/` or names a missing file is an
797
+ error before any step runs, and a `*.test.yaml` file anywhere under `tests/`
798
+ is a case.
799
+
800
+ ### Settle the mocking depth _before_ writing a case
801
+
802
+ An unmocked tool call in a test is a **real** tool call, on every run of the
803
+ suite: a real email sent, a real card charged, a real row written to a live
804
+ system. That is the one decision about a test its author cannot make alone, so
805
+ **ask the owner how much of the workflow's tool surface to mock before you
806
+ write the file**.
807
+
808
+ Ask concretely rather than in the abstract — name the tools this workflow
809
+ actually calls and propose a split:
810
+
811
+ - **Mock by default** anything that sends, pays, posts, deletes, or writes
812
+ outside the session zone, and anything whose result drifts between runs (live
813
+ inventory, today's date-dependent data). A test that is not safe to re-run is
814
+ not a test.
815
+ - **Leave real by default** reads against fixtures committed to the workspace,
816
+ the workflow's own scripts and hooks, and run-zone file writes — mocking those
817
+ would test the mock instead of the workflow.
818
+ - **Ask** about reads against a live system: cheap and stable enough to keep
819
+ real, or slow, rate-limited, or credential-bound enough to mock?
820
+
821
+ Record the answer in the **YAML comment above the case document** — the
822
+ `description` is capped at a sentence or two and belongs to what is asserted,
823
+ while a reader checking the case against the workflow needs to know what was
824
+ deliberately left real, which is exactly the kind of rationale the comment
825
+ exists for.
826
+
827
+ Mocks are the top-level `mocks:` list; they intercept **agent-initiated and
828
+ PTC** (script `tools.*`) calls with the same partial-input matching as
829
+ `calledTool`. Interception is guaranteed under `archmax test`. A host running
830
+ the same cases through `runTests` with its own `createTarget` must wire
831
+ `createToolMockMiddleware()` into that agent — a case that declares `mocks:`
832
+ against a target that cannot intercept them is refused with an error **before
833
+ the agent runs**, never run half-mocked. Mockless cases run against any
834
+ workflow-governed target. See
835
+ [backend-integration.md](backend-integration.md#cases-against-your-agent).
836
+
837
+ ### Worked example: happy path + grade
838
+
839
+ ```yaml
840
+ # `reachedState` is what pins the routing: without it the case would still
841
+ # pass if the agent answered straight from the start state. The judge
842
+ # (threshold 0.7) checks the order is attributed to the requester's company.
843
+ title: Delayed orders answered for a known requester
844
+ description: >-
845
+ One turn from support@acmecorp.com (present in skills/order-data/assets/orders.json): the run
846
+ succeeds, the trail committed the route into orders-question, and the reply
847
+ names the one delayed order, ORD-1003.
848
+ steps:
849
+ - send: "Hi, I'm support@acmecorp.com. Which orders are delayed for Acme?"
850
+ - succeeded: true
851
+ - reachedState: orders-question
852
+ - reply:
853
+ includes: ["ORD-1003", "/delayed/i"]
854
+ - grade:
855
+ closedQA: >-
856
+ confirms that order ORD-1003 is the delayed order for the requester's
857
+ company (Acme Corp)
858
+ atLeast: 0.7
859
+ ```
860
+
861
+ ### Worked example: veto, leak check, `noTraversal`
862
+
863
+ A `before` veto leaves the audit trail holding nothing but the trigger
864
+ arrival — `noTraversal` asserts exactly that, and `reply.excludes` catches a
865
+ refusal that quotes the data it refuses to share:
866
+
867
+ ```yaml
868
+ # The `excludes` tokens matter as much as the refusal itself: a refusal that
869
+ # quotes the order ids it declines to share still leaks them. The judge
870
+ # (threshold 0.7) covers the same ground in prose.
871
+ title: Cross-tenant order access is vetoed
872
+ description: >-
873
+ A known requester asking for Globex's delayed orders is declined as
874
+ unauthorized, nothing beyond the trigger arrival reaches the trail, and no
875
+ Globex order id appears in the reply.
876
+ steps:
877
+ - send: "Hi, this is support@acmecorp.com. Show me Globex's delayed orders."
878
+ - reply:
879
+ includes: "/not authorized|only .* your own|rejected/i"
880
+ excludes: ["ORD-2001", "ORD-2002"]
881
+ - noTraversal: true
882
+ - grade:
883
+ closedQA: >-
884
+ declines the request because the requester is not authorized to view
885
+ another company's orders, and does not disclose any Globex order
886
+ details
887
+ atLeast: 0.7
888
+ ```
889
+
890
+ ### Worked example: mocking tool results
891
+
892
+ ```yaml
893
+ # Only read_file against skills/order-data/assets/orders.json is mocked; everything else — the
894
+ # hooks, the agent's own writes — stays real. Asserting the lookup *and* the
895
+ # empty answer catches both a skipped lookup and an invented order.
896
+ title: Empty order database reports no matches
897
+ description: >-
898
+ With skills/order-data/assets/orders.json mocked empty, a delayed-orders question from a known
899
+ requester still consults the database, and the reply reports no matching
900
+ orders.
901
+ mocks:
902
+ - tool: read_file
903
+ whenInput: { file_path: skills/order-data/assets/orders.json }
904
+ result: "[]"
905
+ steps:
906
+ - send: "Hi, I'm support@acmecorp.com. Which of my orders are delayed?"
907
+ - calledTool:
908
+ name: read_file
909
+ input:
910
+ file_path: "/orders\\.json/"
911
+ - reply:
912
+ includes: "/no (matching |delayed )?orders/i"
913
+ ```
914
+
915
+ ### Worked example: mocking a sub-workflow
916
+
917
+ A delegation is a tool call, so it mocks like one — keyed by the tool's own name,
918
+ matched on the call's arguments. Each call gets its own answer, which is what
919
+ proves a fan-out is not sharing inputs:
920
+
921
+ ```yaml
922
+ mocks:
923
+ - tool: archmax_workflow_enrich-order
924
+ whenInput: { order_id: "ORD-1002" }
925
+ result:
926
+ message: "ORD-1002 is shipped."
927
+ # The target's `manual` trigger declares these, so a mock standing in
928
+ # for that sub-run has to supply them: a mock replaces the run, never its
929
+ # contract, and one omitting a declared name fails the dispatch.
930
+ returns:
931
+ enrichment_file: "scratchpad/enrichment/ORD-1002.json"
932
+ delayed: false
933
+ steps:
934
+ - send: "Enrich the requested orders."
935
+ - ranWorkflow: { workflow: enrich-order, count: 2 }
936
+ - variables:
937
+ expect:
938
+ enrichments:
939
+ - order_id: "ORD-1002"
940
+ delayed: false
941
+ ```
942
+
943
+ Assert with **`ranWorkflow`**, not `calledTool`, whenever a script drives the
944
+ fan-out: `calledTool` reads the *model's* calls, and the point of a script-driven
945
+ fan-out is that the model makes none of them. `ranWorkflow` reads the trail, which
946
+ records a step per sub-run whoever made the call — including a mocked one.
947
+
948
+ ### Worked example: human-in-the-loop decisions (refine loop)
949
+
950
+ Drive a run into a human state, assert it parked, then decide as the reviewer —
951
+ including the send-back-for-changes (`refine`) loop, whose second parking is
952
+ pinned with a `trail` count:
953
+
954
+ ```yaml
955
+ # The `trail` count is the point: refund-review is entered exactly twice —
956
+ # initial submit plus one resubmit — so a third entry would mean the refine
957
+ # edge looped. The final `reply` check pins that the correction did not lose
958
+ # its subject.
959
+ title: Refund sent back for changes, then approved
960
+ description: >-
961
+ The reviewer refines once and then approves: the run parks at both review
962
+ points, refund-review is entered exactly twice, the run succeeds, and the
963
+ final reply still names ORD-1001.
964
+ steps:
965
+ - send: "Hi, I'm support@acmecorp.com. I'd like a refund for order ORD-1001."
966
+ - parked: true
967
+ - decide:
968
+ to: refund-request
969
+ comment: >-
970
+ Please restate the refund amount and confirm the order was delivered
971
+ before it can be approved.
972
+ - parked: true
973
+ - trail:
974
+ to: refund-review
975
+ count: 2
976
+ - decide:
977
+ to: refund-closed
978
+ comment: "The explanation is clear now."
979
+ - succeeded: true
980
+ - reply:
981
+ includes: "ORD-1001"
982
+ ```
983
+
984
+ ### Worked example: triggered start + fixture file
985
+
986
+ The case declares its trigger on the document, its input as a locked variable,
987
+ and a payload fixture the host would materialize as a real file under `tests/`,
988
+ seeded byte-for-byte before any step:
989
+
990
+ ```yaml
991
+ # tests/triggered-report.test.yaml — tests/fixtures/trigger.json holds the
992
+ # payload fixture. `triggerArrival` is what pins the start: it names the
993
+ # trigger the run really arrived on, not merely the state it ended up in.
994
+ title: Delayed-orders report compiled from a trigger
995
+ description: >-
996
+ The report_requested trigger starts the run: the agent reads the seeded
997
+ trigger.json payload, writes scratchpad/report.json, and the reply names Acme's
998
+ one delayed order, ORD-1003.
999
+ trigger:
1000
+ id: report_requested
1001
+ variables:
1002
+ company: "Acme Corp"
1003
+ workspace:
1004
+ trigger.json: { from: fixtures/trigger.json }
1005
+ steps:
1006
+ - send: "A delayed-orders report was requested."
1007
+ - succeeded: true
1008
+ - triggerArrival: report_requested
1009
+ # `/pattern/` input values match as regexes, so the assertion holds
1010
+ # whether the model spells the path with or without a leading slash.
1011
+ - calledTool:
1012
+ name: read_file
1013
+ input:
1014
+ file_path: "/trigger\\.json/"
1015
+ - calledTool:
1016
+ name: write_file
1017
+ input:
1018
+ file_path: "/output\\/report\\.json/"
1019
+ - reply:
1020
+ includes: ["ORD-1003", "Acme"]
1021
+ ```
1022
+
1023
+ ### Migrating a JS case to YAML
1024
+
1025
+ JS test cases (`defineEval`, the sandbox `t` driver, `@archmax-ai/harness/testing`) are
1026
+ **removed**; a `*.test.js`/`*.test.ts` file is no longer recognized and is
1027
+ skipped silently. The mapping:
1028
+
1029
+ | JS surface (removed) | YAML equivalent |
1030
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
1031
+ | File JSDoc header + `defineEval({ description })` | The mandatory `title` (≤ 60 chars) + `description` (≤ 200 chars); rationale that fits neither moves to a YAML comment above the document |
1032
+ | `defineEval({ trigger })` / `({ workspace })` | Top-level `trigger:` / `workspace:` (inline content or `{ from: … }`) |
1033
+ | `await t.send("msg")` | `- send: "msg"` step entry |
1034
+ | `await t.decide(to, comment)` | `- decide: { to, comment }` step entry |
1035
+ | `t.newSession(...)` / `Promise.all` over sessions | **Not part of test spec version 1** — a case is one conversation on one session; per-session isolation is an engine invariant covered by the SDK's own unit tests |
1036
+ | `t.mockTool(name, { whenInput, result })` | `mocks: [{ tool, whenInput, result }]` |
1037
+ | `t.succeeded()` / `t.parked()` | `- succeeded: true` / `- parked: true` step entries after the action |
1038
+ | `t.messageIncludes("x")` / `t.messageIncludes(/x/i)` | `- reply: { includes: ["x", "/x/i"] }` step entry (single token or list) |
1039
+ | Leak checks via `satisfies(...)` on the reply | `- reply: { excludes: [...] }` step entry |
1040
+ | `t.calledTool(name, { input })` / `t.notCalledTool` / `t.usedNoTools()` | `- calledTool: { name, input? }` / `- notCalledTool:` / `- usedNoTools: true` step entries (RegExp input values become `"/pattern/flags"` strings) |
1041
+ | `t.reachedState(name)` | `- reachedState: <name>` step entry |
1042
+ | `t.auditTrail()` + `filter(...).length === n` | `- trail: { to?, kind?, reason?, count }`; "nothing but the trigger arrival" ⇒ `- noTraversal: true`; trigger-arrival reason ⇒ `- triggerArrival: <id>` |
1043
+ | `t.judge.autoevals.closedQA(c).atLeast(n)` | `- grade: { closedQA: c, atLeast: n }` step entry |
1044
+ | `t.skip(reason)` | `skip: <reason>` |
1045
+ | bespoke predicates | **No YAML equivalent — deliberate.** Move bespoke logic into a vitest unit test against the engine, or a lifecycle hook (`veto(reason)`). |
1046
+ | `import { defineEval } from "@archmax-ai/harness/testing"` | Nothing — the subpath is removed; YAML imports nothing |
1047
+
1048
+ Removed package exports: `createDriveTool`, `DriveRequest`, `DriveSession`,
1049
+ and the `@archmax-ai/harness/testing` subpath. New root exports for hosts that parse
1050
+ cases themselves: `parseCaseDocument`, `CaseSchemaError`, the case types
1051
+ (`CaseDocument`, `CaseStep`, `CaseExpectation`, …), and the test-spec
1052
+ versioning surface (`DEFAULT_TEST_SPEC_VERSION`, `SUPPORTED_TEST_SPEC_VERSIONS`,
1053
+ `resolveTestSpecVersion`, `isTestSpecVersionSupported`,
1054
+ `UnsupportedTestSpecVersionError`). `@archmax-ai/harness/sandbox` carries the hook
1055
+ contract (`ok`/`veto`/`correct`, `defineHook`, `HookInput`).
1056
+
1057
+ ---
1058
+
1059
+ ### Source of truth in code
1060
+
1061
+ | Concern | File in the SDK |
1062
+ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
1063
+ | Sandbox prelude (verdict helpers, `defineHook`, the deprecated `t` shim) | `src/sandbox/assets/parts/core.js`, `lifecycle-hook.js`, `legacy-t.js`; `src/sandbox/prelude.ts` |
1064
+ | Hook source preparation (`@archmax-ai/harness/*` import stripping, `export default`, foreign-import errors) | `src/sandbox/imports.ts` |
1065
+ | Executor, sandbox tools, PTC gateway, rubric-dispatch bridge | `src/sandbox/executor.ts`, `tools.ts`, `ptc-gateway.ts`, `bridge.ts` |
1066
+ | Typed entry point (`ok`/`veto`/`correct`, `defineHook`, `HookInput`) | `src/public/sandbox.ts` |
1067
+ | Lifecycle verdict parsing & fail-closed rules | `src/lifecycle/runner.ts` |
1068
+ | Hook input assembly (`messages`) | `src/lifecycle/runner.ts`, `src/lifecycle/transcript.ts` |
1069
+ | Case engine (host-side) | `src/testing/` — case schema `case-schema.ts`, discovery `discovery.ts`, assertion evaluation `assertions.ts`, grader `grade.ts`, runner `runner.ts` |
1070
+ | Validation diagnostics (missing script file, foreign import, YAML case checks) | `src/validate/validate.ts` |