@statelyai/agent 2.0.0-alpha.29 → 2.0.0-alpha.31

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 (69) hide show
  1. package/CHANGELOG.md +1733 -0
  2. package/dist/ai-sdk.cjs +9 -9
  3. package/dist/ai-sdk.d.cts +5 -5
  4. package/dist/ai-sdk.d.mts +5 -5
  5. package/dist/ai-sdk.mjs +4 -4
  6. package/dist/{decision-Bl_rKQnU.mjs → decision-DqO018Tp.mjs} +23 -10
  7. package/dist/{decision-oevZnkBe.cjs → decision-Iys2hzg3.cjs} +31 -18
  8. package/dist/{event-log-Dtb16p1p.cjs → event-log-Bdww2l1D.cjs} +104 -25
  9. package/dist/{event-log-GPZO9p7_.mjs → event-log-DE430ZCa.mjs} +87 -26
  10. package/dist/{event-log-store-bCdskPNS.cjs → event-log-store-BWMLi6oh.cjs} +1 -1
  11. package/dist/{event-log-store-yalq8C77.mjs → event-log-store-CCWdTPEP.mjs} +1 -1
  12. package/dist/{event-log-store-CN9ygggF.d.cts → event-log-store-DTf_OzB-.d.cts} +1 -1
  13. package/dist/{event-log-store-DS836wch.d.mts → event-log-store-RziIobdW.d.mts} +1 -1
  14. package/dist/index.cjs +136 -23
  15. package/dist/index.d.cts +58 -11
  16. package/dist/index.d.mts +58 -11
  17. package/dist/index.mjs +133 -21
  18. package/dist/log.cjs +2 -2
  19. package/dist/log.d.cts +1 -1
  20. package/dist/log.d.mts +1 -1
  21. package/dist/log.mjs +2 -2
  22. package/dist/machines.cjs +1 -1
  23. package/dist/machines.mjs +1 -1
  24. package/dist/{model-source-C7FFP2Sj.mjs → model-source-CXsSZPq0.mjs} +1 -2
  25. package/dist/{model-source-DjJPjGSP.cjs → model-source-DQ7KgJaT.cjs} +4 -5
  26. package/dist/openai.cjs +3 -3
  27. package/dist/openai.d.cts +2 -2
  28. package/dist/openai.d.mts +2 -2
  29. package/dist/openai.mjs +3 -3
  30. package/dist/otel.d.cts +1 -1
  31. package/dist/otel.d.mts +1 -1
  32. package/dist/{run-agent-C9ubX8Tx.d.cts → run-agent-B5opVUxx.d.cts} +25 -6
  33. package/dist/{run-agent-CxSyZmIE.d.mts → run-agent-D4MwS5GY.d.mts} +25 -6
  34. package/dist/{run-agent-CuQT1xSS.mjs → run-agent-DWZ1uyBJ.mjs} +100 -37
  35. package/dist/{run-agent-cToZ3K42.cjs → run-agent-tmk58M0x.cjs} +100 -37
  36. package/dist/{setup-agent-DniLvDu7.mjs → setup-agent-BaffhCcM.mjs} +38 -3
  37. package/dist/{setup-agent-tsc3vm5I.d.mts → setup-agent-CPyg2ic7.d.mts} +11 -3
  38. package/dist/{setup-agent-DTF3NQg3.cjs → setup-agent-D0L8Anl_.cjs} +38 -3
  39. package/dist/{setup-agent-DlbgwsIJ.d.cts → setup-agent-yFFVXf5s.d.cts} +11 -3
  40. package/dist/tanstack-ai.cjs +3 -3
  41. package/dist/tanstack-ai.d.cts +2 -2
  42. package/dist/tanstack-ai.d.mts +2 -2
  43. package/dist/tanstack-ai.mjs +3 -3
  44. package/dist/testing.cjs +176 -26
  45. package/dist/testing.d.cts +128 -10
  46. package/dist/testing.d.mts +128 -10
  47. package/dist/testing.mjs +168 -20
  48. package/dist/{text-logic-BDbGjYMh.d.mts → text-logic-BwdrDpO9.d.mts} +6 -2
  49. package/dist/{text-logic-9fv1i9aK.d.cts → text-logic-j9hShB7d.d.cts} +6 -2
  50. package/dist/{usage-D8UphgEA.d.mts → usage-BYqv7VBw.d.cts} +1 -1
  51. package/dist/{usage-R1NTmLxY.d.cts → usage-Bl4nK8D-.d.mts} +1 -1
  52. package/dist/validate.cjs +3 -3
  53. package/dist/validate.d.cts +2 -2
  54. package/dist/validate.d.mts +2 -2
  55. package/dist/validate.mjs +3 -3
  56. package/dist/{verify-B5qGC5WN.mjs → verify-BH5e0XLK.mjs} +55 -5
  57. package/dist/{verify-DYH_2VzU.d.cts → verify-CPKDPMwY.d.cts} +14 -6
  58. package/dist/{verify-Cqghu4gz.cjs → verify-CS_xxvVU.cjs} +57 -7
  59. package/dist/{verify-CBuYIpsa.d.mts → verify-tkymWz6X.d.mts} +14 -6
  60. package/package.json +34 -4
  61. package/readme.md +72 -42
  62. package/skills/add-evals/SKILL.md +144 -0
  63. package/skills/add-traces/SKILL.md +147 -0
  64. package/skills/generate-machine/SKILL.md +6 -6
  65. package/skills/graph-engineering/SKILL.md +33 -0
  66. package/skills/improve-machine/SKILL.md +73 -21
  67. package/skills/improve-machine/reference.md +12 -7
  68. package/skills/loop-to-machine/SKILL.md +210 -0
  69. package/skills/loop-to-machine/policy.md +154 -0
@@ -31,13 +31,13 @@ import { describeMachine } from "@statelyai/agent";
31
31
  const text = describeMachine(machine); // "Machine <id>", then "Goal: …", states, transitions, events
32
32
  ```
33
33
 
34
- Pass `{ snapshot }` to mark the active states with `*`. Concept page: `docs/environment-and-policy.md`.
34
+ Pass `{ snapshot }` to mark the active states with `*`. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/next/docs/environment-and-policy.md).
35
35
 
36
36
  ## 1. Read the schema
37
37
 
38
38
  The config shape is fixed by a shipped JSON Schema. Read it first; it is the contract.
39
39
 
40
- - Consuming the package: `node_modules/@statelyai/agent/agent-workflow.json` (importable as `@statelyai/agent/agent-workflow.json`)
40
+ - Consuming the package: `node_modules/@statelyai/agent/schemas/agent-workflow.json` (importable as `@statelyai/agent/agent-workflow.json`)
41
41
  - In the `statelyai/agent` repo: `schemas/agent-workflow.json`
42
42
 
43
43
  ## 2. Author the config
@@ -58,7 +58,7 @@ Rules the runtime enforces. Each one maps to a build error or a lint diagnostic:
58
58
  - A request invoke resolves with `{ result, messages }`, so `onDone.assign` reads its result from `"{{ event.output.result.<field> }}"` (`"{{ event.output.result }}"` for a plain-text request). A non-request `actors` invoke reads `"{{ event.output.<field> }}"` directly.
59
59
  - Every path must reach a `"type": "final"` state, and each final state needs an `output` when `schemas.output` is declared.
60
60
  - Do not invent guard or action names. Only `"{{ }}"` guards, `assign`, and `emit` exist unless the host tells you which named guards/actions it implements.
61
- - Model refs are strings (`"openai/gpt-5.4-mini"`); the host resolves them.
61
+ - Model refs are strings (`"openai/gpt-6-luna"`); the host resolves them.
62
62
  - To make a decision a closed choice, add `payloads` to the `agent.decide` input: one payload list per event type, without `type` (`"payloads": { "REFUND": [{ "amount": 20 }, { "amount": 50 }], "ESCALATE": [] }`). The model then picks one listed event instead of writing a payload. A type with no key is offered as `{ type }`; `[]` withholds it.
63
63
  - An idle human step can carry `meta.interaction` for the host to render with `getInteraction`: a `label`, `events` (one button per event type), `choices` (one button per full event, `{ "label": "Refund $20", "event": { "type": "REFUND", "amount": 20 } }`, or a context path string holding such a list), and `textEvent` for free text. A bare `"textEvent": "REJECT"` puts the text in a `text` field; `{ "type": "REJECT", "field": "reason" }` names the field. `meta` is not template-evaluated.
64
64
 
@@ -102,7 +102,7 @@ Reference config — decision, text request, idle human step, one final state:
102
102
  "context": { "ticket": "{{ input.ticket }}" },
103
103
  "requests": {
104
104
  "draftReply": {
105
- "model": "openai/gpt-5.4-mini",
105
+ "model": "openai/gpt-6-luna",
106
106
  "system": "Draft a short, courteous support reply.",
107
107
  "prompt": "{{ input.ticket }}",
108
108
  "input": {
@@ -124,7 +124,7 @@ Reference config — decision, text request, idle human step, one final state:
124
124
  "id": "triageDecision",
125
125
  "src": "agent.decide",
126
126
  "input": {
127
- "model": "openai/gpt-5.4-mini",
127
+ "model": "openai/gpt-6-luna",
128
128
  "system": "Decide whether this ticket needs escalation or a drafted reply.",
129
129
  "prompt": "{{ context.ticket }}",
130
130
  "allowedEvents": ["ESCALATE", "REPLY"],
@@ -217,7 +217,7 @@ const result = await verifyAgentConfig(candidate, {
217
217
  guards: [],
218
218
  actions: [],
219
219
  events: ["ESCALATE", "REPLY", "APPROVE", "REJECT"],
220
- models: ["openai/gpt-5.4-mini"],
220
+ models: ["openai/gpt-6-luna"],
221
221
  },
222
222
  explore: {
223
223
  inputs: [{ ticket: "Export downloads a 0-byte CSV on Safari." }],
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: graph-engineering
3
+ description: Route an agent-reliability request to the right @statelyai/agent skill and keep the four steps in order — make it a state machine, add traces, add evals, self-improve. Use when asked to "make my agent reliable", "graph engineering", "turn my agent into a state machine and improve it", "where do I start with this agent", or any request that spans more than one of those steps.
4
+ ---
5
+
6
+ # Graph engineering
7
+
8
+ Four steps, in order. Each one needs the one before it.
9
+
10
+ | Step | Skill | Done when |
11
+ | --- | --- | --- |
12
+ | 1. Make it a state machine | `loop-to-machine` (existing code) or `generate-machine` (from scratch) | Rules are guards and the sentinel is unreachable (`canReach`). From existing code: the parity report has no unreviewed regressions. From scratch: every `verifyAgentConfig` gate passes |
13
+ | 2. Add traces | `add-traces` | Every run leaves a stamped log thread that replays to its final state |
14
+ | 3. Add evals | `add-evals` | Evals per state at the cheapest level, an objective, baseline numbers per split |
15
+ | 4. Self-improve | `improve-machine` | A pull request with one policy change and its held-out scorecard, or a report that none helped |
16
+
17
+ ## Find where the project is
18
+
19
+ Check in this order and start at the first step that is not done:
20
+
21
+ 1. Does the code path production actually runs go through a machine (`setupAgent`, `createMachine`, an `AgentWorkflowConfig`)? Follow the route or entry point to the model call; a machine that nothing calls does not count. If the path is a loop or ad-hoc code, step 1.
22
+ 2. Do runs persist to an event log store with a declared machine `version` and a `metadata` stamp? If not, step 2.
23
+ 3. Is there an objective over ground truth, and evals that name the states they cover? If not, step 3.
24
+ 4. Otherwise, step 4.
25
+
26
+ Tell the user which step you are starting at and why, in one line, then load that skill.
27
+
28
+ ## Across all steps
29
+
30
+ - **The environment is people's code.** Rules, guards, invariants, and the sentinel change only by human review. No step rewrites them on its own.
31
+ - **Ground truth scores everything.** Outcomes and effects in the world, labeled by people. A model never grades its own runs.
32
+ - **Ask for labels and rules; decide the mechanics.** Expected outcomes, thresholds, the objective, and the train/held-out split are the user's. File layout, test names, and helper code are yours.
33
+ - **Hand back between steps.** Finish one step's hand-back before starting the next, unless the user asked for several in one go.
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: improve-machine
3
- description: Improve a @statelyai/agent policy machine (a JSON AgentWorkflowConfig) from its run traces toward its goal, inside a fixed environment machine, with gates and a held-out promotion rule. Use when asked to "improve this agent", "self-improve", "optimize the playbook from traces", "tune the policy", "make the agent cheaper or more accurate", or "why is my agent failing" for a machine whose runs are persisted to an event log store.
3
+ description: Improve a @statelyai/agent policy machine (a TypeScript setupAgent machine or a JSON AgentWorkflowConfig) from its run traces toward its goal, inside a fixed environment machine, with gates and a held-out promotion rule, and hand the result to a person as a pull request. Use when asked to "improve this agent", "self-improve", "optimize the playbook from traces", "tune the policy", "make the agent cheaper or more accurate", or "why is my agent failing" for a machine whose runs are persisted to an event log store.
4
4
  ---
5
5
 
6
6
  # Improve an agent machine
7
7
 
8
- You improve a **policy**: the JSON `AgentWorkflowConfig` that decides how the agent works toward its goal. It runs inside an **environment**: a TypeScript machine whose states and guards encode the rules of the world and the business invariants. One round changes the policy once and measures it:
8
+ You improve a **policy**: the machine that decides how the agent works toward its goal, written in TypeScript or as a JSON `AgentWorkflowConfig`. It runs inside an **environment**: a TypeScript machine whose states and guards encode the rules of the world and the business invariants. One round changes the policy once and measures it:
9
9
 
10
10
  ```
11
- locate inputs → read traces → diagnose → propose one diff → verifyAgentConfig (≤ 3 repairs) → replay train → replay held-out → promote or discard → record
11
+ locate inputs → read traces → diagnose → propose one diff → gate (≤ 3 repairs) → replay train → replay held-out → recommend or discard → record → pull request
12
12
  ```
13
13
 
14
- To author a policy from scratch, use the `generate-machine` skill. Concept page: `docs/environment-and-policy.md`.
14
+ This is step 4 of 4, after `loop-to-machine`, `add-traces`, and `add-evals`. To author a JSON policy from scratch, use the `generate-machine` skill. Concept page: [Environment and policy](https://github.com/statelyai/agent/blob/next/docs/environment-and-policy.md).
15
15
 
16
16
  ## Hard rules
17
17
 
@@ -19,8 +19,32 @@ To author a policy from scratch, use the `generate-machine` skill. Concept page:
19
19
  - **Never edit a machine while it runs.** A promoted policy applies to new runs only.
20
20
  - **The objective comes from ground truth.** Score outcomes from the traces (terminal states, outputs) or from effects recorded in the world. Never ask a model to grade its own runs.
21
21
  - **One cause per round, smallest diff.** A diff that fixes two things cannot tell you which one helped.
22
- - **Diagnose from training scenarios only.** Held-out scenarios are for the promotion decision. Never read their failures to shape a change, and never move a scenario between splits.
22
+ - **Diagnose from training scenarios only.** Held-out scenarios are for the promotion decision. Never read their failures to shape a change, and never move a scenario between splits. Eval files may list baseline failures for every split; read only the training rows.
23
23
  - **Stop after 3 rounds without a promotion** (or the limit the user gives).
24
+ - **Propose, never promote.** A round ends in a recommendation. A recommended candidate goes to a person as a pull request with its scorecard; merging it is the promotion. Never merge, deploy, or switch the running policy yourself.
25
+
26
+ ## Policy format
27
+
28
+ The rounds are the same for both formats. What differs is what the diff may touch and how it is gated.
29
+
30
+ | | TypeScript policy | JSON policy |
31
+ | --- | --- | --- |
32
+ | The policy | A `setupAgent` machine in its own file(s), with a declared `version` | An `AgentWorkflowConfig` file |
33
+ | May change | The policy file(s) and their `CHANGELOG.md`; once, the line in the runner that routes the decision to the policy; tests that pin measured numbers (see the pull request) | The config's `states` and `context` |
34
+ | Gates | `tsc`, `lintAgentMachine`, `explorePaths` on the policy, the environment check, the overfit check | `verifyAgentConfig` |
35
+ | Vocabulary | What the policy file imports and the events the environment offers | The `allow` lists |
36
+
37
+ **No policy yet.** `loop-to-machine` leaves a single model decision inside the environment when the old code had no policy logic. Then:
38
+
39
+ - Record `v0` in a new `CHANGELOG.md` next to the environment (in a `policy/` directory): "the model decides directly". This entry goes in the working tree.
40
+ - The first recommended candidate introduces the policy as `v1`: a policy file that reproduces the current behavior (it passes the environment's prompt and facts through to the model) plus the one rule the diagnosis found, and the routing line in the runner (`withPolicy(modelExecutors)`). The policy runs as the decider through `createPolicyDecider` from `@statelyai/agent`; the pattern is in `../loop-to-machine/policy.md`.
41
+ - Its `description` is the environment's goal with measurable success criteria. If the environment's goal has none, draft them from the confirmed objective and list the draft under questions for the user.
42
+ - If the policy needs a fact the environment does not pass in the decision's `metadata.facts`, that is an environment change request.
43
+
44
+ For a TypeScript policy, before the first round:
45
+
46
+ - **List the environment files** (the environment machine, its guards, the world and its tools) and record a hash of each. Any change to them fails the round. The runner is not one of them, but its diff may contain only the routing line.
47
+ - **Keep the policy a machine.** A diff may add states, `choice` branches, guards over facts, helpers in the policy file, or change a prompt or a model ref. It may not turn the policy into a free function, call tools the environment owns, or import from the environment anything but its schemas, types, constants, and exported guard functions.
24
48
 
25
49
  ## 1. Locate the inputs
26
50
 
@@ -30,16 +54,16 @@ Find all six before changing anything. If one is missing, stop and ask.
30
54
  | -------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
31
55
  | Goal | Root `description` of the policy config or the environment machine | Draft one with measurable success criteria; ask the user to confirm it |
32
56
  | Objective | A scorer over outcomes (an eval file, a `score*` function, a benchmark) | Write one from the goal's success criteria; **ask the user to confirm it** |
33
- | Traces | The `store` passed to `createAgentRuntime` / `runAgent`; its `listThreads`, or recorded ids | Wire a store first (see `generate-machine` step 6) and collect runs |
57
+ | Traces | The `store` passed to `createAgentRuntime` / `runAgent`; its `listThreads`, or recorded ids | Wire a store first (the `add-traces` skill) and collect runs |
34
58
  | Scenarios | A scenario list with expected outcomes, split into train and held-out | Propose a fixed split (about 70/30, every category in both); ask to confirm |
35
59
  | Vocabulary | The actors, requests, named guards, actions, decision events, and model refs the host implements | Read them off the host code; never invent names |
36
60
  | Policy history | The policy files and a `CHANGELOG.md` next to them | Create the changelog with the current policy as `v0` |
37
61
 
38
- The objective defines "better". Do not start a round until the user has confirmed it. A good objective has:
62
+ The objective defines "better". Do not start a round until the user has confirmed it, including how two versions compare when it has more than one field. A good objective has:
39
63
 
40
- - a primary metric from the goal (accuracy against expected outcomes, resolution rate),
64
+ - a primary metric from the goal (accuracy against expected outcomes and effects, resolution rate). With several, state the rule: one primary, or none worse and one better,
41
65
  - a hard constraint: invariant violations, which must be 0,
42
- - a cost term (model calls, tokens, guard rejections) used as a tie-breaker or a stated budget.
66
+ - a cost term (model calls, tokens, guard rejections) used as a tie-breaker or a stated budget. Count the calls that reach a model executor, not the environment's decision requests: a policy that answers with a rule makes no model call, and a cost term that counts requests cannot see the saving.
43
67
 
44
68
  ## 2. Read
45
69
 
@@ -48,6 +72,7 @@ Read both machines as text. `describeMachine` puts the goal first:
48
72
  ```ts
49
73
  import { describeMachine, setupAgent } from "@statelyai/agent";
50
74
 
75
+ // A TypeScript policy: import it. A JSON policy: lower it.
51
76
  const { machine: policy } = setupAgent.fromConfig(policyConfig, { compileSchema });
52
77
  const environmentText = describeMachine(environment);
53
78
  const policyText = describeMachine(policy);
@@ -58,8 +83,9 @@ Then find the training threads and compute one outcome row per episode:
58
83
  ```ts
59
84
  import { getDecisionAttempts, getUsageFromEvents, replay } from "@statelyai/agent/log";
60
85
 
61
- // Thread ids name the split and policy version: "train:v<n>:<scenario>:r<k>".
62
- const prefix = `train:v${currentVersion}:`;
86
+ // Thread ids name the split and the decider version, as add-traces set up: "train:<deciderVersion>:<scenario>:r<k>".
87
+ // With a policy, the decider version is the policy's version ("v3"); before one exists, the model's label.
88
+ const prefix = `train:${deciderVersion}:`;
63
89
  const threadIds: string[] = [];
64
90
  let cursor: string | undefined;
65
91
  do {
@@ -100,9 +126,10 @@ Group the failing training episodes by cause. Check these clusters in order:
100
126
 
101
127
  1. **Invariant violations.** Any violation is a stop: the environment is wrong, not the policy. Write a change request.
102
128
  2. **Wrong terminal state.** The episode ended in `escalated` when the label says `issued`, or the reverse. Look at which decision led there.
103
- 3. **Guard rejections.** The policy keeps proposing events the environment refuses: `rejected-by-guard` entries in the rows' `failedAttempts`. The policy lacks a check the environment already makes.
104
- 4. **Loops and budget exhaustion.** Repeated state values, or a terminal reached because a turn or retry budget ran out.
105
- 5. **Cost hotspots.** Correct episodes that spend model calls where a `choice` over known facts, or a cheap judgment, would decide.
129
+ 3. **Right terminal state, wrong effect.** The episode ended `issued` with the wrong amount, or reached the right state by a costlier path. Compare the chosen event with the offered options at each decision.
130
+ 4. **Guard rejections.** The policy keeps proposing events the environment refuses: `rejected-by-guard` entries in the rows' `failedAttempts`. The policy lacks a check the environment already makes.
131
+ 5. **Loops and budget exhaustion.** Repeated state values, or a terminal reached because a turn or retry budget ran out.
132
+ 6. **Cost hotspots.** Correct episodes that spend model calls where a `choice` over known facts, or a cheap judgment, would decide.
106
133
 
107
134
  Pick the cluster with the largest effect on the objective. Write the hypothesis down before you edit anything (template in [reference.md](reference.md)): cause, evidence (thread ids), the change, and which metric should move.
108
135
 
@@ -111,12 +138,29 @@ Pick the cluster with the largest effect on the objective. Write the hypothesis
111
138
  Make the smallest policy diff that addresses the one cause.
112
139
 
113
140
  - Typical diffs: add a `choice` branch over facts already in context; replace a model call with a judgment actor from the vocabulary; narrow an `agent.decide` `allowedEvents` list; turn an open decision into a closed choice with `payloads`; tighten a prompt; add an `onError` path; lower a model ref to a cheaper one in the vocabulary.
114
- - Use only names in the vocabulary. A named guard the host does not implement fails the `lower` gate.
115
- - Keep the config `id`. Bump `version`.
141
+ - TypeScript policy: compare facts directly in a guard or `choice` instead of adding a named guard; keep helpers in the policy file.
142
+ - JSON policy: use only names in the vocabulary. A named guard the host does not implement fails the `lower` gate.
143
+ - Keep the machine `id`. Bump `version`.
116
144
  - Keep the root `description` (the goal) unchanged. Changing the goal is the user's decision.
117
145
 
118
146
  ## 5. Gate
119
147
 
148
+ ### TypeScript policy
149
+
150
+ Run every check; a failure's output is the repair prompt. Cap repairs at 3.
151
+
152
+ 1. **Environment unchanged:** every environment file hashes as recorded. A change is never repaired; discard the candidate.
153
+ 2. **Types:** `tsc --noEmit` passes.
154
+ 3. **Lint:** `lintAgentMachine(policy)` from `@statelyai/agent/testing` reports no errors.
155
+ 4. **Paths:** for probe inputs built from recorded decision requests (`getDecisionRequests(trace, { name })` from `@statelyai/agent/testing`, one per training scenario that reaches the decision: its facts and offered options), `canReach(policy, (snapshot) => snapshot.status === "done" && !isOfferedEvent(snapshot.output), { input })` is `{ reachable: false, complete: true }`: every final output is one of the offered events. Pass `outputs` for any actor the policy invokes, and `errors: { "agent.decide": new Error("down") }` so the fallback path is checked too.
156
+ 5. **Source rules:** the policy file is still one `setupAgent` machine; its imports from the environment are only schemas, types, constants, and exported guard functions; it names no actor the environment owns. These are source checks: read the diff, or grep its imports and actor names.
157
+ 6. **Overfit:** the diff contains no literal from the scenarios (ids, ticket text, customer ids, order ids). A branch that matches one scenario's data is memorization; discard it.
158
+ 7. **Existing evals:** the rule and decision evals from `add-evals` still pass. Tests that pin measured numbers of the current version (a baseline failure list, a call count) are expected to change; update them in the same pull request and list each change in its body.
159
+
160
+ The environment's own `canReach` proofs still cover every policy, because the environment's guards judge every proposed event.
161
+
162
+ ### JSON policy
163
+
120
164
  Run `verifyAgentConfig` from `@statelyai/agent/validate` with the vocabulary and probe inputs. Probe inputs are one input per training scenario category, chosen to reach every branch the diff touches.
121
165
 
122
166
  ```ts
@@ -150,15 +194,15 @@ const failed = verified.gates.find((gate) => !gate.ok);
150
194
 
151
195
  ## 6. Replay
152
196
 
153
- Run the candidate on the training scenarios, then on the held-out scenarios. Write every replay to the store under new thread ids that name the split and version (`train:v3:<scenario>:r1`, `heldout:v3:<scenario>:r1`), so the next round can list them by prefix.
197
+ Run the candidate on the training scenarios, then on the held-out scenarios. Write every replay to the store under new thread ids that name the split and version, in the scheme `add-traces` set up (`train:v3:<scenario>:r1`, `held-out:v3:<scenario>:r1`), so the next round can list them by prefix. With no persistent store, write each thread's `result.events` to a file.
154
198
 
155
199
  - Run the current policy and the candidate on the same scenarios, the same world state, and the same model refs.
156
200
  - Model calls are nondeterministic. Run each scenario k times (k ≥ 3 when calls are not cached) and compare means.
157
201
  - Compute the objective, invariant violations, and cost per split, for both versions.
158
202
 
159
- ## 7. Promote or discard
203
+ ## 7. Recommend or discard
160
204
 
161
- State the tolerance before you replay. Promote only when every condition holds:
205
+ State the tolerance before you replay. Recommend only when every condition holds:
162
206
 
163
207
  - invariant violations are 0 on both splits,
164
208
  - the objective on the held-out split is better than the current policy's,
@@ -167,6 +211,14 @@ State the tolerance before you replay. Promote only when every condition holds:
167
211
 
168
212
  Otherwise discard the candidate and keep the current policy. Either way, append an entry to the `CHANGELOG.md` next to the policies: version, hypothesis, diff summary, gate results, before and after per split, and the decision. Record discarded candidates too; they stop the next round from retrying the same idea.
169
213
 
214
+ ### The pull request
215
+
216
+ A recommended candidate becomes one pull request (or, with no remote, one branch and a diff file), opened against the repository's integration branch:
217
+
218
+ - **The diff:** the policy file(s) and the new `CHANGELOG.md` entry; the runner's routing line, when this candidate introduces the policy; and updated pinned measurements, each listed in the body. Nothing else.
219
+ - **The body:** the hypothesis, the scorecard (objective, violations, and cost before and after, train and held-out separately, k runs each), every scenario that changed outcome, the gate results, and the thread ids a reviewer can replay.
220
+ - **One candidate per pull request.** Leave your working tree on the current policy. Later rounds build on the current policy, not on an unmerged candidate.
221
+
170
222
  ## 8. Stop and report
171
223
 
172
224
  Stop when any of these holds:
@@ -176,11 +228,11 @@ Stop when any of these holds:
176
228
  - the remaining failures need an environment change (write a change request),
177
229
  - the vocabulary lacks a capability the fix needs (ask for it; name the actor, request, or guard and its input and output).
178
230
 
179
- Report to the user: rounds run, each candidate with its hypothesis and decision, the objective before and after on both splits, violations, cost, and any change requests.
231
+ Report to the user: rounds run, each candidate with its hypothesis and decision, the pull request link (or, with no remote, the branch and diff file), the objective before and after on both splits, violations, cost, and any change requests.
180
232
 
181
233
  ## Worked example
182
234
 
183
- `examples/self-improving-refunds` in the `statelyai/agent` repo runs this loop end to end:
235
+ [`examples/self-improving-refunds`](https://github.com/statelyai/agent/blob/next/examples/self-improving-refunds) in the `statelyai/agent` repo (not in the installed package) runs this loop end to end for a JSON policy:
184
236
 
185
237
  - the refund desk is the environment: business rules as guards, and an `illegallyIssued` sentinel proven unreachable,
186
238
  - `playbooks/*.json` are policy versions, with the vocabulary listed in `playbooks/README.md`,
@@ -12,10 +12,11 @@ Templates for the `improve-machine` skill: a round checklist, the hypothesis, th
12
12
  [ ] Baseline: objective, violations, cost per split for the current version
13
13
  [ ] Failures clustered (train only); one cause picked
14
14
  [ ] Hypothesis written
15
- [ ] Candidate: smallest diff, id kept, version bumped, description unchanged
16
- [ ] verifyAgentConfig passed (repairs used: _/3)
15
+ [ ] Candidate: smallest diff, id kept, version bumped, description unchanged (or, for a new policy: v0 recorded, v1 reproduces current behavior plus one rule)
16
+ [ ] Gates passed: TS (env unchanged, tsc, lint, paths, source rules, overfit, evals) or JSON (verifyAgentConfig) (repairs used: _/3)
17
17
  [ ] Replayed train, then held-out, k runs each, new thread ids
18
18
  [ ] Promotion rule applied; CHANGELOG entry written
19
+ [ ] Recommended candidate opened as one pull request with its scorecard; not merged
19
20
  [ ] Stop condition checked
20
21
  ```
21
22
 
@@ -39,7 +40,7 @@ You improve a policy machine. You may change only the policy config below.
39
40
  {describeMachine(environment)}
40
41
 
41
42
  Current policy (version {n}):
42
- {JSON.stringify(policyConfig, null, 2)}
43
+ {the policy file's source, or JSON.stringify(policyConfig, null, 2)}
43
44
 
44
45
  Vocabulary (use no other names):
45
46
  - actors: {actors}
@@ -53,8 +54,12 @@ Objective on the training scenarios: {objective summary}
53
54
  Diagnosed cause: {hypothesis.cause}
54
55
  Evidence: {failing rows: scenario, expected, reached state, rejections, tokens}
55
56
 
56
- Return one complete AgentWorkflowConfig as JSON. Make the smallest change that
57
- addresses the diagnosed cause. Keep "id" and "description". Set "version" to {n + 1}.
57
+ Return the complete policy: the TypeScript file, or one AgentWorkflowConfig as
58
+ JSON. Make the smallest change that addresses the diagnosed cause. Keep "id" and
59
+ "description". Set "version" to {n + 1}. Never match on a scenario's data.
60
+ For a TypeScript policy, keep the result one `setupAgent` machine. Import from
61
+ the environment only schemas, types, constants, and exported guard functions.
62
+ Do not invoke tools the environment owns.
58
63
  ```
59
64
 
60
65
  On a failed gate, append:
@@ -69,11 +74,11 @@ Return a corrected config.
69
74
  ## Changelog entry
70
75
 
71
76
  ```md
72
- ## v{n} — {promoted | discarded}
77
+ ## v{n} — {recommended | discarded}
73
78
 
74
79
  - Hypothesis: {cause} → {change}
75
80
  - Diff: {states or transitions touched}
76
- - Gates: validate ✓ vocabulary ✓ lower ✓ lint ✓ explore ✓ (repairs: {r})
81
+ - Gates: {TS: environment ✓ tsc ✓ lint ✓ paths ✓ overfit ✓ evals ✓ | JSON: validate ✓ vocabulary ✓ lower ✓ lint ✓ explore ✓} (repairs: {r})
77
82
  - Train: objective {a} → {b}, violations {v}, cost {c0} → {c1}
78
83
  - Held-out: objective {a} → {b}, violations {v}, cost {c0} → {c1}
79
84
  - Regressions: {none | scenario ids}
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: loop-to-machine
3
+ description: Convert existing agent code (a while-loop around a model, a tool loop, ad-hoc if/else control flow) into an XState v6 state machine with @statelyai/agent, and prove with a parity report that it behaves like the code it replaces. Use when asked to "turn this agent into a state machine", "port my loop to xstate", "make this a statechart", "refactor this agent into a machine", or "split environment and policy" for code that already runs.
4
+ ---
5
+
6
+ # Convert agent code to a machine
7
+
8
+ You replace the control flow of working agent code with a machine, and show where the machine behaves like the old code and where it differs on purpose. The model calls, tools, and retry code move across unchanged.
9
+
10
+ ```
11
+ inventory → pin the baseline → environment → (policy) → runner → proofs → parity → hand back
12
+ ```
13
+
14
+ This is step 1 of 4. Next: `add-traces`, then `add-evals`, then `improve-machine`. To author a machine with no existing code, use `generate-machine`.
15
+
16
+ ## Hard rules
17
+
18
+ - **Pin the baseline before you change anything.** The parity test runs against the old code, which stays in the repo, untouched, until a person deletes it.
19
+ - **Never promise "behaves exactly like".** Promise what the parity report shows: same outcome and effects on these scenarios, with every difference labeled.
20
+ - **Never copy a rule break.** Where the old code broke a rule, the machine blocks it. That difference is `intended`, not a regression.
21
+ - **Rules are code, not prompt text.** A rule that lived only in a system prompt becomes a guard. Keep the prompt line only as context for the model.
22
+ - **Business rules belong to people.** If a rule is ambiguous (which threshold, which order of checks), ask. Never pick one silently.
23
+
24
+ ## 1. Inventory the code
25
+
26
+ Read the loop and fill this table before writing anything. Each row maps to a machine element.
27
+
28
+ | In the code | Becomes |
29
+ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
30
+ | A tool with a side effect someone must gate (refund, send) | An actor invoked by one state, entered only through a guarded transition |
31
+ | A read-only tool (search, lookup) | An actor the machine invokes itself, or a tool on a text request |
32
+ | A rule: an `if`, a prompt line, a check after the fact | A guard on the transition it protects |
33
+ | A model call that produces content (a draft, generated code, a summary) | A text request in `setupAgent({ requests })`, invoked by one state |
34
+ | One SDK tool call that both picks the action and carries its content (`edit_graph({ … })`, `send_email({ … })`) | A text request with those tools, then guards on the returned call: exactly one, a tool that was offered, input that passes the tool's schema. Keep it a text request: `agent.decide` would rename the tools to `send_event_<TYPE>` with the description "Choose the '<TYPE>' move.", so the model would no longer see your tool names and descriptions |
35
+ | A tool the browser runs (no `execute`; the client sends results back) | The request ends with the tool calls; an idle state waits for a `TOOL_RESULTS` event the route sends when the client posts results |
36
+ | A point where the model picks what to do | A decision state: `agent.decide`, usually a closed choice |
37
+ | A check on the model's output, then a retry or a repair call | An actor or guard that checks it, and a `choice` state that routes to the repair request, the next candidate, or a failure final |
38
+ | A value the model pulled out of the input (an order id, an amount) | Code when the format is fixed (a regex); a text request when it is not |
39
+ | A step every correct run takes in the same order (verify, then look up) | A state the machine enters itself; the model no longer chooses it |
40
+ | A person in the loop: `await approve()`, a returned `pending`, or a tool that asks a person (`askCustomer`, `requestApproval`), even a synchronous one | An idle state with `meta.interaction`, answered by `onIdle` in the runner |
41
+ | A cap (`turns < 8`, a retry counter) on a loop the machine keeps | A `choice` state against an exported constant |
42
+ | A cap on a loop the machine removed | Nothing. Record that it is gone and why |
43
+ | Each distinct return | A final state |
44
+ | A terminal tool with a side effect (`escalate`, `close`) | A state that invokes it, then the final state; its `onError` goes to a `failed` final |
45
+
46
+ List the rules as a numbered list in your notes. Every one must end as a guard, a state ordering, or a written reason it was dropped.
47
+
48
+ A loop with no decision point (generate, validate, repair) is still worth converting: the machine makes the retry cap, the validation rules, and every failure path explicit and provable. It has no `agent.decide`, so step 4 and the decision parts of step 5 do not apply.
49
+
50
+ ## 2. Pin the baseline
51
+
52
+ The parity test exists before the machine does.
53
+
54
+ 1. **Scenarios.** Reuse the code's tests, fixtures, or benchmark cases. Each has an `id` and, where people know it, the `expected` outcome. If there are none, draft 6–10 that cover every terminal and every rule, and ask the user to confirm them.
55
+ 2. **Deterministic stand-in models.** Parity with a live model drifts between runs. Use the code's scripted or mock models if it has them; otherwise write one: a function from the request (its prompt, or its `name`) to a fixed answer. ([`examples/mock-model.ts`](https://github.com/statelyai/agent/blob/next/examples/mock-model.ts) in the `statelyai/agent` repo is one, built on the AI SDK's `MockLanguageModelV3`; it is not in the package.) You want two: one that follows the rules, and one that breaks them. A seeded random model needs its seed derived from the scenario id and a fresh instance per run, or every scenario replays the same draws.
56
+ 3. **Ground truth.** Read outcomes and effects from the world, not from the agent's own report. `effects` holds **every write the world records**: the refunds, and also the approvals, the verifications, the questions asked, the escalations. A refund issued without its approval looks correct if `effects` holds only refunds. Text a model wrote (an escalation reason) differs on every run: compare its count or presence, not the text. If the world can check rules after the fact, return those violations. If it cannot, write the check as a plain function over the effects.
57
+ 4. **A baseline with no function to call.** When the loop lives inline in a framework handler (a tRPC procedure, a route), call the handler itself in the test, with its model client and storage replaced by the stand-ins (a module mock, or the framework's test caller). Do not extract the loop into a function first: that changes the code you are trying to pin.
58
+ 5. **The harness check.** Run the old code on both sides with each stand-in model. Each row's baseline must equal its candidate (the runs are deterministic), and with the rule-following model every row must be `same`. With a rule-breaking model, rows are `regression` by design: the candidate breaks rules.
59
+
60
+ ```ts
61
+ import { compareRuns, type ParityResult, type ParityRun } from "@statelyai/agent/testing";
62
+
63
+ function toParityRun(run: { outcome: string; world: World }): ParityRun {
64
+ return {
65
+ outcome: run.outcome,
66
+ // Every write the world records; model-written text as a count.
67
+ effects: { refunds: run.world.refunds, approvals: run.world.approvals, verified: [...run.world.verified], escalations: run.world.escalations.length },
68
+ violations: run.world.violations, // rules broken, from ground truth
69
+ };
70
+ }
71
+
72
+ // A model factory, called once per scenario and per side. Construct a stateful or seeded
73
+ // model inside it (seeded from s.id); returning a shared stateless object is fine.
74
+ const runWith = (runner: typeof runLoop, model: (s: Scenario) => Model) => async (s: Scenario) =>
75
+ toParityRun(await runner({ ...s, model: model(s), world: createWorld(s) }));
76
+
77
+ const MODELS: Array<[string, (s: Scenario) => Model]> = [
78
+ ["careful", () => carefulModel],
79
+ ["sloppy", () => sloppyModel],
80
+ ];
81
+
82
+ test.each(MODELS)("harness: the loop matches itself (%s)", async (_, model) => {
83
+ const report = await compareRuns({ scenarios: SCENARIOS, baseline: runWith(runLoop, model), candidate: runWith(runLoop, model) });
84
+ for (const row of report.scenarios) expect(row.candidate, row.id).toEqual(row.baseline);
85
+ });
86
+ ```
87
+
88
+ ## 3. Author the environment
89
+
90
+ The environment is the machine: states, guards that encode the rules, side-effecting actors, an invariant sentinel. Use `setupAgent` (from `@statelyai/agent`) with zod schemas for `input`, `context`, `output`, and every event. Export the event schemas as one object, so a policy can reuse them later.
91
+
92
+ A gated action:
93
+
94
+ ```ts
95
+ deciding: {
96
+ tags: ["decision"],
97
+ invoke: {
98
+ src: "agent.decide",
99
+ input: ({ context }) => ({
100
+ model: "decider", // a key the host's executors resolve
101
+ name: "chooseResolution", // tests, traces, and a later policy find it by this name
102
+ prompt: `Ticket: ${context.ticket}\nOrder: ${JSON.stringify(context.order)}`,
103
+ allowedEvents: ["REFUND", "ESCALATE", "CLOSE"],
104
+ // A closed choice: the decider picks one of these. The runtime drops the ones the guards refuse.
105
+ payloads: { REFUND: refundAmounts(context).map((amount) => ({ amount })) },
106
+ // The facts a decider reads. Arrives as `request.metadata.facts`.
107
+ metadata: { facts: { order: context.order, requestedAmount: context.requestedAmount } },
108
+ }),
109
+ onError: { target: "escalating" }, // retries exhausted, or no option legal
110
+ },
111
+ on: {
112
+ REFUND: ({ context, event }) =>
113
+ refundIsLegal(context, event.amount)
114
+ ? { target: event.amount > APPROVAL_THRESHOLD ? "awaitingApproval" : "paying", context: { amount: event.amount } }
115
+ : undefined, // undefined rejects: the decision retries with feedback
116
+ // `escalate` and `close` are tools with side effects: states that invoke them, then the final states.
117
+ ESCALATE: { target: "escalating" },
118
+ CLOSE: { target: "closing" },
119
+ },
120
+ },
121
+ ```
122
+
123
+ - **Do the deterministic steps yourself.** Identity, lookup, the ownership check: states the machine runs in order. A model only answers what genuinely needs judgment.
124
+ - **Export the guards** as plain functions (`refundIsLegal`) so tests can call them, and build the machine in a factory (`createDeskMachine({ refundIsLegal })`) so a test can pass a loosened guard.
125
+ - **Add an invariant sentinel** for the rule that must never break: a final state with a description starting `SENTINEL:`, entered from an `always` on the state that invokes the side effect (`paying`). An `always` is taken before the state's invoke starts, so the effect never runs. Write the check as its own function, not a call to the guard, so loosening the guard does not loosen the check.
126
+ - **A person's step** is an idle state whose `meta.interaction` tells a host what to render: `{ label: "Approve a refund of ${amount}?", events: { APPROVE: { label: "Approve" }, REJECT: { label: "Reject" } } }`, or `textEvent: { type: "CUSTOMER_REPLIED", field: "reply" }` for free text.
127
+ - **Actors are placeholders.** Declare each tool as `createAsyncLogic<Output, Input>({ run })` from `xstate` in `setupAgent({ actors })` (XState v6 has no `fromPromise`). Give the type parameters, or `onDone`'s `output` is untyped. Pass the real implementations at run time with `runAgent(machine, { actors })`.
128
+ - **Final outputs carry the machine's result** (`{ outcome, orderId, amount }`), JSON only. The runner rebuilds the old return type from it and from the world (step 5).
129
+ - **Declare `version`** on `createMachine` (`version: "1"`). Behavior lives in transition functions, which the structural hash cannot see; traces stamp this string.
130
+
131
+ ## 4. A policy, only if the code already has one
132
+
133
+ Keep the decision in the environment as one `agent.decide`, and do not write a policy machine, unless the old code already has decision logic beyond one model call: a rule ladder, heuristics that pick a strategy, a cheap check before the model. A policy that only passes the options through to a model is boilerplate. `improve-machine` introduces a policy when the traces show a rule worth adding.
134
+
135
+ If the code does have that logic, port it as a policy machine that answers `chooseResolution`. The pattern, with code that compiles, is in [policy.md](policy.md).
136
+
137
+ ## 5. The runner
138
+
139
+ Write `runMachine` with the old function's signature and result type, so callers and the parity test change one line.
140
+
141
+ - **The existing SDK call becomes the executor.** Write `generateText` (or `streamText`) in `executors` as the old call, with the same model, system prompt, tools, and options, and return `{ result }`. The request still needs `prompt` or `messages` in `setupAgent({ requests })` (`setupAgent` throws `invalid-request` otherwise): pass the conversation as `messages: ({ input }) => input.messages` and read `request.messages` in the executor.
142
+ - **A streaming route keeps its response, not its function signature.** `createAiSdkExecutors().streamText` forwards text chunks only, so a UI stream with tool calls cannot come out of it. Write the `streamText` executor around the old call, pipe its UI stream to the route's response yourself, and return the final messages as `{ result }`.
143
+
144
+ - `runAgent(machine, { input, executors, actors, onIdle })`.
145
+ - `onIdle` answers each idle state with the old code's call (`tools.askCustomer(...)`, `tools.requestApproval(...)`). Write the returned events with `as const` on `type`, or the event union does not narrow.
146
+ - `executors.decide` adapts the code's model to the decision: the closed choice arrives as `request.options` (each `{ event }`), and the facts as `request.metadata?.facts`, typed `unknown`: parse it with the exported facts schema. Every executor slot is optional in the type; check or assert the ones you call.
147
+ - In an `onError` function, the error is `event.error`: `onError: ({ event }) => ({ target: "failed", context: { error: String(event.error) } })`. There is no `error` argument.
148
+ - Make input and events JSON-safe at the boundary: no `Date`, `Map`, class instances, or functions (fields set to `undefined` are dropped). With a store, a value the log cannot hold fails the run; without one, the log stops with a warning.
149
+ - Rebuild the old result from `result.output` and the world. A field that has no meaning anymore (a turn count with no loop) keeps its type and gets a comment saying what it now holds.
150
+
151
+ ## 6. Proofs that need no model
152
+
153
+ From `@statelyai/agent/testing`:
154
+
155
+ - `lintAgentMachine(machine)`: no errors.
156
+ - `canReach(machine, "<sentinel>", options)` returns `reachable: false` **and** `complete: true`, for each distinct input shape (an input with an order id and one without lead to different paths; loop over them).
157
+ - `options.payloads` is a function of the snapshot that returns payload lists per event type. List values on both sides of every guard boundary: `0`, the threshold and one over, and each order amount the outputs below can produce and one over. Without it, a closed choice is explored only with its own `payloads`, and `complete: true` then proves nothing about the boundaries.
158
+ - A free-text event from a person's step (`CUSTOMER_REPLIED`) needs an entry too, one payload per branch it can take, or the search cannot send it.
159
+
160
+ ```ts
161
+ const proofOptions: ExplorePathsOptions = {
162
+ payloads: () => ({
163
+ REFUND: [0, 40, 41, 100, 101, 240, 241, 800, 801].map((amount) => ({ amount })),
164
+ CUSTOMER_REPLIED: [{ reply: "It's ORD-1001" }, { reply: "no idea" }],
165
+ }),
166
+ outputs: { lookupOrder: [ownedOrder, otherCustomersOrder, refundedOrder, { found: false }] },
167
+ errors: { lookupOrder: new Error("down"), "agent.decide": new Error("exhausted") },
168
+ };
169
+ ```
170
+ - `options.outputs` lists every output each actor can return (found and not found, owned and not, refunded and not); `options.errors` makes each actor fail once.
171
+ - **A machine with a loop** (a repair cycle, a resubmit) needs `options.dedupeBy`, or the search runs to `maxDepth` and reports `complete: false`. Return every context field a guard or choice reads: `dedupeBy: (s) => JSON.stringify([s.value, s.context.attempts, s.context.lastError])`. `outputs` are canned per actor src, not per call: to explore "fails twice, then succeeds", let the guard read a counter from context instead of the output.
172
+ - `explorePaths(...).reachedStates` lists only states a run settles in; a `choice` state it passes through is not listed.
173
+ - A loosened guard makes `canReach` true. Write this test too, or the proof proves nothing.
174
+ - Each normal final state is reachable, so the search is not vacuous.
175
+
176
+ Type the options with `ExplorePathsOptions`, and a `payloads`, `dedupeBy`, or predicate callback's snapshot with `ExploreSnapshot`, both from `@statelyai/agent/testing`. Do not use `AnyMachineSnapshot` from your app's own `xstate`, which may be another version. `errors` takes one error per actor src, and `"agent.decide"` is a valid key.
177
+
178
+ ## 7. Parity
179
+
180
+ Switch the parity test's candidate to `runMachine`, and run it with each stand-in model:
181
+
182
+ ```ts
183
+ test.each(MODELS)("parity with the loop: %s", async (_, model) => {
184
+ const report = await compareRuns({ scenarios: SCENARIOS, baseline: runWith(runLoop, model), candidate: runWith(runMachine, model) });
185
+ const unreviewed = report.scenarios.filter((row) => row.label === "regression" && !REVIEWED[row.id]);
186
+ expect(unreviewed).toEqual([]);
187
+ // A reviewed difference still has to be exactly the difference a person accepted.
188
+ for (const row of report.scenarios.filter((row) => REVIEWED[row.id])) REVIEWED[row.id]!(row);
189
+ });
190
+
191
+ // Effect-only differences a person accepted: who, and the exact difference.
192
+ const REVIEWED: Record<string, (row: ParityResult<Scenario>) => void> = {
193
+ // "no-order-id-no-reply": (row) => { /* accepted by <name>: asks once, not three times */ expect(row.candidate.outcome).toBe(row.baseline.outcome); ... },
194
+ };
195
+ ```
196
+
197
+ - Every `regression` row: fix the machine, or, when the old behavior was wrong but broke no rule, ask the user. If the outcomes differ, the answer may become the scenario's `expected`. If only effects differ (the loop asked three times, the machine once), list the row in a reviewed-differences list in the test, with the exact difference asserted and the person who accepted it. Never edit a scenario to make a row pass.
198
+ - Every `intended` row: check that its reason is a real rule. A candidate that fixed a rule break with the same outcome is `intended` too.
199
+ - Run once with a live model if a key is available. Report it as one sample, not a guarantee.
200
+
201
+ ## 8. Hand back
202
+
203
+ Report, in this order:
204
+
205
+ 1. The files: environment, runner, tests (and a policy, if the code had one). The old code is untouched.
206
+ 2. The rule list from step 1, each mapped to its guard, its state, or the reason it was dropped.
207
+ 3. The parity table per stand-in model: `same / intended / regression`, and one line per `intended` row with its reason.
208
+ 4. The `canReach` results and the loosened-guard test.
209
+ 5. Anything you had to decide that the user should confirm.
210
+ 6. Next step: `add-traces`.