@archmax-ai/harness 0.8.0 → 1.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 (219) hide show
  1. package/README.md +58 -14
  2. package/dist/agent.d.ts +4 -4
  3. package/dist/agent.js +4 -4
  4. package/dist/assembly/compose.d.ts +5 -24
  5. package/dist/assembly/compose.js +40 -41
  6. package/dist/assembly/compose.js.map +1 -1
  7. package/dist/assembly/delegation-registry.js +3 -14
  8. package/dist/assembly/delegation-registry.js.map +1 -1
  9. package/dist/assembly/index.d.ts +18 -15
  10. package/dist/assembly/index.js +50 -32
  11. package/dist/assembly/index.js.map +1 -1
  12. package/dist/assembly/plain.js +1 -2
  13. package/dist/assembly/plain.js.map +1 -1
  14. package/dist/authoring-skill/archmax-harness/README.md +5 -5
  15. package/dist/authoring-skill/archmax-harness/SKILL.md +72 -30
  16. package/dist/authoring-skill/archmax-harness/references/backend-integration.md +35 -23
  17. package/dist/authoring-skill/archmax-harness/references/cases.md +44 -10
  18. package/dist/authoring-skill/archmax-harness/references/{hook-and-test-scripts.md → code-state-and-test-scripts.md} +617 -313
  19. package/dist/authoring-skill/archmax-harness/references/code-states-and-scripts.md +115 -0
  20. package/dist/authoring-skill/archmax-harness/references/diagnostics.md +40 -7
  21. package/dist/authoring-skill/archmax-harness/references/wiring.md +19 -10
  22. package/dist/authoring-skill/archmax-harness/references/workflow-schema.md +416 -276
  23. package/dist/authoring-skill/archmax-harness/references/workflow-yaml.md +161 -62
  24. package/dist/cli/state-flow.d.ts +1 -1
  25. package/dist/cli/state-flow.js +18 -28
  26. package/dist/cli/state-flow.js.map +1 -1
  27. package/dist/cli/turn.d.ts +1 -1
  28. package/dist/cli/turn.js +2 -3
  29. package/dist/cli/turn.js.map +1 -1
  30. package/dist/core/clock.d.ts +26 -0
  31. package/dist/core/clock.js +64 -0
  32. package/dist/core/clock.js.map +1 -0
  33. package/dist/core/code-description.d.ts +3 -3
  34. package/dist/core/code-description.js +3 -3
  35. package/dist/core/deepagents.d.ts +5 -5
  36. package/dist/core/deepagents.js +4 -4
  37. package/dist/core/events.d.ts +23 -42
  38. package/dist/core/events.js +21 -24
  39. package/dist/core/events.js.map +1 -1
  40. package/dist/core/message-readers.d.ts +22 -2
  41. package/dist/core/message-readers.js +31 -0
  42. package/dist/core/message-readers.js.map +1 -1
  43. package/dist/core/messages.d.ts +1 -23
  44. package/dist/core/messages.js +2 -35
  45. package/dist/core/messages.js.map +1 -1
  46. package/dist/core/mounts.js +3 -3
  47. package/dist/core/platform-prompt.generated.js +1 -1
  48. package/dist/core/platform-prompt.generated.js.map +1 -1
  49. package/dist/core/session-zone.d.ts +1 -1
  50. package/dist/core/session-zone.js +1 -1
  51. package/dist/core/workspace-context.d.ts +1 -1
  52. package/dist/core/workspace.d.ts +1 -1
  53. package/dist/core/workspace.js +1 -1
  54. package/dist/core/zones.d.ts +4 -4
  55. package/dist/core/zones.js +5 -5
  56. package/dist/core/zones.js.map +1 -1
  57. package/dist/env.d.ts +9 -9
  58. package/dist/index.d.ts +3 -7
  59. package/dist/index.js +2 -4
  60. package/dist/index.js.map +1 -1
  61. package/dist/kernel/kernel.d.ts +31 -61
  62. package/dist/kernel/kernel.js +57 -114
  63. package/dist/kernel/kernel.js.map +1 -1
  64. package/dist/machine/allow.d.ts +17 -2
  65. package/dist/machine/allow.js +16 -6
  66. package/dist/machine/allow.js.map +1 -1
  67. package/dist/machine/lint-spec.js +68 -12
  68. package/dist/machine/lint-spec.js.map +1 -1
  69. package/dist/machine/machine.d.ts +39 -16
  70. package/dist/machine/machine.js +72 -49
  71. package/dist/machine/machine.js.map +1 -1
  72. package/dist/machine/spec-schema.d.ts +34 -179
  73. package/dist/machine/spec-schema.js +93 -106
  74. package/dist/machine/spec-schema.js.map +1 -1
  75. package/dist/machine/tool-names.d.ts +34 -10
  76. package/dist/machine/tool-names.js +41 -10
  77. package/dist/machine/tool-names.js.map +1 -1
  78. package/dist/machine/tool-paths.d.ts +6 -0
  79. package/dist/machine/tool-paths.js +10 -0
  80. package/dist/machine/tool-paths.js.map +1 -1
  81. package/dist/machine/types.d.ts +14 -38
  82. package/dist/machine/types.js.map +1 -1
  83. package/dist/machine/validate-spec.d.ts +1 -1
  84. package/dist/machine/variables.d.ts +2 -0
  85. package/dist/machine/variables.js.map +1 -1
  86. package/dist/public/sandbox.d.ts +62 -66
  87. package/dist/public/sandbox.js +22 -23
  88. package/dist/public/sandbox.js.map +1 -1
  89. package/dist/public/spec.d.ts +12 -19
  90. package/dist/public/spec.js +11 -19
  91. package/dist/public/spec.js.map +1 -1
  92. package/dist/runtime/contract.d.ts +11 -12
  93. package/dist/runtime/contract.js +16 -16
  94. package/dist/runtime/contract.js.map +1 -1
  95. package/dist/sandbox/assets/parts/code-state.js +17 -0
  96. package/dist/sandbox/executor.d.ts +25 -7
  97. package/dist/sandbox/executor.js +56 -47
  98. package/dist/sandbox/executor.js.map +1 -1
  99. package/dist/sandbox/imports.d.ts +10 -10
  100. package/dist/sandbox/imports.js +11 -11
  101. package/dist/sandbox/imports.js.map +1 -1
  102. package/dist/sandbox/prelude.d.ts +5 -6
  103. package/dist/sandbox/prelude.js +38 -35
  104. package/dist/sandbox/prelude.js.map +1 -1
  105. package/dist/sandbox/ptc-gateway.d.ts +19 -18
  106. package/dist/sandbox/ptc-gateway.js +26 -22
  107. package/dist/sandbox/ptc-gateway.js.map +1 -1
  108. package/dist/sessions/operations.js +4 -5
  109. package/dist/sessions/operations.js.map +1 -1
  110. package/dist/sessions/resume.d.ts +12 -7
  111. package/dist/sessions/resume.js +12 -9
  112. package/dist/sessions/resume.js.map +1 -1
  113. package/dist/sessions/summary.d.ts +1 -1
  114. package/dist/testing/assertions.js +9 -4
  115. package/dist/testing/assertions.js.map +1 -1
  116. package/dist/testing/case-document.d.ts +2 -0
  117. package/dist/testing/case-document.js.map +1 -1
  118. package/dist/testing/case-schema.d.ts +1 -1
  119. package/dist/testing/case-schema.js +17 -1
  120. package/dist/testing/case-schema.js.map +1 -1
  121. package/dist/testing/driver.d.ts +4 -0
  122. package/dist/testing/driver.js +22 -3
  123. package/dist/testing/driver.js.map +1 -1
  124. package/dist/testing/grade.js +3 -3
  125. package/dist/testing/grade.js.map +1 -1
  126. package/dist/testing/interpreter.d.ts +2 -2
  127. package/dist/testing/interpreter.js +1 -0
  128. package/dist/testing/interpreter.js.map +1 -1
  129. package/dist/testing/runner.d.ts +2 -2
  130. package/dist/validate/validate.d.ts +1 -1
  131. package/dist/validate/validate.js +39 -70
  132. package/dist/validate/validate.js.map +1 -1
  133. package/dist/workflow/automated-controls.d.ts +63 -0
  134. package/dist/workflow/automated-controls.js +158 -0
  135. package/dist/workflow/automated-controls.js.map +1 -0
  136. package/dist/workflow/automated.d.ts +112 -0
  137. package/dist/workflow/automated.js +163 -0
  138. package/dist/workflow/automated.js.map +1 -0
  139. package/dist/workflow/checkpointer.d.ts +7 -0
  140. package/dist/workflow/checkpointer.js +28 -0
  141. package/dist/workflow/checkpointer.js.map +1 -1
  142. package/dist/workflow/code-state.d.ts +46 -0
  143. package/dist/workflow/code-state.js +26 -0
  144. package/dist/workflow/code-state.js.map +1 -0
  145. package/dist/workflow/control-tools.d.ts +54 -23
  146. package/dist/workflow/control-tools.js +121 -77
  147. package/dist/workflow/control-tools.js.map +1 -1
  148. package/dist/workflow/file-values.d.ts +4 -4
  149. package/dist/workflow/file-values.js +1 -1
  150. package/dist/workflow/file-values.js.map +1 -1
  151. package/dist/workflow/governance.d.ts +13 -22
  152. package/dist/workflow/governance.js +51 -87
  153. package/dist/workflow/governance.js.map +1 -1
  154. package/dist/workflow/grader-state.d.ts +55 -0
  155. package/dist/workflow/grader-state.js +182 -0
  156. package/dist/workflow/grader-state.js.map +1 -0
  157. package/dist/workflow/history.d.ts +11 -0
  158. package/dist/workflow/history.js +64 -0
  159. package/dist/workflow/history.js.map +1 -0
  160. package/dist/workflow/middleware.d.ts +14 -17
  161. package/dist/workflow/middleware.js +106 -74
  162. package/dist/workflow/middleware.js.map +1 -1
  163. package/dist/workflow/move-events.d.ts +21 -0
  164. package/dist/workflow/move-events.js +20 -0
  165. package/dist/workflow/move-events.js.map +1 -0
  166. package/dist/workflow/parks.d.ts +9 -2
  167. package/dist/workflow/parks.js +16 -8
  168. package/dist/workflow/parks.js.map +1 -1
  169. package/dist/workflow/paths.d.ts +9 -9
  170. package/dist/workflow/paths.js +19 -19
  171. package/dist/workflow/paths.js.map +1 -1
  172. package/dist/workflow/render-prompt.d.ts +7 -5
  173. package/dist/workflow/render-prompt.js +22 -37
  174. package/dist/workflow/render-prompt.js.map +1 -1
  175. package/dist/workflow/signature-checks.d.ts +2 -2
  176. package/dist/workflow/snapshot.js +5 -29
  177. package/dist/workflow/snapshot.js.map +1 -1
  178. package/dist/workflow/state.d.ts +19 -6
  179. package/dist/workflow/state.js +24 -8
  180. package/dist/workflow/state.js.map +1 -1
  181. package/dist/workflow/sub-workflow.d.ts +3 -3
  182. package/dist/workflow/tool-service.d.ts +1 -9
  183. package/dist/workflow/tool-service.js +21 -24
  184. package/dist/workflow/tool-service.js.map +1 -1
  185. package/dist/workflow/turn-boundary.d.ts +2 -2
  186. package/dist/workflow/turn-boundary.js +10 -7
  187. package/dist/workflow/turn-boundary.js.map +1 -1
  188. package/dist/workflow/turn-runner.js +30 -2
  189. package/dist/workflow/turn-runner.js.map +1 -1
  190. package/package.json +1 -1
  191. package/skills/archmax-harness/README.md +5 -5
  192. package/skills/archmax-harness/SKILL.md +72 -30
  193. package/skills/archmax-harness/references/backend-integration.md +35 -23
  194. package/skills/archmax-harness/references/cases.md +44 -10
  195. package/skills/archmax-harness/references/{hook-and-test-scripts.md → code-state-and-test-scripts.md} +617 -313
  196. package/skills/archmax-harness/references/code-states-and-scripts.md +115 -0
  197. package/skills/archmax-harness/references/diagnostics.md +40 -7
  198. package/skills/archmax-harness/references/wiring.md +19 -10
  199. package/skills/archmax-harness/references/workflow-schema.md +416 -276
  200. package/skills/archmax-harness/references/workflow-yaml.md +161 -62
  201. package/dist/authoring-skill/archmax-harness/references/hooks-and-scripts.md +0 -88
  202. package/dist/lifecycle/hook-shape.d.ts +0 -44
  203. package/dist/lifecycle/hook-shape.js +0 -66
  204. package/dist/lifecycle/hook-shape.js.map +0 -1
  205. package/dist/lifecycle/runner.d.ts +0 -183
  206. package/dist/lifecycle/runner.js +0 -373
  207. package/dist/lifecycle/runner.js.map +0 -1
  208. package/dist/lifecycle/transcript.d.ts +0 -27
  209. package/dist/lifecycle/transcript.js +0 -43
  210. package/dist/lifecycle/transcript.js.map +0 -1
  211. package/dist/rubrics/rubrics.d.ts +0 -135
  212. package/dist/rubrics/rubrics.js +0 -145
  213. package/dist/rubrics/rubrics.js.map +0 -1
  214. package/dist/sandbox/assets/parts/core.js +0 -22
  215. package/dist/sandbox/assets/parts/lifecycle-hook.js +0 -47
  216. package/dist/sandbox/bridge.d.ts +0 -16
  217. package/dist/sandbox/bridge.js +0 -95
  218. package/dist/sandbox/bridge.js.map +0 -1
  219. package/skills/archmax-harness/references/hooks-and-scripts.md +0 -88
@@ -1,28 +1,29 @@
1
- # Hook scripts & cases — full reference
1
+ # Code-state scripts & cases — full reference
2
2
 
3
3
  The complete contract and worked examples for the two authored surfaces this
4
4
  reference covers:
5
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
6
+ 1. **Code-state scripts** (`workflows/<slug>/scripts/*.js` — beside the spec
7
+ that wires them, on the **authoring plane**) — the scripts a `type: code`
8
+ state runs, **hand-authored** `.js` files. `workflow.yaml` wires
9
+ `script: scripts/<name>.js` on the code state: the path is
10
+ **workflow-relative**, confined to that workflow's `scripts/` directory and
11
+ ends in `.js` or `.mjs`, so it resolves to `workflows/<slug>/scripts/<name>.js`.
12
+ An absolute path, a `..` segment, or a path outside `scripts/` is a load error
13
+ and a `validate` error. They run in the QuickJS sandbox: plain JavaScript with
14
+ **top-level `await`**, no Node APIs, no filesystem beyond the `tools.*` bridge.
15
+
16
+ **A code-state script is not an agent's script.** The runtime runs a code
17
+ state; the agent runs an `archmax_run` source. The two live in different places
18
+ on purpose — code-state scripts in `workflows/<slug>/scripts/`, runtime scripts
19
+ in `skills/<capability>/scripts/` — and the classes cannot meet: the agent
20
20
  cannot read `workflows/**` at all, and `archmax_run` is confined to skill
21
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.
22
+ could execute or read the check that judges its work could probe it for a
23
+ bypass, which is the whole reason for the split. A code-state script's source
24
+ never reaches the model: not in a prompt, not in an event, not in a session
25
+ artifact — the next state reads the reason the script passed to
26
+ `archmax_advance`, never its code.
26
27
  2. **Cases** (`workflows/<name>/tests/*.test.yaml`) — declarative
27
28
  YAML documents run by `archmax test` and interpreted **host-side**: no
28
29
  sandbox executes a case. Suite config is the `tests:` block in
@@ -32,23 +33,24 @@ Sandbox scripts are self-contained: the only permitted imports are **typed
32
33
  `@archmax-ai/harness/*` carriers** (stripped before evaluation — see
33
34
  [Typed imports](#typed-imports)); importing from any other specifier is an
34
35
  **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`).
36
+ them (`SANDBOX_CONTRACT.context` is `"code"` for a code-state script, or `"ptc"`
37
+ for the agent's own `archmax_eval`/`archmax_run` code, which gets **no**
38
+ `defineCodeState`; `SANDBOX_CONTRACT.version` is `3`).
38
39
 
39
40
  ## Contents
40
41
 
41
42
  - [Typed imports](#typed-imports)
42
- - [Part 1 — Lifecycle hook scripts](#part-1--lifecycle-hook-scripts)
43
+ - [Part 1 — Code-state scripts and agent scripts](#part-1--code-state-scripts-and-agent-scripts)
43
44
  - [Authoring model: hand-authored](#authoring-model-hand-authored)
44
- - [The hook contract](#the-hook-contract)
45
+ - [The code-state script contract](#the-code-state-script-contract)
45
46
  - [Governance of `tools.*` calls](#governance-of-tools-calls)
46
47
  - [`archmax_run` scripts: the `args` contract](#archmax_run-scripts-the-args-contract)
47
48
  - [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)
49
+ - [Worked example: a check in front of a state](#worked-example-a-check-in-front-of-a-state)
50
+ - [Worked example: a check behind a state](#worked-example-a-check-behind-a-state)
51
+ - [Worked example: approve, then end](#worked-example-approve-then-end)
52
+ - [When to use a grader state instead](#when-to-use-a-grader-state-instead)
53
+ - [Part 2 — Cases (declarative YAML)](#part-2--cases-declarative-yaml)
52
54
  - [Layout and the prose rules](#layout-and-the-prose-rules)
53
55
  - [Suite config: the `tests:` block](#suite-config-the-tests-block)
54
56
  - [Top-level keys](#top-level-keys)
@@ -60,7 +62,7 @@ vocabulary at all; `SANDBOX_CONTRACT.version` is `2`).
60
62
  - [File fixtures: `from:` references](#file-fixtures-from-references)
61
63
  - [Settle the mocking depth before writing a case](#settle-the-mocking-depth-before-writing-a-case)
62
64
  - [Worked example: happy path + grade](#worked-example-happy-path--grade)
63
- - [Worked example: veto, leak check, `noTraversal`](#worked-example-veto-leak-check-notraversal)
65
+ - [Worked example: declined at entry, leak check](#worked-example-declined-at-entry-leak-check)
64
66
  - [Worked example: mocking tool results](#worked-example-mocking-tool-results)
65
67
  - [Worked example: human-in-the-loop decisions (refine loop)](#worked-example-human-in-the-loop-decisions-refine-loop)
66
68
  - [Worked example: triggered start + fixture file](#worked-example-triggered-start--fixture-file)
@@ -70,182 +72,292 @@ vocabulary at all; `SANDBOX_CONTRACT.version` is `2`).
70
72
 
71
73
  ## Typed imports
72
74
 
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):
75
+ A code-state script may open with a **typed import** — a type carrier that gives
76
+ IDE completion for the state's input, and is **stripped before QuickJS
77
+ evaluation** (the runtime implementation is an identity):
76
78
 
77
79
  ```js
78
- import { ok, veto, correct } from "@archmax-ai/harness/sandbox";
79
80
  /** … */
80
- export default async function hook({ state, variables, messages, tools }) {
81
+ import { defineCodeState } from "@archmax-ai/harness/sandbox";
82
+
83
+ export default defineCodeState(async ({ state, trigger, variables, tools }) => {
81
84
  /* ... */
82
- }
85
+ });
83
86
  ```
84
87
 
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
+ The same subpath types the input and the history (`CodeStateInput`,
89
+ `CodeStateArgs`, `CodeStateFn`, `SandboxTools`, `SessionHistory`,
90
+ `HistoryMessage`, `TrailStep`). Importing from any
91
+ **non-`@archmax-ai/harness/*`** specifier is an **error** — sandbox scripts are
92
+ self-contained. **Bare-globals authoring** (no import) is equally valid:
93
+ `defineCodeState` is a prelude global, and a plain
94
+ `export default async function ({ tools }) { … }` registers the same way.
88
95
 
89
96
  ---
90
97
 
91
- ## Part 1 — Lifecycle hook scripts
98
+ ## Part 1 — Code-state scripts and agent scripts
92
99
 
93
100
  ### Authoring model: hand-authored
94
101
 
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):
102
+ A code state references an **ordinary hand-authored** `.js` file. You DESIGN AND
103
+ WRITE the script directly — there is no generation step. `workflow.yaml` only
104
+ wires it: the state's type, its script, and its transitions, which are where the
105
+ script may send the session:
98
106
 
99
107
  ```yaml
100
108
  states:
101
- identify-case:
109
+ screen-request:
110
+ type: code
111
+ script: scripts/screen-request.js
102
112
  triggers:
103
113
  manual:
104
- before:
105
- - script: hooks/check-requester.js
106
- - script: hooks/check-tenancy.js
114
+ skills:
115
+ allow: [order-data] # the script reads the order records: granted here
116
+ on_error: escalation # where a failure of the script routes
117
+ transitions:
118
+ - to: identify-case
119
+ description: The requester is known and asks only about their own company's orders.
120
+ - to: decline
121
+ description: The request names no known requester, or reaches for another company's orders.
107
122
  ```
108
123
 
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:
124
+ `script` is **required** on a code state and **refused** on every other type
125
+ (both load errors). A code state makes no model call, so `instructions`, `model`,
126
+ `budget`, `evidence` and `approvers` on one are `validate` errors: say what the
127
+ script does in its JSDoc block, and in the state's `summary`.
128
+
129
+ A code-state script **MUST** open with a leading JSDoc block — a title line, a
130
+ blank line, and a description of what the script does _and how_: the inputs it
131
+ reads, the sequence of checks, and **where it sends the session in each case**
132
+ (each `archmax_advance` target and why, each `archmax_raise` code). It is the only
133
+ human-facing contract the file has (admin UIs display it instead of the code), and
134
+ it is what a reviewer refines when the check is wrong. No `@specification` tag, no
135
+ `// archmax:generated` marker, no generation semantics. See
136
+ [SKILL.md → Workspace layout](../SKILL.md#workspace-layout-complete--nothing-else-exists).
137
+
138
+ - `archmax validate` reports a code-state script problem when the script **file
139
+ is missing** from `scripts/` or it has a **foreign (non-`@archmax-ai/harness/*`)
140
+ import**. It does **not** check the JSDoc block on any script — the header is
141
+ an authoring standard you uphold yourself. (Cases are YAML; there
142
+ `archmax validate` **errors** on a missing `description` instead.)
143
+ - The runtime never generates scripts: a missing script file at run time fails
144
+ the state (fail-closed), routed through its `on_error`.
145
+
146
+ ### The code-state script contract
147
+
148
+ When the session's position reaches a code state — the agent's `archmax_advance`
149
+ into it, another automated state's advance, a human decision, an `on_error`
150
+ route, a turn that opens there, an `archmax_reset` back to it — the runtime runs
151
+ its script **in place of the next model call**. The agent's model is not called
152
+ while the session stands in a code state, so a chain of code states makes no
153
+ model call at all. The script's **default export** is called with **one object**,
154
+ and the script **moves the session itself**:
133
155
 
134
156
  ```js
135
157
  /**
136
- * Requester gate — reject a run whose requester is not a known customer.
158
+ * Requester screen — reject a request whose requester is not a known customer.
137
159
  *
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`.
160
+ * Reads the first user message from the session's history, extracts the first
161
+ * email address in it, loads skills/order-data/assets/orders.json (this state
162
+ * enables `order-data`), and looks the address up against every order's `email`.
141
163
  *
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.
164
+ * Advances to decline when the request carries no email address, and when the
165
+ * address matches no order — each with a reason naming what was missing — and
166
+ * otherwise to identify-case.
144
167
  */
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" }));
168
+ import { defineCodeState } from "@archmax-ai/harness/sandbox";
169
+
170
+ export default defineCodeState(async ({ tools }) => {
171
+ const { messages } = JSON.parse(await tools.archmax_get_history());
172
+ const orders = JSON.parse(await tools.read_file({ file_path: "skills/order-data/assets/orders.json" }));
147
173
  const request = messages.find((m) => m.role === "user")?.text ?? "";
148
174
  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.`);
175
+ if (!email) return tools.archmax_advance({ to: "decline", reason: "No email address found in the request." });
176
+ if (!orders.some((o) => o.email === email)) {
177
+ return tools.archmax_advance({ to: "decline", reason: `"${email}" is not in the order database.` });
178
+ }
151
179
  console.log(`Allowed: ${email}`);
152
- return ok();
153
- }
180
+ await tools.archmax_advance({ to: "identify-case", reason: `${email} is a known requester.` });
181
+ });
154
182
  ```
155
183
 
156
- **The input** (`HookInput` in `@archmax-ai/harness/sandbox`; the same fields minus
157
- `tools` are also the global `args`):
184
+ **The input** (`CodeStateInput` in `@archmax-ai/harness/sandbox`; the same fields
185
+ minus `tools` are also the global `args`):
158
186
 
159
187
  | Field | Meaning |
160
188
  | --- | --- |
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, or the binary notice for a binary file (see below). See [Governance](#governance-of-tools-calls). |
189
+ | `state` | The code state's own slug. |
190
+ | `trigger?` | The id **string** of the trigger the turn carries. It carries no payload — the session's input is in `variables`. |
191
+ | `variables` | The session'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. Write with `tools.archmax_set_variables`, which holds locks, `title` and typed returns exactly as the agent's call does. |
192
+ | `tools` | **Exactly this state's tool surface** (async): its grants (the workflow-wide grant included), the host's essential tools, and its controls. A tool answers to its own name (`tools.read_file`, `tools["archmax_workflow_enrich-order"]`) and to its camelCased key (`tools.readFile`); `Object.keys(tools)` lists the camelCased keys. A call with no argument passes `{}` (`tools.archmax_get_history()`, `tools.ls()`). **A name outside the surface is `undefined`.** See [Governance](#governance-of-tools-calls). |
193
+
194
+ **The session's history** is not part of the input: the script reads it the way
195
+ a grader does, with `archmax_get_history` (`SessionHistory`):
196
+
197
+ ```js
198
+ const { messages, trail } = JSON.parse(await tools.archmax_get_history());
199
+ ```
200
+
201
+ It is the session as it stood when the state was entered, built from the
202
+ checkpointed session (never a summarized request), whole and oldest first,
203
+ across every turn:
204
+
205
+ | Field | Meaning |
206
+ | --- | --- |
207
+ | `messages` | The transcript as `{ role, text, toolCalls?, tool?, id?, note? }`. `role` is `user`, `assistant`, `tool`, `system`, or **`runtime`** — a note the runtime wrote (an arrival, a decision, an error route, an automated state's move; `note` names the kind). An `assistant` entry lists the calls it made in `toolCalls`, as `{ id?, name, args }`; the `tool` entry answering one carries the tool's name in `tool`, the call's `id`, and the **result in `text`**, as the tool returned it (`JSON.parse` it when the tool returns JSON; a failed or refused call's `text` is the error). A script that reacts to narration matches `role === "runtime"`, never a `[bracket]` prefix in a user message. Not windowed: the sandbox's `maxResultChars` and `memoryLimitBytes` bound it. |
208
+ | `trail` | The trail as `{ to, kind, reason?, workflow?, status?, ts }`. `kind` is `trigger`, `agent`, `human`, `grader`, `code`, `sub-workflow`, `reset` or `on_error`. Count earlier rounds here: `trail.filter((s) => s.kind === "code" && s.to === "refund-request").length`. |
209
+
210
+ Only what the transcript holds is there: a call a script made through `tools.*`
211
+ (an agent's `archmax_run`, or a code state's own) is not, and neither is a
212
+ grader's.
213
+
214
+ **What a code state checks.** The conversation is the model's memory, not
215
+ evidence: a code state checks the session's records — `variables`, files, and
216
+ the tool results the history carries as the tools returned them. Check a result
217
+ itself, never a copy the model wrote into a variable, a file or its reply, and
218
+ never call a tool again just to see its result (for a host whose tools reach an
219
+ external system, that is a second action). To read a result, find the call in an
220
+ `assistant` entry's `toolCalls`, then the `tool` entry with the same `id`:
221
+
222
+ ```js
223
+ /**
224
+ * Status check — the recorded status must be the one get_order returned.
225
+ *
226
+ * Finds the last get_order call in the session's history and the tool message
227
+ * answering it, and reads scratchpad/status.json ({ status }), which the agent
228
+ * wrote. Advances to notify-customer when the recorded status is the returned
229
+ * one; otherwise back to look-up-order, with the status to record, or with a
230
+ * request to look the order up when no get_order call has a readable result.
231
+ */
232
+ import { defineCodeState } from "@archmax-ai/harness/sandbox";
233
+
234
+ export default defineCodeState(async ({ tools }) => {
235
+ const { messages } = JSON.parse(await tools.archmax_get_history());
236
+ const call = messages.flatMap((m) => m.toolCalls ?? []).findLast((c) => c.name === "get_order");
237
+ const answer = call && messages.find((m) => m.role === "tool" && m.id === call.id);
238
+ let order;
239
+ try {
240
+ order = JSON.parse(answer.text);
241
+ } catch {
242
+ return tools.archmax_advance({ to: "look-up-order", reason: "Look the order up with get_order first." });
243
+ }
244
+ const recorded = JSON.parse(await tools.read_file({ file_path: "scratchpad/status.json" }));
245
+ if (recorded.status !== order.status) {
246
+ return tools.archmax_advance({
247
+ to: "look-up-order",
248
+ reason: `Order ${call.args.id} is ${order.status}: record that status exactly.`,
249
+ });
250
+ }
251
+ await tools.archmax_advance({ to: "notify-customer", reason: `The recorded status ${order.status} is get_order's.` });
252
+ });
253
+ ```
254
+
255
+ The history spans every turn and every entry into a state, so where only the
256
+ latest round counts, take the last match, as above. What a later state or turn
257
+ needs from the conversation (the person's words, a decision) is still best
258
+ stored in a variable or a file by the state that has it, so no check has to
259
+ search the transcript for it.
260
+
261
+ **The controls** on `tools`:
262
+
263
+ | Control | Effect |
264
+ | --- | --- |
265
+ | `archmax_advance({ to, reason, evidence? })` | Records the move along one of the state's transitions. Checked **at the call**: an undeclared `to` or an unmet `requires` rejects naming the valid targets or the missing variable, and the script may catch it and decide otherwise. `evidence` is accepted only into a human state. The `reason` is what the next state reads. Absent in a terminal state. |
266
+ | `archmax_raise({ code, reason })` | Records the end of the session as **failed**, with the code and reason as its outcome's `exit`. |
267
+ | `archmax_get_variables({ name?, path? })` / `archmax_set_variables({ variables, lock? })` | Read and write the session's variables, as the agent's calls do. A write is visible to the script's later calls, so a script can set what `requires` asks for and then advance. |
268
+ | `archmax_get_history()` | Resolves to the session's history as JSON text, `{ messages, trail }` (above). |
269
+
270
+ These are a grader's controls too: both automated types get the same set. There
271
+ is no `archmax_wait` and no `archmax_reset`: a code state cannot park, since a
272
+ script frame is not durable. To hand work to a person, advance into a human state.
167
273
 
168
- **The verdict** — the three helpers are globals:
274
+ **How the run settles** — exactly one outcome:
169
275
 
170
- | Return | Effect |
276
+ | The run… | Outcome |
171
277
  | --- | --- |
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
278
+ | called `archmax_advance` and finished cleanly | **advanced**: the session moves. The trail records `{ to, kind: "code", reason }`, and a runtime note carries the reason to the next state: `[code] '<state>' moved the run to '<to>': <reason>. You are now in '<to>'.` Into a human state the session parks after the one reply-only handoff message. Into another automated state, that state runs next, still with no model call. |
279
+ | called `archmax_raise` | **raised**: the session ends `failed` — no `on_error`, no `returns` check, nothing further runs in the turn. |
280
+ | finished without either, in a **terminal** state | **completed**: the session completes, held to the trigger's `returns`. |
281
+ | threw, timed out, could not be read, imported a foreign module, or — in a **non-terminal** state — finished without either call | **failed**: routed through the state's `on_error`, else the session ends rejected. Finishing undecided fails with `code state '<slug>' finished without calling archmax_advance or archmax_raise`. |
282
+
283
+ Three rules hold the outcomes apart:
284
+
285
+ - **The return value is not read.** Returning `false`, a string or an object does
286
+ nothing; there is no verdict to return. Only the controls decide.
287
+ - **One decision per run.** The first accepted `archmax_advance` or `archmax_raise`
288
+ is the state's decision, and every later one is refused (it rejects).
289
+ - **The decision takes effect when the script finishes.** A script that advances
290
+ and then throws **fails**, and the session does not move. A raise wins over a
291
+ later error.
292
+
293
+ So parse defensively where malformed input should *send the work back*, and let a
294
+ genuine infrastructure failure (an unreadable database) throw, so the state fails
295
+ closed through its `on_error`.
296
+
297
+ Other globals: `console.log(...)` (each line a `code-output` event, which the CLI's
298
+ state flow prints under the state), and `defineCodeState(fn)`, an alias for
299
+ `export default fn`. A file with **no default export** is evaluated as a plain
300
+ script: it reads the input from the global `args` and calls the global `tools`.
301
+ **Each run starts clean**: a fresh REPL every time the state is entered, shared
302
+ with neither the agent's `archmax_eval` nor another code state. The script is one
303
+ evaluation, so `settings.timeoutMs` (default 15000), `memoryLimitBytes`,
304
+ `maxPtcCalls` and `maxResultChars` bound it whole, and a cancelled turn stops it at
305
+ once.
306
+
307
+ `${{…}}` does not apply inside a script: the runtime substitutes references in the
308
+ arguments a *model* writes. A script already has the values in `variables` and
309
+ composes strings in JavaScript, so `${{…}}` text in a script's `tools.*` call is
193
310
  ordinary data.
194
311
 
195
312
  ### Governance of `tools.*` calls
196
313
 
197
314
  Every `tools.*` call is checked by the decision kernel at the moment it runs —
198
315
  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.*`, `mounts.*`) | Safety rules, workflow-wide `forbid_always`, the workflow's mount grants, 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`, `skills.forbid_always` and
212
- `mounts.forbid_always` bind it like anything else, and no script of either kind
213
- may write into the read-only authored zone.
214
-
215
- Mounts are held to the **workflow's** grants, taken across every state. A hook
216
- reads a governed mount that `mounts.allow_always` or any state's `mounts.allow`
217
- names, even in a state that enables none, and writes one only where a grant of
218
- the workflow asks `access: read_write`. A governed mount no grant of the workflow
219
- names is refused (`mount.not-allowed`). That includes a mount in the table only
220
- because a child workflow grants it. So grant a mount the hook needs:
316
+ directly. There is no runtime authority for scripts: **both kinds are governed
317
+ against the state they run in.**
318
+
319
+ | Script kind | Governed against | Must be granted there |
320
+ | --- | --- | --- |
321
+ | Code-state script (`type: code`) | its own code state: `tools.allow`/`forbid`, `skills.*`, `mounts.*`, plus `allow_always`, `forbid_always`, safety rules, consumer rules | every tool, skill and governed mount it uses |
322
+ | `archmax_run` / `archmax_eval` script (the model chose to run it) | the agent's active state, exactly as the model's own calls | every tool it calls |
323
+
324
+ So a code state that reads `skills/order-data/assets/orders.json` enables
325
+ `order-data` in its own `skills.allow`, and one that calls a host tool names it in
326
+ its own `tools.allow`. A tool another state grants is `undefined` on its `tools`;
327
+ a bare `forbid` entry removes a tool from it, and a guarded one (`{ tool:
328
+ write_file, paths: [...] }`) refuses the matching calls. A grant on a code state
329
+ naming `write_todos`, `archmax_eval`, `archmax_run`, `archmax_wait` or
330
+ `archmax_reset` is a `validate` error: no automated state is offered them, and
331
+ the same tools in the workflow's `allow_always` are left out of its surface.
332
+
333
+ Mounts work the same way: a governed mount is reachable from a code state only
334
+ when `mounts.allow_always` or that state's own `mounts.allow` grants it, and
335
+ writable only where the grant asks `access: read_write`:
221
336
 
222
337
  ```js
223
- // workflow.yaml: states.review.mounts.allow: [contracts]
224
- // A before hook on any state of this workflow may then read it:
225
- const policy = await tools.readFile({ file_path: "contracts/policy.md" });
338
+ // workflow.yaml: states.check-contract.mounts.allow: [contracts]
339
+ const policy = await tools.read_file({ file_path: "contracts/policy.md" });
226
340
  ```
227
341
 
228
342
  Neither kind may **read** the authoring plane at all. A `tools.*` call naming
229
- `workflows/**` is blocked (`zone.governance-plane`) with a
230
- diagnostic naming the prefix — a hook cannot read its own spec, a sibling's, or
231
- the criteria of the rubric declared in it. There is no `task()` in any sandbox
232
- context: a script that wants a model verdict gets a `rubric` hook declared beside
233
- it
234
- instead.
235
-
236
- A refused call **throws** inside the script, carrying the governance reason.
343
+ `workflows/**` is blocked (`zone.governance-plane`) with a diagnostic naming the
344
+ prefix — a script cannot read its own spec, a sibling's, or a grader's
345
+ criteria. There is no `task()` in any sandbox context and no `tools.task`: work
346
+ that needs a model's judgment is a grader state on the graph.
347
+
348
+ A refused call **rejects** inside the script, carrying the governance reason.
237
349
  The underlying tool never runs whether or not you catch it:
238
350
 
239
351
  ```js
240
352
  try {
241
- await tools.writeFile({ file_path: "scratchpad/report.md", content: body });
353
+ await tools.write_file({ file_path: "scratchpad/report.md", content: body });
242
354
  } catch (err) {
243
355
  console.log(`could not write the report: ${err.message}`);
244
356
  }
245
357
  ```
246
358
 
247
359
  A **binary file** is not a refusal and does not throw: reads are text only, so
248
- `tools.readFile` on an image, audio, video, PDF or PowerPoint file, or on any
360
+ `tools.read_file` on an image, audio, video, PDF or PowerPoint file, or on any
249
361
  file whose content holds a NUL byte (an archive, an Office document), resolves to
250
362
  the notice `Error: '<path>' is a binary file (<type>, <size>) and was not read;
251
363
  read_file returns text files only.` instead of content — an image too, even when
@@ -256,28 +368,27 @@ an `Error: …` string the same way. A script that parses what it read checks
256
368
  first:
257
369
 
258
370
  ```js
259
- const text = await tools.readFile({ file_path: "scratchpad/upload.json" });
371
+ const text = await tools.read_file({ file_path: "scratchpad/upload.json" });
260
372
  const upload = text.startsWith("Error:") ? null : JSON.parse(text);
261
373
  ```
262
374
 
263
375
  To **duplicate, move or delete** a file — a template, an image, a `.docx` —
264
- call `tools.copyFile({ source, destination, overwrite? })`,
265
- `tools.moveFile({ source, destination, overwrite? })` or
266
- `tools.removeFile({ file_path })` instead of reading and writing it back. The
267
- bytes move, binary files included, with no `readFile` line limit, and each
376
+ call `tools.copy_file({ source, destination, overwrite? })`,
377
+ `tools.move_file({ source, destination, overwrite? })` or
378
+ `tools.remove_file({ file_path })` instead of reading and writing it back. The
379
+ bytes move, binary files included, with no `read_file` line limit, and each
268
380
  call resolves to one line (`Copied '<source>' to '<destination>' (<size>).`,
269
381
  `Moved …`, `Removed '<path>'.`). They are governed by their declared paths like
270
382
  any other call, so a governance refusal (a destination in a read-only mount, a
271
- disabled skill's file) throws. A failure the operation itself meets (a missing
383
+ disabled skill's file) rejects. A failure the operation itself meets (a missing
272
384
  source, a directory, an existing destination without `overwrite: true`)
273
385
  resolves to an `Error: …` string instead.
274
386
 
275
- **Authoring rule for `archmax_run` scripts:** if sandboxed code calls `tools.X`,
276
- the state's `allow` list must cover `tool: X` (with any argument constraints the
277
- script's call satisfies), or the call is blocked. Grant it per state with
278
- `tools.allow`, or workflow-wide with `tools.allow_always`; a default tool such as
279
- `read_file` is covered wherever the workflow-wide grant includes it. Lifecycle
280
- hooks need no such grant.
387
+ **Authoring rule for every script:** if sandboxed code calls `tools.X`, the state
388
+ it runs in must cover `tool: X` (with any argument constraints the call
389
+ satisfies), or `tools.X` is `undefined` (or the call rejects). Grant it per state
390
+ with `tools.allow`, or workflow-wide with `tools.allow_always`; a default tool such
391
+ as `read_file` is covered wherever the workflow-wide grant includes it.
281
392
 
282
393
  ### `archmax_run` scripts: the `args` contract
283
394
 
@@ -288,9 +399,9 @@ store. It reads its input from the global **`args`**:
288
399
 
289
400
  - **the arguments the model passed** to `archmax_run` (`args.<name>`), plus
290
401
  - **`args.variables`** — the session's variables as a flat `name → value` map,
291
- the same map a hook gets. The runtime supplies it on every call, so a script
292
- reads the run's input (a seeded list, a delivered payload) without the model
293
- having to retype it.
402
+ the same map a code state gets. The runtime supplies it on every call, so a
403
+ script reads the run's input (a seeded list, a delivered payload) without the
404
+ model having to retype it.
294
405
 
295
406
  ```js
296
407
  /** One sub-run per requested order. */
@@ -299,16 +410,18 @@ const orders = args.variables?.orders_to_enrich ?? [];
299
410
 
300
411
  `args.variables` is a read-only snapshot; a script that must persist a result
301
412
  returns it and the state's `requires:` makes the model record it with
302
- `archmax_set_variables`. There is **no `task()`** in an `archmax_run` script or in
303
- `archmax_eval`. The model has no `task` tool at all: it is disclosed in no state
304
- and grantable by nothing, because a rubric grades the agent rather than serving it.
413
+ `archmax_set_variables`. An agent's script reaches **no control tool** — not
414
+ `archmax_advance`, not the variable tools — and there is **no `task()`** in an
415
+ `archmax_run` script or in `archmax_eval`. The model has no `task` tool at all: it
416
+ is disclosed in no state and grantable by nothing, because judging work is a
417
+ grader state and delegating it is a sub-workflow.
305
418
 
306
419
  ### Scripts and sub-workflow fan-out
307
420
 
308
421
  A script **is** the deterministic way to drive a fan-out. Delegation tools are on the `tools.*`
309
- surface — a delegation does work and returns a value, unlike the control tools a script may never
310
- call — so a script decides how many sub-runs to start, with what inputs, and what to do with each
311
- answer:
422
+ surface — a delegation does work and returns a value, unlike the control tools an agent's script
423
+ may never call — so a script decides how many sub-runs to start, with what inputs, and what to do
424
+ with each answer:
312
425
 
313
426
  ```js
314
427
  /** One sub-run per delayed order, all in flight at once. */
@@ -328,59 +441,68 @@ enriched;
328
441
  `Promise.all` genuinely parallelizes: the bridge starts each host call detached and returns its
329
442
  promise handle immediately. The runtime still bounds how many sub-runs are in flight
330
443
  (the dispatcher's `maxConcurrent`), queueing the rest. The list never enters the model's
331
- context.
444
+ context. A code state may drive the same fan-out when its own state grants the delegation tool,
445
+ and then record the results itself with `tools.archmax_set_variables` before it advances.
332
446
 
333
447
  Two limits still hold, both inherited rather than special to delegation. A script may only call a
334
448
  delegation tool its **calling state** allows — the same kernel verdict an agent's call gets. And a
335
449
  script's delegation **cannot park**: a QuickJS frame is not durable, so a child that reaches a human
336
450
  state fails that call closed with `parked`.
337
451
 
338
- Scripts still may not call `archmax_advance` (moving the state machine is the model's governed
339
- decision) or `archmax_set_variables`. Concurrent calls succeed or fail **independently**, so a script
340
- can act on what worked — `Promise.allSettled` is the honest shape when partial success is
341
- meaningful. Each sub-run is its own session with its own `scratchpad/`, so concurrent sub-runs
452
+ An agent's script still may not call `archmax_advance` (moving the state machine is the model's
453
+ governed decision) or `archmax_set_variables`. Concurrent calls succeed or fail **independently**,
454
+ so a script can act on what worked — `Promise.allSettled` is the honest shape when partial success
455
+ is meaningful. Each sub-run is its own session with its own `scratchpad/`, so concurrent sub-runs
342
456
  never collide on a path; a `file` return comes back as a copy under a name no other file holds.
343
457
 
344
458
  A script lives as long as its turn. When the host cancels the turn, a call still in flight fails
345
459
  with `Tool '<name>' failed: the turn was aborted`, and every call the script makes after that is
346
460
  refused the same way before it reaches the tool. A retry in a `catch` therefore dispatches nothing
347
- more, so there is no need to tell a cancel apart from an ordinary failure. Hook scripts stop the
348
- same way.
461
+ more, so there is no need to tell a cancel apart from an ordinary failure. Code-state scripts stop
462
+ the same way.
349
463
 
350
- ### Worked example: a `before` gate
464
+ ### Worked example: a check in front of a state
351
465
 
352
- The tenancy half of the bundled example's entry gate
353
- (`workflows/order-lookup/hooks/check-tenancy.js`), the second hook in
354
- `identify-case`'s `before` list — so a missing or unknown email has already been
355
- vetoed by `check-requester.js` before this runs:
466
+ To screen every request before any model call, make the trigger's entry state a
467
+ code state that advances to where the work starts, or to a state that declines it.
468
+ The bundled example's `screen-request` (`workflows/order-lookup/scripts/screen-request.js`)
469
+ merges a requester check and a tenancy check — wired as in
470
+ [Authoring model](#authoring-model-hand-authored), with `decline` a terminal agent
471
+ state that tells the person why:
356
472
 
357
473
  ```js
358
474
  /**
359
- * Tenancy gate — a requester may only reach their own company's orders.
475
+ * Request screen — the requester must be known, and may only reach their own
476
+ * company's orders.
360
477
  *
361
478
  * Binds the requester's email (the first one in the first user message) to the
362
479
  * customers whose orders carry it in skills/order-data/assets/orders.json, then
363
480
  * checks the request two ways: it names another customer, or it references an
364
481
  * order id (`ORD-…`) owned by another customer. Matching is case-insensitive.
365
482
  *
366
- * Vetoes with the customers or order ids the requester reached for when either
367
- * check finds one; otherwise ok. If the order database cannot be read, vetoes
368
- * too (fail-closed).
483
+ * Advances to decline — with the reason the decline state tells the person — when
484
+ * there is no email, the email is unknown, or either tenancy check finds a
485
+ * foreign customer; otherwise to identify-case. An order database that cannot
486
+ * be read throws: the state fails closed through its on_error.
369
487
  */
370
- export default async function hook({ messages, tools }) {
371
- let orders;
372
- try {
373
- orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
374
- if (!Array.isArray(orders)) throw new Error("not an array");
375
- } catch {
376
- return veto("Could not load skills/order-data/assets/orders.json - unable to check tenancy.");
377
- }
488
+ import { defineCodeState } from "@archmax-ai/harness/sandbox";
489
+
490
+ export default defineCodeState(async ({ tools }) => {
491
+ const { messages } = JSON.parse(await tools.archmax_get_history());
492
+ const orders = JSON.parse(await tools.read_file({ file_path: "skills/order-data/assets/orders.json" }));
493
+ if (!Array.isArray(orders)) throw new Error("skills/order-data/assets/orders.json is not a list of orders");
378
494
 
379
495
  const request = messages.find((m) => m.role === "user")?.text ?? "";
380
496
  const email = (request.match(/[\w.+-]+@[\w.-]+\.\w{2,}/)?.[0] ?? "").toLowerCase();
497
+ if (!email) {
498
+ return tools.archmax_advance({ to: "decline", reason: "Include your email address so we can look up your account." });
499
+ }
381
500
  const own = new Set(
382
501
  orders.filter((o) => String(o.email).toLowerCase() === email).map((o) => String(o.customer)),
383
502
  );
503
+ if (own.size === 0) {
504
+ return tools.archmax_advance({ to: "decline", reason: `"${email}" is not in the order database.` });
505
+ }
384
506
 
385
507
  const lower = request.toLowerCase();
386
508
  const foreignByName = [...new Set(orders.map((o) => String(o.customer)))].filter(
@@ -392,35 +514,64 @@ export default async function hook({ messages, tools }) {
392
514
  .map((o) => String(o.id));
393
515
 
394
516
  if (foreignByName.length > 0 || foreignById.length > 0) {
395
- const detail = (foreignByName.length > 0 ? foreignByName : foreignById).join(", ");
396
- return veto(
397
- `The email address "${email}" is not authorized to access orders for ${detail}. You may only view orders for your own account.`,
398
- );
517
+ return tools.archmax_advance({
518
+ to: "decline",
519
+ reason: `"${email}" may only view orders for its own account. Do not name the other customer's orders.`,
520
+ });
399
521
  }
400
- return ok();
401
- }
522
+ await tools.archmax_advance({ to: "identify-case", reason: `${email} is authorized for ${[...own].join(", ")}.` });
523
+ });
402
524
  ```
403
525
 
404
- The pattern to internalize: **one `return veto(reason)` per veto condition,
405
- each with a human-readable reason that names the fix**; `console.log` for
406
- anything worth seeing in a verbose run; `return ok()` at the end.
526
+ The pattern to internalize: **one `archmax_advance` per outcome, each with a
527
+ `reason` the next state can act on** (the `decline` agent reads it from the `[code]`
528
+ note and tells the person); `console.log` for anything worth seeing in a verbose
529
+ run; a `throw` for what should fail the state rather than route it. To end the
530
+ session as a failure instead, with no reply at all, call
531
+ `tools.archmax_raise({ code: "unknown-requester", reason })`.
407
532
 
408
- ### Worked example: an `after` policy check
533
+ ### Worked example: a check behind a state
409
534
 
410
- An `after` hook re-derives policy deterministically so the agent can never
411
- record a decision the policy forbids. Wiring (on `refund-request` in the
412
- bundled example's `workflow.yaml`):
535
+ To check the agent's work before the session moves on, route the agent's
536
+ transition into a code state, and give that state an edge onward and an edge
537
+ **back**. The bundled example's `check-refund` sits between `refund-request` (the
538
+ agent records a decision) and `refund-review` (a person reviews it), so a decision
539
+ the policy forbids never reaches the reviewer:
413
540
 
414
541
  ```yaml
415
- after:
416
- script: hooks/check-refund.js
542
+ states:
543
+ refund-request:
544
+ instructions: >-
545
+ … Record the decision in scratchpad/refund.json, tell the customer, and in
546
+ that same message archmax_advance to check-refund. If the check sends the
547
+ case back, its note names the fix: correct the file and advance again.
548
+ skills:
549
+ allow: [order-data, refund-policy]
550
+ transitions:
551
+ - to: check-refund
552
+ description: The refund decision has been recorded and explained to the customer.
553
+ check-refund:
554
+ type: code
555
+ script: scripts/check-refund.js
556
+ skills:
557
+ allow: [order-data]
558
+ on_error: escalation
559
+ transitions:
560
+ - to: refund-review
561
+ description: The recorded decision matches the refund policy.
562
+ - to: refund-request
563
+ description: The recorded decision contradicts the policy; send it back with the fix.
564
+ - to: escalation
565
+ description: The decision still fails the check after two corrections.
417
566
  ```
418
567
 
419
- The check (`workflows/order-lookup/hooks/check-refund.js`):
568
+ The check (`workflows/order-lookup/scripts/check-refund.js`), which counts its own
569
+ earlier send-backs on the trail and escalates on the third:
420
570
 
421
571
  ```js
422
572
  /**
423
- * Refund policy gate — the agent may not record a refund the policy forbids.
573
+ * Refund policy check — the agent may not hand a refund the policy forbids to
574
+ * the reviewer.
424
575
  *
425
576
  * Re-derives the refund decision from the order database instead of trusting
426
577
  * the one the agent wrote. Reads the recorded ticket from scratchpad/refund.json
@@ -430,78 +581,183 @@ The check (`workflows/order-lookup/hooks/check-refund.js`):
430
581
  * Policy: a refund is "approved" only when the order exists with status
431
582
  * delivered or delayed; an unknown order and any other status must be "denied".
432
583
  *
433
- * Vetoes, each with a reason that names the fix, when: the ticket file is
434
- * missing or not valid JSON; `decision` is not exactly "approved" or "denied";
435
- * the order database cannot be read; or the recorded decision contradicts the
436
- * derived one. Otherwise ok.
584
+ * Advances to refund-review when the recorded decision matches. Otherwise sends
585
+ * the session back to refund-request with a reason naming the fix — the ticket
586
+ * missing or unparseable, `decision` not "approved"/"denied", or the decision
587
+ * contradicting the policy — and, once it has sent it back twice (counted on
588
+ * the trail), advances to escalation instead. An unreadable order database
589
+ * throws: the state fails through its on_error.
437
590
  */
438
- export default async function hook({ tools }) {
591
+ import { defineCodeState } from "@archmax-ai/harness/sandbox";
592
+
593
+ const MAX_SEND_BACKS = 2;
594
+
595
+ export default defineCodeState(async ({ tools }) => {
596
+ const { trail } = JSON.parse(await tools.archmax_get_history());
597
+ const sentBack = trail.filter((s) => s.kind === "code" && s.to === "refund-request").length;
598
+ const sendBack = (reason) =>
599
+ sentBack >= MAX_SEND_BACKS
600
+ ? tools.archmax_advance({ to: "escalation", reason: `Still failing after ${sentBack} corrections: ${reason}` })
601
+ : tools.archmax_advance({ to: "refund-request", reason });
602
+
439
603
  let refund;
440
604
  try {
441
- refund = JSON.parse(await tools.readFile({ file_path: "scratchpad/refund.json" }));
605
+ refund = JSON.parse(await tools.read_file({ file_path: "scratchpad/refund.json" }));
442
606
  } catch (e) {
443
- return veto(
444
- `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`,
445
- );
607
+ return sendBack(`scratchpad/refund.json could not be read or parsed (${e.message}) - write it as a JSON object with "orderId" and "decision", then advance again`);
446
608
  }
447
-
448
609
  const decision = refund?.decision;
449
610
  if (decision !== "approved" && decision !== "denied") {
450
- return veto(`scratchpad/refund.json "decision" must be exactly "approved" or "denied"; got ${JSON.stringify(decision)}`);
451
- }
452
-
453
- let orders;
454
- try {
455
- orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }));
456
- } catch {
457
- return veto("skills/order-data/assets/orders.json could not be read - the order database must be accessible to validate the refund decision");
611
+ return sendBack(`scratchpad/refund.json "decision" must be exactly "approved" or "denied"; got ${JSON.stringify(decision)}`);
458
612
  }
459
613
 
614
+ const orders = JSON.parse(await tools.read_file({ file_path: "skills/order-data/assets/orders.json" }));
460
615
  const wanted = typeof refund.orderId === "string" ? refund.orderId.trim().toLowerCase() : "";
461
616
  const order = orders.find((o) => typeof o?.id === "string" && o.id.toLowerCase() === wanted);
462
617
  const derived = order && ["delivered", "delayed"].includes(order.status) ? "approved" : "denied";
463
618
  console.log(`Refund policy check - derived "${derived}", recorded "${decision}"`);
464
619
 
465
- if (decision === derived) return ok();
466
- const why = !order
467
- ? "it does not exist in the order database"
468
- : derived === "approved"
469
- ? `its status is "${order.status}"`
470
- : `its status "${order.status}" is not delivered or delayed`;
471
- return veto(
472
- `Policy requires "${derived}" for order ${JSON.stringify(refund.orderId)} because ${why}, but the recorded decision is "${decision}" - update scratchpad/refund.json to decision: "${derived}"`,
620
+ if (decision === derived) {
621
+ return tools.archmax_advance({ to: "refund-review", reason: `"${decision}" for ${refund.orderId} matches the refund policy.` });
622
+ }
623
+ const why = !order ? "it does not exist in the order database" : `its status is "${order.status}"`;
624
+ return sendBack(
625
+ `Policy requires "${derived}" for order ${JSON.stringify(refund.orderId)} because ${why}, but the recorded decision is "${decision}" - update scratchpad/refund.json to decision: "${derived}", correct what you told the customer, and advance again`,
473
626
  );
627
+ });
628
+ ```
629
+
630
+ Note how the send-back reasons carry the _fix_: the agent reads the reason as a
631
+ `[code]` note, rewrites `scratchpad/refund.json`, and advances again. There is no
632
+ retry budget to declare: the script counts the rounds itself, from the history's
633
+ `trail`, and decides when to give up.
634
+
635
+ ### Worked example: approve, then end
636
+
637
+ A check that approves into a terminal **agent** state costs one more model call:
638
+ that state's agent has to finish the turn. Approve into a **terminal code state**
639
+ instead. Its script runs and the session completes, with no further model call.
640
+ Say the enrichment path's grader approves into `enrichments-approved`, and the
641
+ trigger declares `returns: [delayed_order_ids]`:
642
+
643
+ ```yaml
644
+ enrichments-approved:
645
+ type: code
646
+ script: scripts/enrichments-approved.js # no transitions: terminal
647
+ ```
648
+
649
+ ```js
650
+ /**
651
+ * Approved-enrichments record — the end of the enrichment path.
652
+ *
653
+ * Reads the `enrichments` the grader approved and sets the trigger's declared
654
+ * return `delayed_order_ids` from them, so a caller has the result without
655
+ * walking the list, then returns: the session completes.
656
+ */
657
+ export default async function ({ variables, tools }) {
658
+ const delayed = variables.enrichments.filter((e) => e.delayed).map((e) => e.order_id);
659
+ await tools.archmax_set_variables({ variables: { delayed_order_ids: delayed } });
474
660
  }
475
661
  ```
476
662
 
477
- Note how the veto reasons carry the _fix_: the agent reads the reason,
478
- rewrites `scratchpad/refund.json`, and retries the transition.
663
+ A terminal code state's completion is held to the trigger's `returns` like any
664
+ completion: set a declared return with `tools.archmax_set_variables` before the
665
+ script returns.
479
666
 
480
- ### When to use a grading rubric instead
667
+ ### When to use a grader state instead
481
668
 
482
- Use a `script:` hook when the check is **deterministic** — derivable from
669
+ Use a **code state** when the check is **deterministic** — derivable from
483
670
  workspace data with plain code (policy tables, schema checks, cross-file
484
- consistency). Use a `rubric:` hook when the check requires **judgment**
485
- (is the reply complete? is the tone right?):
671
+ consistency). Use a **grader state** when the check requires **judgment** (is the
672
+ report complete? is the summary faithful?). A grader is an isolated subagent the
673
+ runtime runs on entry: its system prompt is a compiled-in preamble followed by the
674
+ state's `instructions` (its criteria), it reads the session with
675
+ `archmax_get_history` (the whole transcript, tool results included, and the
676
+ trail) and the work with its own state's tools, and it decides with
677
+ `archmax_advance` along one of its transitions, or ends the session with
678
+ `archmax_raise`. The bundled example's enrichment path:
486
679
 
487
680
  ```yaml
488
681
  states:
489
- orders-question:
490
- after:
491
- - rubric:
492
- max_iterations: 2 # `correct` verdicts allowed before a hard veto
493
- instructions: |-
494
- You review the agent's final reply about the user's orders...
495
- Return { "verdict": "ok" | "correct" | "veto", "reason": "..." }.
682
+ enrich-order:
683
+ instructions: >-
684
+ Run skills/order-enrichment/scripts/enrich-orders.js with archmax_run …
685
+ Record that list as the `enrichments` variable with archmax_set_variables,
686
+ then advance to review-enrichment, which checks it. If the review sends it
687
+ back, fix what its note names and advance again.
688
+ requires: [enrichments]
689
+ transitions:
690
+ - to: review-enrichment
691
+ description: Every order has been enriched and `enrichments` is recorded.
692
+ review-enrichment:
693
+ type: grader
694
+ budget: { maxTurns: 8 } # the grader's own model calls
695
+ on_error: escalation # a grader that fails or finishes undecided routes here
696
+ skills:
697
+ allow: [] # read_file, from the workflow-wide grant, reads the returned files
698
+ instructions: |-
699
+ You check the enrichments recorded for this run before they are reported.
700
+
701
+ 1. Read `orders_to_enrich` and `enrichments` with `archmax_get_variables`.
702
+ 2. The enrichments are correct when `enrichments` holds exactly one entry
703
+ per requested `order_id` — none missing, none extra — and each entry
704
+ has an `enrichment_file` and a boolean `delayed`.
705
+ 3. Read each `enrichment_file` with `read_file`: its `delayed` must match
706
+ the entry's.
707
+
708
+ - Correct: advance to `enrichment-reported`.
709
+ - Wrong: advance back to `enrich-order`, naming exactly what is missing or
710
+ does not match.
711
+ - Before sending it back, call `archmax_get_history` and count the steps
712
+ in `trail` with `kind: "grader"` and `to: "enrich-order"`: if there are
713
+ already two, raise `enrichment-incorrect` instead.
714
+ transitions:
715
+ - to: enrichment-reported # a terminal agent state that summarizes the enrichments
716
+ description: Every requested order has exactly one enrichment, and each matches its file.
717
+ - to: enrich-order
718
+ description: >-
719
+ The enrichments are incomplete or do not match their files: send them
720
+ back, naming exactly what to fix.
496
721
  ```
497
722
 
498
- A rubric may return `correct` — the agent gets another attempt with the
499
- grader's reason as correction, budgeted by its own `max_iterations`. Script hooks
500
- get the exact same flow when they return `correct(reason)` under a
501
- `max_iterations` sidecar. A rubric whose response cannot be parsed
502
- into a verdict **fails closed** (veto with a snippet of the raw output). A rubric
503
- receives a compact JSON payload (`userRequest`, `history`, `state`, `phase`) —
504
- its `instructions` are written against that, not against a hook's `messages`.
723
+ What to know:
724
+
725
+ - A grader state **must** declare non-empty `instructions` and at least one
726
+ transition (`validate` errors): a grader with nowhere to send the session could
727
+ only approve by saying nothing, which would fail open. `evidence`, `approvers`
728
+ and `budget.maxParks` are errors on it. Every edge **into** a grader carries a
729
+ `description`: its instructions are criteria the work it judges is never shown,
730
+ so an undescribed edge into one is a load error.
731
+ - `archmax_get_history` returns `{ messages, trail }` — the same history a code
732
+ state reads: the whole transcript, a `tool` entry carrying its result in `text`,
733
+ and every trail step. Not windowed: a grader judges the whole session, so its
734
+ criteria say which part counts ("the files `enrichments` names", "the last
735
+ assistant message with text"). Check a claim against the tool result or
736
+ the file itself, never against the agent's retelling of it.
737
+ - There is **no iteration budget**. A grader that should give up after N rounds
738
+ says so in its `instructions` and counts the rounds from the history's trail
739
+ (each earlier send-back is `{ kind: "grader", to: "<graded state>" }`), as
740
+ above. The turn's recursion limit is the only backstop.
741
+ - The grader is offered exactly its state's surface — its grants (never
742
+ `write_todos`, `archmax_eval`, `archmax_run`, `archmax_wait` or `archmax_reset`,
743
+ even when `allow_always` lists them) and the controls a code state gets:
744
+ `archmax_advance`, `archmax_raise`, the variable tools and `archmax_get_history`
745
+ — governed against the grader state. Its messages never enter the session's
746
+ transcript and its tokens never stream; the session reads its decision as a
747
+ `[grader]` note and a `kind: grader` trail step.
748
+ - A grader that answers in text without deciding **fails closed** (`grader state
749
+ '<slug>' finished without calling archmax_advance or archmax_raise`), routed
750
+ through `on_error`. `budget.maxTurns` / `timeoutMs` bound its own model calls.
751
+ - `model` on a grader state names the id it runs on, resolved through the
752
+ assembly's `modelFactory` under the **`grader`** role.
753
+ - The checked state's `instructions` tell the agent that advancing into the
754
+ grader is what hands the work in, and to fix the work when a note sends it back
755
+ — never to argue with the note.
756
+ - **Put a grader where the agent hands work on, not on a live answer.** A turn's
757
+ `reply` is every assistant text of the turn, in order, so a draft the grader
758
+ sends back has still reached the person. Check work that waits for nobody — a
759
+ report, a record, a decision a person reviews next — and let a live answer end
760
+ where it is given.
505
761
 
506
762
  ---
507
763
 
@@ -521,7 +777,7 @@ workflows/<name>/tests/
521
777
  `archmax test` filter argument matches that path as a substring.
522
778
  - **Cases are data, not code.** The host parses, validates, and interprets
523
779
  them directly — no QuickJS sandbox is created for a case. The sandbox still
524
- runs lifecycle hooks and `archmax_run` *inside the driven agent*, unchanged.
780
+ runs code-state scripts and `archmax_run` *inside the driven agent*, unchanged.
525
781
  - **A case reads like a script.** `steps` is one flat sequential list where
526
782
  actions (`send`, `decide`) and assertions are peers; each assertion
527
783
  evaluates against the view of the nearest action above it.
@@ -556,7 +812,7 @@ workflows/<name>/tests/
556
812
  (`>-`) and a literal (`|`) block of the same prose score alike.
557
813
  - Work title-first: write `title` and `description` before the steps, and
558
814
  never leave them contradicting each other. This replaces the JSDoc-header
559
- rule that applied to JS test files; JSDoc headers now apply to hook
815
+ rule that applied to JS test files; JSDoc headers now apply to code-state
560
816
  scripts and `archmax_run` sources only.
561
817
 
562
818
  Run with `archmax test <workflow> [filter]`. A minimal case:
@@ -585,8 +841,8 @@ tests:
585
841
  120000 # per-case WALL-CLOCK budget (default 120000) —
586
842
  # deliberately named differently from settings.timeoutMs,
587
843
  # the per-script sandbox budget
588
- judge: {} # grader settings ({} = env-configured default model;
589
- # or { model, modelOptions })
844
+ judge: {} # the model that scores `grade:` ({} = env-configured
845
+ # default model; or { model, modelOptions }) — not a grader state
590
846
  ```
591
847
 
592
848
  There is no `reporters` option. A
@@ -607,7 +863,8 @@ Unknown keys are validation **errors**. All keys except `title` and
607
863
  | `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). |
608
864
  | `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). |
609
865
  | `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. |
610
- | `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). |
866
+ | `mocks` | `[{ tool, whenInput?, result }]` | Declarative tool mocks: when `tool` is called with input partially matching `whenInput`, return `result` instead of executing. Intercepts **agent-initiated** calls and every programmatic call alike — an agent's script's `tools.*`, a code state's `tools.*`, a grader's calls. A control tool (`archmax_advance`, `archmax_raise`, the variable tools) is never mocked. See [the mocking section](#settle-the-mocking-depth-before-writing-a-case). |
867
+ | `clock` | `"2026-03-20"` / `"…T14:30:00Z"` | The date and time every model call of the case states as "now": an ISO 8601 date (midnight UTC) or a date-time with `Z` or an offset; a zoneless or impossible value is a schema error. Carried on every action, a handoff and a child session included, and outranks the host's `clock` option. See [Start conditions](#start-conditions-trigger-variables-and-workspace). |
611
868
  | `steps` | flat list of step entries | The case script: actions and assertions as peers (below). |
612
869
 
613
870
  There is **no multi-session surface** in test spec version 1 — no `sessions:`,
@@ -661,20 +918,20 @@ failure in one of them **halts the case** — see
661
918
 
662
919
  | Assertion | Meaning |
663
920
  | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
664
- | `succeeded: true` | **Structural.** The run finished: not failed **and not parked**, and the agent did not end it with `archmax_raise` (a raise fails here, naming its code). |
921
+ | `succeeded: true` | **Structural.** Reads the action's outcome: passes when it is `completed` — not parked, not raised with `archmax_raise`, not failed, not rejected. A tool call that failed or was blocked along the way, and that the turn recovered from, does not fail it. A miss says why: the raised code and reason, `the session is parked`, or `the turn did not complete`. |
665
922
  | `parked: true` | **Structural.** The run is parked at a human state awaiting a decision. |
666
923
  | `raised: true` / `raised: <code>` / `raised: { code?, reason? }` | **Structural.** The agent ended the turn with `archmax_raise({ code, reason })`. A string pins the code exactly; the mapping pins the code and matches the reason — exactly, or as a regex when written `"/pattern/flags"`. A miss names the code that was raised, or says the run did not raise. |
667
924
  | `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. |
668
- | `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). |
669
- | `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). |
925
+ | `reachedState: <slug>` | **Structural.** A **committed** (non-trigger) audit-trail step entered the state — the agent's `archmax_advance`, a grader's or a code state's advance, a human decision, `archmax_reset`, an `on_error` route. The trigger-arrival step never counts, so a session reaches its start state only by moving into it (a session whose entry code state raised never "reaches" it). |
926
+ | `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 **every assistant message of the session so far** (earlier turns included), not just the final message (a parked run often closes with a short "moving on" message while the substantive answer came earlier). |
670
927
  | `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). |
671
928
  | `notCalledTool: { name, input? }` | No matching call occurred. |
672
929
  | `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. |
673
- | `usedNoTools: true` | The turn used no tools at all. |
674
- | `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. |
675
- | `noTraversal: true` | **Structural.** The trail holds nothing but trigger arrivals — a veto at entry committed no transitions. |
930
+ | `usedNoTools: true` | The turn's agent used no tools at all. An automated state's calls are not in the transcript, so they never count. |
931
+ | `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` \| `grader` \| `code` \| `sub-workflow` \| `reset` \| `on_error` — a grader's or code state's move is `grader`/`code`, with the `reason` it passed to `archmax_advance`. `count: 0` asserts a state was never entered. The trail accumulates across turns — each turn starts with a `trigger` step. |
932
+ | `noTraversal: true` | **Structural.** The trail holds nothing but trigger arrivals — nothing moved the session (an entry code state that raised, an agent that answered from its start state). A grader's or code state's move is a traversal. |
676
933
  | `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. |
677
- | `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`. |
934
+ | `grade: { closedQA, atLeast }` | LLM-grade the turn's **run evidence** against the `closedQA` criterion — what the action's turn said (every text of that turn, empty when it said nothing) *plus* a bounded chronological record of the session's assistant messages and tool calls so far (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`. |
678
935
 
679
936
  **Regex form**: a string shaped `/pattern/flags` is compiled as a regular
680
937
  expression; anything else matches as a plain substring (in `reply` tokens) or
@@ -684,7 +941,30 @@ validation errors**, never silently matched as literals. Quote them in YAML —
684
941
 
685
942
  **There is no generic predicate.** No `satisfies`, no inline JS — that is the
686
943
  point of the format. Bespoke logic belongs in a vitest unit test against the
687
- engine or in a lifecycle hook.
944
+ engine or in a code state on the graph.
945
+
946
+ **An automated state is asserted by what it committed.** A code state's or a
947
+ grader's own tool calls are not in the transcript, so `calledTool`,
948
+ `notCalledTool`, `blockedTool` and `usedNoTools` see only the agent's, and a
949
+ grader's words are never part of `reply`. Assert its move and reason with
950
+ `reachedState` / `trail: { kind: code | grader, to, reason?, count }`, its raise
951
+ with `raised`, what it set with `variables`, and its dispatches with
952
+ `ranWorkflow`:
953
+
954
+ ```yaml
955
+ steps:
956
+ - send: "Hi, I'm support@acmecorp.com. Which orders are delayed?"
957
+ - trail: { kind: code, to: identify-case, count: 1 } # the entry screen passed it
958
+ ```
959
+
960
+ ```yaml
961
+ trigger: { id: enrichment_requested } # the enrichment path
962
+ variables: { orders_to_enrich: [{ order_id: "ORD-1002" }, { order_id: "ORD-1003" }] }
963
+ steps:
964
+ - send: "Enrich the requested orders."
965
+ - trail: { kind: grader, to: enrichment-reported, count: 1 } # the grader approved the enrichments
966
+ - trail: { kind: grader, to: enrich-order, count: 0 } # without a send-back
967
+ ```
688
968
 
689
969
  ### Structural assertions halt the case
690
970
 
@@ -762,8 +1042,8 @@ steps:
762
1042
  runtime's `variables` and therefore **locked**, values structured or
763
1043
  scalar. A seed here is indistinguishable from a production host seed to
764
1044
  everything downstream: a `${{…}}` reference in a `tools.allow` glob resolves
765
- from it, a state's `requires:` gate is satisfied by it, a lifecycle hook reads
766
- it in `variables`, and `archmax_set_variables` refuses to rewrite it.
1045
+ from it, a state's `requires:` gate is satisfied by it, a code state's script
1046
+ reads it in `variables`, and `archmax_set_variables` refuses to rewrite it.
767
1047
  **Recover the real payload rather than inventing one** — see
768
1048
  [Recovering a case's inputs from previous runs](#recovering-a-cases-inputs-from-previous-runs).
769
1049
  - **`workspace`** — input files seeded before any step. Keys are run-zone
@@ -783,6 +1063,15 @@ Two operational notes:
783
1063
  _earlier_ run whose declaration was since removed can linger. The default
784
1064
  (per-run unique sessions) starts clean.
785
1065
 
1066
+ **Pin the date** with a case-level `clock:` whenever a fixture or a prompt
1067
+ carries a date (an order placed on `2026-03-15`, "refundable for 30 days"):
1068
+ without it the model reads the real "now" and the verdict drifts with the
1069
+ calendar. Pin on a ten-minute mark (the line is rounded down to ten minutes,
1070
+ UTC only). Only the prompt is pinned — code-state scripts, the sandbox's `Date`, a
1071
+ park's `resumeAt` and every recorded timestamp stay real, so a script that needs
1072
+ the case's "today" reads it from a seeded variable, and a wait resumed in the
1073
+ case is resumed with `deliver`, not by its `until`.
1074
+
786
1075
  ### Recovering a case's inputs from previous runs
787
1076
 
788
1077
  A trigger carries only an id, so everything a firing passes arrives as
@@ -863,8 +1152,8 @@ actually calls and propose a split:
863
1152
  inventory, today's date-dependent data). A test that is not safe to re-run is
864
1153
  not a test.
865
1154
  - **Leave real by default** reads against fixtures committed to the workspace,
866
- the workflow's own scripts and hooks, and run-zone file writes — mocking those
867
- would test the mock instead of the workflow.
1155
+ the workflow's own scripts and code states, and run-zone file writes — mocking
1156
+ those would test the mock instead of the workflow.
868
1157
  - **Ask** about reads against a live system: cheap and stable enough to keep
869
1158
  real, or slow, rate-limited, or credential-bound enough to mock?
870
1159
 
@@ -874,9 +1163,10 @@ while a reader checking the case against the workflow needs to know what was
874
1163
  deliberately left real, which is exactly the kind of rationale the comment
875
1164
  exists for.
876
1165
 
877
- Mocks are the top-level `mocks:` list; they intercept **agent-initiated and
878
- PTC** (script `tools.*`) calls with the same partial-input matching as
879
- `calledTool`. Interception is guaranteed under `archmax test`. A host running
1166
+ Mocks are the top-level `mocks:` list; they intercept **agent-initiated** calls
1167
+ and every programmatic call — an agent's script, a code state's script, a
1168
+ grader — with the same partial-input matching as `calledTool`. So a mock of a
1169
+ file an entry code state reads changes what that state decides too. Interception is guaranteed under `archmax test`. A host running
880
1170
  the same cases through `runTests` with its own `createTarget` must wire
881
1171
  `createToolMockMiddleware()` into that agent — a case that declares `mocks:`
882
1172
  against a target that cannot intercept them is refused with an error **before
@@ -892,9 +1182,8 @@ workflow-governed target. See
892
1182
  # (threshold 0.7) checks the order is attributed to the requester's company.
893
1183
  title: Delayed orders answered for a known requester
894
1184
  description: >-
895
- One turn from support@acmecorp.com (present in skills/order-data/assets/orders.json): the run
896
- succeeds, the trail committed the route into orders-question, and the reply
897
- names the one delayed order, ORD-1003.
1185
+ A known requester's question routes into orders-question, and the reply names
1186
+ the one delayed order, ORD-1003.
898
1187
  steps:
899
1188
  - send: "Hi, I'm support@acmecorp.com. Which orders are delayed for Acme?"
900
1189
  - succeeded: true
@@ -908,27 +1197,33 @@ steps:
908
1197
  atLeast: 0.7
909
1198
  ```
910
1199
 
911
- ### Worked example: veto, leak check, `noTraversal`
1200
+ ### Worked example: declined at entry, leak check
912
1201
 
913
- A `before` veto leaves the audit trail holding nothing but the trigger
914
- arrival — `noTraversal` asserts exactly that, and `reply.excludes` catches a
915
- refusal that quotes the data it refuses to share:
1202
+ An entry code state that declines a request commits a `code` step into the
1203
+ declining state and none into the state the work starts in — `reachedState`
1204
+ and a `count: 0` trail pin exactly that — and `reply.excludes` catches a
1205
+ refusal that quotes the data it refuses to share. (Had the screen ended the
1206
+ session with `tools.archmax_raise` instead, `raised: <code>` and
1207
+ `noTraversal: true` would pin it.)
916
1208
 
917
1209
  ```yaml
918
- # The `excludes` tokens matter as much as the refusal itself: a refusal that
919
- # quotes the order ids it declines to share still leaks them. The judge
920
- # (threshold 0.7) covers the same ground in prose.
921
- title: Cross-tenant order access is vetoed
1210
+ # The `trail` count pins the decline to *entry*: identify-case was never
1211
+ # entered, so no route was taken. The `excludes` tokens matter as much as the
1212
+ # refusal itself: a refusal that quotes the order ids it declines to share
1213
+ # still leaks them. The judge (threshold 0.7) covers the same ground in prose.
1214
+ title: Cross-tenant order access is declined
922
1215
  description: >-
923
- A known requester asking for Globex's delayed orders is declined as
924
- unauthorized, nothing beyond the trigger arrival reaches the trail, and no
925
- Globex order id appears in the reply.
1216
+ A known requester asking for Globex's delayed orders is declined at entry,
1217
+ identify-case is never entered, and no Globex order id appears in the reply.
926
1218
  steps:
927
1219
  - send: "Hi, this is support@acmecorp.com. Show me Globex's delayed orders."
1220
+ - succeeded: true
1221
+ - reachedState: decline
1222
+ - trail: { kind: code, to: decline, count: 1 }
1223
+ - trail: { to: identify-case, count: 0 }
928
1224
  - reply:
929
- includes: "/not authorized|only .* your own|rejected/i"
1225
+ includes: "/not authorized|only .* your own|declined/i"
930
1226
  excludes: ["ORD-2001", "ORD-2002"]
931
- - noTraversal: true
932
1227
  - grade:
933
1228
  closedQA: >-
934
1229
  declines the request because the requester is not authorized to view
@@ -940,20 +1235,28 @@ steps:
940
1235
  ### Worked example: mocking tool results
941
1236
 
942
1237
  ```yaml
943
- # Only read_file against skills/order-data/assets/orders.json is mocked; everything else — the
944
- # hooks, the agent's own writes — stays real. Asserting the lookup *and* the
945
- # empty answer catches both a skipped lookup and an invented order.
946
- title: Empty order database reports no matches
1238
+ # Only read_file against skills/order-data/assets/orders.json is mocked; the
1239
+ # agent's own writes stay real. The report_requested trigger enters
1240
+ # report-requested directly, so no code state reads the records first — a mock
1241
+ # answers a code state's calls too, and an empty database would make the entry
1242
+ # screen decline the requester. Asserting the lookup *and* the empty answer
1243
+ # catches both a skipped lookup and an invented order.
1244
+ title: Empty order database reports no delayed orders
947
1245
  description: >-
948
- With skills/order-data/assets/orders.json mocked empty, a delayed-orders question from a known
949
- requester still consults the database, and the reply reports no matching
950
- orders.
1246
+ With skills/order-data/assets/orders.json mocked empty, a requested report
1247
+ still consults the database, and the reply reports no delayed orders.
1248
+ trigger:
1249
+ id: report_requested
1250
+ variables:
1251
+ company: "Acme Corp"
1252
+ workspace:
1253
+ trigger.json: { company: "Acme Corp" }
951
1254
  mocks:
952
1255
  - tool: read_file
953
1256
  whenInput: { file_path: skills/order-data/assets/orders.json }
954
1257
  result: "[]"
955
1258
  steps:
956
- - send: "Hi, I'm support@acmecorp.com. Which of my orders are delayed?"
1259
+ - send: "A delayed-orders report was requested."
957
1260
  - calledTool:
958
1261
  name: read_file
959
1262
  input:
@@ -1044,8 +1347,8 @@ seeded byte-for-byte before any step:
1044
1347
  title: Delayed-orders report compiled from a trigger
1045
1348
  description: >-
1046
1349
  The report_requested trigger starts the run: the agent reads the seeded
1047
- trigger.json payload, writes scratchpad/report.json, and the reply names Acme's
1048
- one delayed order, ORD-1003.
1350
+ trigger.json, writes scratchpad/report.json, and the reply names Acme's one
1351
+ delayed order, ORD-1003.
1049
1352
  trigger:
1050
1353
  id: report_requested
1051
1354
  variables:
@@ -1065,7 +1368,7 @@ steps:
1065
1368
  - calledTool:
1066
1369
  name: write_file
1067
1370
  input:
1068
- file_path: "/output\\/report\\.json/"
1371
+ file_path: "/scratchpad\\/report\\.json/"
1069
1372
  - reply:
1070
1373
  includes: ["ORD-1003", "Acme"]
1071
1374
  ```
@@ -1092,7 +1395,7 @@ skipped silently. The mapping:
1092
1395
  | `t.auditTrail()` + `filter(...).length === n` | `- trail: { to?, kind?, reason?, count }`; "nothing but the trigger arrival" ⇒ `- noTraversal: true`; trigger-arrival reason ⇒ `- triggerArrival: <id>` |
1093
1396
  | `t.judge.autoevals.closedQA(c).atLeast(n)` | `- grade: { closedQA: c, atLeast: n }` step entry |
1094
1397
  | `t.skip(reason)` | `skip: <reason>` |
1095
- | bespoke predicates | **No YAML equivalent — deliberate.** Move bespoke logic into a vitest unit test against the engine, or a lifecycle hook (`veto(reason)`). |
1398
+ | bespoke predicates | **No YAML equivalent — deliberate.** Move bespoke logic into a vitest unit test against the engine, or a code state that checks it on the graph. |
1096
1399
  | `import { defineEval } from "@archmax-ai/harness/testing"` | Nothing — the subpath is removed; YAML imports nothing |
1097
1400
 
1098
1401
  Removed package exports: `createDriveTool`, `DriveRequest`, `DriveSession`,
@@ -1101,8 +1404,9 @@ cases themselves: `parseCaseDocument`, `CaseSchemaError`, the case types
1101
1404
  (`CaseDocument`, `CaseStep`, `CaseExpectation`, …), and the test-spec
1102
1405
  versioning surface (`DEFAULT_TEST_SPEC_VERSION`, `SUPPORTED_TEST_SPEC_VERSIONS`,
1103
1406
  `resolveTestSpecVersion`, `isTestSpecVersionSupported`,
1104
- `UnsupportedTestSpecVersionError`). `@archmax-ai/harness/sandbox` carries the hook
1105
- contract (`ok`/`veto`/`correct`, `defineHook`, `HookInput`).
1407
+ `UnsupportedTestSpecVersionError`). `@archmax-ai/harness/sandbox` carries the
1408
+ code-state contract (`defineCodeState`, `CodeStateInput`, `CodeStateArgs`,
1409
+ `CodeStateFn`, `SandboxTools`, `SessionHistory`, `HistoryMessage`, `TrailStep`).
1106
1410
 
1107
1411
  ---
1108
1412
 
@@ -1110,11 +1414,11 @@ contract (`ok`/`veto`/`correct`, `defineHook`, `HookInput`).
1110
1414
 
1111
1415
  | Concern | File in the SDK |
1112
1416
  | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
1113
- | Sandbox prelude (verdict helpers, `defineHook`) | `src/sandbox/assets/parts/core.js`, `lifecycle-hook.js`; `src/sandbox/prelude.ts` |
1114
- | Hook source preparation (`@archmax-ai/harness/*` import stripping, `export default`, foreign-import errors) | `src/sandbox/imports.ts` |
1115
- | Executor, sandbox tools, PTC gateway, rubric-dispatch bridge | `src/sandbox/executor.ts`, `tools.ts`, `ptc-gateway.ts`, `bridge.ts` |
1116
- | Typed entry point (`ok`/`veto`/`correct`, `defineHook`, `HookInput`) | `src/public/sandbox.ts` |
1117
- | Lifecycle verdict parsing & fail-closed rules | `src/lifecycle/runner.ts` |
1118
- | Hook input assembly (`messages`) | `src/lifecycle/runner.ts`, `src/lifecycle/transcript.ts` |
1417
+ | Sandbox prelude (the `code` context, `defineCodeState`, the scoped `tools` view) | `src/sandbox/assets/parts/code-state.js`; `src/sandbox/prelude.ts` |
1418
+ | Script source preparation (`@archmax-ai/harness/*` import stripping, `export default`, foreign-import errors) | `src/sandbox/imports.ts` |
1419
+ | Executor, sandbox tools, PTC gateway | `src/sandbox/executor.ts`, `tools.ts`, `ptc-gateway.ts` |
1420
+ | Typed entry point (`defineCodeState`, `CodeStateInput`, `SessionHistory`, `HistoryMessage`, `TrailStep`) | `src/public/sandbox.ts` |
1421
+ | Running an automated state, its outcomes and controls | `src/workflow/automated.ts`, `automated-controls.ts` |
1422
+ | Code-state and grader runs; the history (`messages`, `trail`) | `src/workflow/code-state.ts`, `grader-state.ts`, `history.ts` |
1119
1423
  | Case engine (host-side) | `src/testing/` — case schema `case-schema.ts`, discovery `discovery.ts`, assertion evaluation `assertions.ts`, grader `grade.ts`, runner `runner.ts` |
1120
- | Validation diagnostics (missing script file, foreign import, YAML case checks) | `src/validate/validate.ts` |
1424
+ | Validation diagnostics (missing code-state script, foreign import, YAML case checks) | `src/validate/validate.ts` |