# Nucleic — Grok Build Adapter (Implementation Plan, v0)

Plan for adding **Grok Build** (xAI's terminal coding agent) as a third `AgentBackend`
alongside Claude Code and the (stubbed) Codex adapters. This is a planning doc, not a shipped
adapter; it follows the same structure and rigor as [ADAPTERS](ADAPTERS.md) and is meant to be
the spec the implementation works against.

**Confidence key:** ✅ documented/stable · 🟡 observed/SDK-derived, pin before shipping ·
🔴 inferred, **a live-capture spike must record a real sample** (the M0 methodology —
see [M0_RESULTS](M0_RESULTS.md)). Treat 🔴/🟡 as the validation backlog, not as fact.

---

> ## ⚑ Update (2026-06-21): shipped on ACP, not streaming-json + hooks
>
> This doc was written for `grok -p --output-format streaming-json` with a generated **PreToolUse
> hook** bridging approvals over HTTP (Tier A, below). **That design has been replaced.** Grok now
> ships [`agent` mode — `grok agent stdio` — which is **ACP** (the Agent Client Protocol): standards
> JSON-RPC 2.0 over stdio, baked into the binary]. So the Grok backend is now a near-twin of the
> **Codex app-server** adapter, not of Claude:
>
> - Transport: the shared [`JSONRPCConnection`](../Sources/NucleicCore/JSONRPCConnection.swift)
>   (the same one Codex uses; ACP sets `includeVersionHeader: true`). No more streaming-json decoder.
> - Approvals: native `session/request_permission` server→client *requests* — **the entire
>   `/grok-hook` HTTP bridge, the generated `.grok/` PreToolUse script, and the inferred
>   streaming-json event vocab are gone.** No more 🔴-inferred wire format here; ACP is a published
>   schema.
> - Implemented in [`Sources/NucleicCore/Grok/`](../Sources/NucleicCore/Grok): `GrokACPBackend`,
>   `GrokACPDecoder` (`session/update` → `AgentEvent`), `GrokACPDecisionMapping` (`Decision` → ACP
>   permission outcome). Tests: `GrokACPDecoderTests`, `FakeGrokContractTests` (a full ACP
>   round-trip against the `fake-grok` stub); fixtures under `fixtures/grok/acp/`.
>
> The §3 mapping table, §4 approval tier, and §6.1 status below are updated to ACP; the rest of the
> doc is retained as historical context for the original streaming-json plan.

---

## 0. Verdict — clean fit

The codebase was designed for this. There is a documented
[`AgentBackend`](../Sources/NucleicCore/Backend.swift) protocol, a `BackendID` enum that already
anticipates multiple agents
([`claudeCode`/`codex`/`codexExec`](../Sources/NucleicProtocol/CoreIdentifiers.swift)), a
per-session `BackendFactory` seam in `AppStore`, per-project `defaultBackend`
([Project.swift](../Sources/NucleicCore/Project.swift)), and an [ADAPTERS](ADAPTERS.md) doc whose
thesis is "wildly different wire formats converge by design." Codex is already stubbed in the enum
and Settings as "coming soon"; Grok follows the same paved path.

Grok Build is unusually convenient because it deliberately mirrors Claude Code's CLI surface
(`grok -p --output-format streaming-json`, `--permission-mode default|plan|acceptEdits|…`, MCP
servers, PreToolUse hooks, reads `CLAUDE.md` natively, `--session` resume). The *shape* of the
integration is therefore nearly identical to [`ClaudeCodeBackend`](../Sources/NucleicCore/Claude/ClaudeCodeBackend.swift).
The differences are in the wire details — and those details are the validation backlog (§2).

The net-new code is small and localized: **one stream decoder + one backend actor + one approval
endpoint + UI/catalog/factory glue.** The hard infrastructure (process host, approval suspension,
conflict locks, containers, sync, persistence, transcripts) is backend-agnostic and reused
unchanged.

---

## 1. What Grok Build provides (confirmed from xAI + community docs)

- ✅ Headless: `grok -p "<prompt>" --output-format streaming-json` — runs once, prints, exits
  nonzero on refusal/tool failure.
- ✅ Permission modes: `--permission-mode {default, dontAsk, acceptEdits, bypassPermissions,
  plan}` — the same vocabulary as Claude Code; `[ui] permission_mode = ask|always-approve` in
  `~/.grok/config.toml` for persistent use.
- ✅ **PreToolUse hooks** (`.grok/hooks/pre_tool.sh`, `.grok/hooks.json`): execute at agent
  lifecycle events, **receive event JSON on stdin and return JSON on stdout, and can block/deny**
  a tool call — even when auto-approve is on. This is the interactive-approval seam.
- ✅ Tool-gating order: PreToolUse hooks → policy rules (deny > ask > allow) → built-in fast
  paths → prompt policy.
- ✅ MCP server support (`/mcps`, config) so the agent can call internal APIs, not just shell.
- ✅ Auth: `XAI_API_KEY=xai-…` env, or `grok auth login` (browser OAuth for SuperGrok); config
  under `~/.grok` (`config.toml`).
- ✅ Resume: conversations persist; `--session latest` / `--session <id>`.
- ✅ Reads existing `CLAUDE.md` (and `AGENTS.md`) project context natively — migration-friendly.
- 🟡 streaming-json event vocab is **xAI's own**, observed as `session.start`, `model.thinking`,
  `tool.call`, `tool.result`, `model.message`, `session.end` — **not** Anthropic's
  `{"type":"assistant","message":{content:[…]}}` envelope. Exact per-event fields must be pinned.

Sources (captured 2026-06-20): xAI "Introducing Grok Build", Mervin Praison "Grok Build CLI",
aimadetools "Grok Build Complete Guide", ContentBuffer "Headless Agentic Coding", xAI Enterprise
Docs.

---

## 2. Validation backlog (🔴 — a live capture must settle these before adapter code)

The same five unknowns that the M0 spike settled for Claude, restated for Grok:

1. **streaming-json line schemas** — the exact field names inside each event
   (`session.start`/`model.message`/`model.thinking`/`tool.call`/`tool.result`/`session.end`),
   tool-call id + input shape, file-change signal, token-usage and cost reporting, and whether
   partial/delta events exist (the equivalent of Claude's `--include-partial-messages`).
2. **PreToolUse hook wire contract** — the exact JSON Grok writes to the hook's **stdin**, and
   the exact JSON it expects back on **stdout** to allow / deny / (possibly) modify a tool call.
   *This is the single most important unknown.* It decides whether we get full interactive
   approvals (Tier A, §4) or fall back to a fixed policy (Tier B).
3. **stdin follow-ups** — whether headless `grok -p` accepts streamed follow-up user turns on
   stdin (Claude's `--input-format stream-json`) or is strictly single-shot-per-process. (Claude
   is run single-shot-per-turn today and resumed via `--resume`; Grok will likely use the same
   model regardless.)
4. **resume flag** — exact form (`--session <id>` vs `--resume`/`--continue`), and how the
   resumable session id surfaces in the stream (so we can capture it into
   `SessionStarted.backendSessionID`).
5. **sandbox auth seeding** — where `grok auth login` stores credentials under `~/.grok`, so a
   containerized run can export them (the Grok analog of Claude's Keychain → `.credentials.json`
   dance in [`SessionController.containerSpec`](../Sources/NucleicCore/SessionController.swift)).

**Deliverable of the spike:** golden native streams under `fixtures/grok/<version>/` (see §8) and
a §6 addendum here that retags each item ✅ with verbatim samples, plus the Tier A/B decision.

---

## 3. Architecture mapping

Everything converges on the same `AgentBackend` → `AgentEvent` stream →
`ApprovalCoordinator.await` seam. **As shipped (ACP), the Grok adapter is a sibling of
`CodexAppServerBackend`** (both are JSON-RPC stdio backends whose approvals arrive as
server→client requests):

| Concern | Codex app-server (today) | Grok ACP (shipped) |
| --- | --- | --- |
| Invocation | `codex app-server` | `grok agent stdio` |
| Transport | `JSONRPCConnection` (header omitted) | `JSONRPCConnection` (`includeVersionHeader: true`) |
| Stream decode | `CodexAppServerDecoder` (thread/turn/item) | **new** `GrokACPDecoder` (`session/update` variants) → same `AgentEvent` kinds |
| Approval bridge | `…/requestApproval` request → `ApprovalCoordinator` | `session/request_permission` request → same `ApprovalCoordinator` |
| Approval reply | `{decision}` | `{outcome:{selected,optionId}}` (`GrokACPDecisionMapping`) |
| Resume | `thread/resume {threadId}` | `session/load {sessionId}` (iff `agentCapabilities.loadSession`) |
| Interrupt | `turn/interrupt {threadId,turnId}` | `session/cancel {sessionId}` |
| Auth | inherited | `authenticate {methodId}` if `authMethods` non-empty; else `grok` login (`~/.grok`) / `XAI_API_KEY` |

> _Historical (original plan): Grok was to be a sibling of `ClaudeCodeBackend` —
> `grok -p --output-format streaming-json --permission-mode default` decoded by a
> `GrokStreamDecoder`, with a generated PreToolUse hook POSTing to the in-process MCP server.
> ACP superseded this; see the update note at the top._

**Reused unchanged:** [`ProcessHost`](../Sources/NucleicCore/ProcessHost.swift),
[`ApprovalCoordinator`](../Sources/NucleicCore/ApprovalCoordinator.swift), the HTTP host inside
[`MCPApprovalServer`](../Sources/NucleicCore/Claude/MCPApprovalServer.swift),
[`RiskClassifier`](../Sources/NucleicCore/RiskClassifier.swift),
[`ContainerManager`](../Sources/NucleicCore/Container), `WorktreeManager`, the conflict/lock
system, transcripts, sync, and persistence. The new code is the decoder, the backend actor, the
approval-callback endpoint, and the catalog/UI/factory glue.

---

## 4. The pivotal decision — approval tier

> **Shipped resolution (ACP): neither tier — native permission requests.** The hook-vs-fixed-policy
> tradeoff below was moot once Grok shipped ACP: approvals arrive as native
> `session/request_permission` server→client *requests*, answered with a `{outcome:{selected,
> optionId}}` result (`GrokACPDecisionMapping`) — full interactive approvals
> (`interactiveApprovals: true`) with no hook and no HTTP bridge. `canModifyToolInput` is `false`
> (ACP's permission outcome can't rewrite tool input). The tier analysis below is historical.

This determines scope. **Target the interactive hook bridge (Tier A), with a non-interactive
fallback (Tier B) for v1 if the spike forces it.**

- **Tier A — interactive (target).** A generated PreToolUse hook posts each gated tool call to
  Nucleic's existing per-session, bearer-token-gated local HTTP server. The handler suspends on
  `ApprovalCoordinator.await` (emitting `AgentEvent.approvalRequested`), and the hook script
  emits the allow/deny JSON Grok expects on stdout. This gives Grok sessions the *same* Mac/iPhone
  approval UX as Claude → `interactiveApprovals: true`. Feasible because Grok hooks are explicitly
  blocking and JSON-in/JSON-out (§1). Paired with `--permission-mode default`.
- **Tier B — non-interactive fallback.** If the hook contract cannot carry a synchronous human
  allow/deny, run Grok like [`CodexExecAdapter`](ADAPTERS.md): `ApprovalPolicy.fixed` rules +
  `--permission-mode acceptEdits`/`plan`, `interactiveApprovals: false`, fail-closed for anything
  unmatched. Lower fidelity but ships, and matches a path the protocol already models. Add a
  `BackendID.grokExec` if both tiers end up coexisting.

Everything downstream of `ApprovalCoordinator.await` is identical either way — the tier only
changes the plumbing that feeds it.

---

## 5. Phased implementation plan

### Phase 0 — Validation spike (de-risk before the adapter)
Mirror the proven M0 methodology. Install `grok`, authenticate, run real headless turns against a
throwaway repo. Extend [`nucleic-spike`](../Sources/nucleic-spike/Spike.swift) (or a `grok-spike`
target) to capture: a simple turn, a tool-approval turn, a multi-file-edit turn, an interrupt, and
a resume. Record golden native streams under `fixtures/grok/<version>/` and retag §2 with verbatim
samples. **Settle Tier A vs B here.**

### Phase 1 — Protocol & identifiers (tiny, backward-compatible)
- Add `case grok` (and `grokExec` if Tier B is needed) to
  [`BackendID`](../Sources/NucleicProtocol/CoreIdentifiers.swift). Persistence already round-trips
  `BackendID` by rawValue and falls back to `.claudeCode` on unknown
  ([GRDBMetadataStore](../Sources/NucleicCore/Persistence/GRDBMetadataStore.swift)), so old DBs and
  the iOS client tolerate the new case for free.
- `AgentEvent.backend` and the sync wire
  ([WireMessages](../Sources/NucleicProtocol/Sync/WireMessages.swift)) carry it automatically — the
  phone gets Grok-labeled sessions with no protocol change.

### Phase 2 — `GrokStreamDecoder`
- New `Sources/NucleicCore/Grok/GrokStreamDecoder.swift`, structurally like
  [`ClaudeStreamDecoder`](../Sources/NucleicCore/Claude/ClaudeStreamDecoder.swift) but decoding
  xAI's vocab → the existing `AgentEvent.Kind`s: `session.start`→`sessionStarted`,
  `model.message`→`assistantText`, `model.thinking`→`thinking`,
  `tool.call`→`toolCallStarted`/`toolCallCompleted` (+ synthesize `fileChange` from
  `write_file`/edit tools, à la `inferredFileChange`), `tool.result`→`toolResult`,
  `session.end`→`turnCompleted`/`runFinished` + `usage`.
- Lenient by contract: unknown lines → `.raw` (the CLI-drift early warning). Unit-tested against
  the Phase 0 fixtures (decode → assert exact `AgentEvent[]`).

### Phase 3 — `GrokBuildBackend`
- New `Sources/NucleicCore/Grok/GrokBuildBackend.swift`, an actor mirroring `ClaudeCodeBackend`:
  - `static let id = BackendID.grok`; `capabilities` set from spike findings
    (`interactiveApprovals` per Tier; `nativeResume: true`; `emitsFileChangeEvents` per schema;
    `sandboxModes` likely `[]` initially).
  - `Configuration` with `executable: "grok"`, read-only tool pre-allow, single-shot-per-turn
    stdin handling (the model Claude already uses).
  - Build args: `-p --output-format streaming-json --permission-mode default` (no `--model` or
    `--reasoning-effort` — Grok Build is one model with Auto reasoning), generated hook/MCP config
    path, resume via `--resume <id>`. Reuse the same `ProcessHost`,
    stderr-tail, optional raw capture, and exit-synthesis logic.
  - **Approval endpoint (Tier A):** add a hook handler to `MCPApprovalServer` that accepts Grok's
    hook POST and returns its allow/deny JSON, suspending on `ApprovalCoordinator` exactly like
    `handleApprovalCall`. Generate the `.grok/hooks` script + config at run start, pointed at
    `http://127.0.0.1:<port>` with the per-session bearer token (and the VM gateway host for
    containerized runs, mirroring the Claude path).
  - Reuse the same `ConflictCoordinator` arbitration in the approval path so Grok sessions
    participate in the lock system + Nucleic Control autoship just like Claude.

### Phase 4 — Auth & env
- **Host-spawned (default):** Grok auth is inherited naturally — host `~/.grok` login or
  `XAI_API_KEY` in the environment. Add a blank-key guard symmetric to the existing
  `ANTHROPIC_API_KEY` purge in [`NucleicApp` init](../Sources/NucleicApp/NucleicApp.swift) only if
  the spike shows a blank `XAI_API_KEY` causes the same 401-preference problem.
- **Sandboxed (defer for v1):** generalize
  [`SessionController.containerSpec`](../Sources/NucleicCore/SessionController.swift) to seed a
  Grok-home (`~/.grok`) and forward `XAI_API_KEY`/config-dir — the Grok analog of the claude-home
  seeding. Reasonable to ship Grok **host-only first** and add container support in a follow-up.
  Settings already has a per-agent auth model
  ([`ControlAuthMode`](../Sources/NucleicCore/Project.swift)) to extend.

### Phase 5 — Backend-aware model catalog
- [`ModelCatalog`](../Sources/NucleicApp/ModelCatalog.swift) is Claude-only and global. Refactor
  to key models/efforts/context-windows by `BackendID` (or add a parallel `GrokModelCatalog`):
  a single `grok-build` SKU with one "Auto" reasoning level. The home composer's model/effort pickers must
  switch catalogs based on the selected backend. Grok uses `--permission-mode` modes rather than
  Claude's `--effort`; the generic `RunSpec.effort`/`model` fields stay and each backend
  translates appropriately.

### Phase 6 — Wiring & UI
- **Factory dispatch:** change the
  [`backendFactory` closure](../Sources/NucleicApp/NucleicApp.swift) (and the AppStore call sites)
  to switch on `session.backend` → `ClaudeCodeBackend` or `GrokBuildBackend`. Today it ignores
  `session` and always returns Claude.
- **Agent picker:** `defaultBackend` exists on `Project` but has no selector yet. Add (a) a
  project-settings backend picker, and (b) ideally a per-chat agent picker in the home composer.
  `AppStore` already resolves `project.defaultBackend ?? .claudeCode` at session creation.
- **Settings:** replace the
  [Codex "coming soon" placeholder](../Sources/NucleicApp/SettingsView.swift) pattern with a real
  "Grok" section (default model, permission/auto mode, `XAI_API_KEY`/login status), mirroring the
  Claude section.
- **Branding:** the lavender-accent gating (Nucleic Control only) is orthogonal; Grok sessions
  render with the standard chrome plus a backend label/icon.

### Phase 7 — Testing
- **`fake-grok` stub:** add `Sources/fake-grok/` analog of
  [`FakeClaude`](../Sources/fake-claude/FakeClaude.swift) that replays fixture streams and honors
  hook callbacks, so backend tests run hermetically with no real `grok` binary (point
  `Configuration.executable` at it, as Claude tests do).
- Decoder unit tests (fixtures → `AgentEvent[]`), backend integration tests
  (run/approve/resume/interrupt), and an approval round-trip test asserting the exact hook-reply
  bytes Grok accepts. Build/test with `swift test --build-system native` (the iCloud codesign
  workaround).

### Phase 8 — Quota / polish (optional)
- [`SubscriptionUsage`](../Sources/NucleicCore/SubscriptionUsage.swift) /
  [`QuotaIndicator`](../Sources/NucleicApp/QuotaIndicator.swift) are Claude-OAuth-specific. Gate
  the indicator off for Grok sessions until/unless xAI exposes an equivalent usage endpoint.

---

## 6. Spike findings (to be filled in by Phase 0)

_Placeholder. After the live capture, retag each §2 item ✅ here with verbatim native samples and
record the Tier A/B decision, exactly as [M0_RESULTS](M0_RESULTS.md) did for Claude._

### 6.1 Implementation status — adapter landed ahead of the spike (🟡/🔴 pending validation)

> **Superseded by the ACP migration (2026-06-21).** Everything in this subsection describes the
> original streaming-json + PreToolUse-hook adapter, which has been **replaced** by the ACP backend
> (`GrokACPBackend`/`GrokACPDecoder`/`GrokACPDecisionMapping`; see the update note at the top of
> this doc and §3/§4). The 🔴-inferred streaming-json/hook unknowns below no longer apply — ACP is a
> published schema and the wire shapes are pinned to it. Retained as historical context.

The Tier-A adapter is implemented against the inferred schemas so the wiring, decoder, approval
bridge, and tests exist and pass hermetically; the §2 unknowns remain the validation backlog and
are **centralized so a real capture is a local re-pin**, not a rewrite:

- **Phase 1 — identifiers.** `BackendID.grok` added (`forModel("grok-*") → .grok`); persistence,
  sync wire, and `StatusFeed`/`xAI` failover route it automatically.
- **Phase 2 — decoder.** [`GrokStreamDecoder`](../Sources/NucleicCore/Grok/GrokStreamDecoder.swift)
  maps the inferred `session.*`/`model.*`/`tool.*` vocab → `AgentEvent`s, lenient (`.raw` on
  drift). Golden fixtures under `fixtures/grok/0.1.0/`; unit tests in
  `Tests/NucleicCoreTests/GrokStreamDecoderTests.swift`. **🔴 the per-event field names are
  inferred** — re-record from a live capture and re-assert.
- **Phase 3 — backend.** [`GrokBuildBackend`](../Sources/NucleicCore/Grok/GrokBuildBackend.swift),
  an actor mirroring `ClaudeCodeBackend`. **Tier A** approvals: a generated PreToolUse hook
  ([`GrokHookConfig`](../Sources/NucleicCore/Grok/GrokHookConfig.swift)) POSTs to the new
  `MCPApprovalServer` `/grok-hook` route, which suspends on the same `ApprovalCoordinator`. Joins
  the conflict-lock system like Claude. **Discovery is project-local `.grok/`** (✅ §1) — written
  into the worktree, git-excluded, restored/removed at teardown — NOT a CLI flag (an earlier
  `--hooks-config` guess made `grok` exit 2) and NOT a config-dir env (would relocate `~/.grok`
  and break auth). **🔴 the hook stdin/stdout contract remains inferred** (Claude-Code-compatible);
  re-pin after the spike.
- **Phase 4 — auth.** Blank-`XAI_API_KEY` purge added to `NucleicApp` init (host-only v1).
- **Phase 5 — catalog & effort.** A single `grok-build` SKU ("Grok Build") is offered; the picker
  doubles as the backend selector. **Grok Build is one model with a single "Auto" reasoning mode**,
  so the "Reasoning" menu offers only **Auto** (plus Orchestra below the divider, an orchestration
  mode rather than a Grok level). The backend passes neither `--model` nor `--reasoning-effort` —
  the SKU/effort the UI carries are cosmetic for Grok. *(Superseded the earlier plan to drive
  `--reasoning-effort none|low|medium|high`, which xAI has since collapsed to Auto.)*
- **Phase 6 — wiring.** Factory dispatches `.grok → GrokBuildBackend`; Settings names Grok Build.
- **Phase 7 — tests.** `fake-grok` stub replays a streaming-json fixture and performs the real
  `/grok-hook` round-trip; `FakeGrokContractTests` covers allow + deny end-to-end.

**Resolved against the real CLI (`grok --help`, captured 2026-06-21):**
- **Invocation** — headless is `grok -p/--single "<prompt>"` (prints to stdout and exits);
  `--output-format streaming-json` ✅ (`plain|json|streaming-json`).
- **Resume** — `-r/--resume [<id>]` ✅ (the `--session` guess was wrong and would have been
  rejected; fixed).
- **Permission mode** — `--permission-mode` accepts `default|acceptEdits|auto|dontAsk|
  bypassPermissions|plan` ✅; v1 uses `dontAsk` (headless auto-approve; the PreToolUse hook,
  which runs first in the gating order, stays the human gate).
- **Effort** — Grok Build is a single model with one **"Auto"** reasoning mode (xAI picks the
  depth itself), so the adapter passes no reasoning flag at all (and no `--model`). The "Reasoning"
  menu offers only Auto; Orchestra is still offered as an orchestration mode. *(Earlier captures
  showed `--effort`/`--reasoning-effort` flags; xAI has since collapsed Grok Build to Auto.)*
- **Hook discovery** — project-local `.grok/` (no flag); verify with `grok inspect`.

**Still open (a streaming-json capture settles these):** the exact `session.*`/`model.*`/`tool.*`
field names and the PreToolUse hook stdin/stdout contract (both decoded/emitted speculatively);
whether `dontAsk` actually lets the hook gate (the Tier-A assumption); the headless background
wait (`--background-wait-timeout` defaults to 600s — a turn that leaves a persistent monitor can
appear to hang; `--no-wait-for-background` is the escape hatch); real model IDs (`grok models`);
and sandbox auth seeding (deferred — host-only).

---

## 7. Effort & risk

- **Net-new code is small and localized:** one decoder, one backend actor, one approval endpoint,
  plus catalog/UI/factory glue. The hard infrastructure is reused untouched.
- **Backward/forward compatible:** the only protocol change is one enum case, which old DBs and
  the iOS client already tolerate.
- **Biggest risk = the PreToolUse hook contract (Tier A).** If the spike shows it can't carry a
  synchronous human decision, fall back to Tier B (`ApprovalPolicy.fixed`, like CodexExec) — still
  ships, lower fidelity.
- **Suggested first PR:** Phases 0–3 + 6–7, host-only, behind the per-project backend picker.
  Sandbox (Phase 4 container seeding) and quota (Phase 8) as follow-ups.

---

## 8. Fixtures harness

Same shape as [ADAPTERS §4](ADAPTERS.md):

```
fixtures/grok/<version>/
  01-simple-turn.jsonl          + 01-simple-turn.events.json     (golden AgentEvent[])
  02-tool-approval.jsonl        + 02…events.json  + hook-call.json / hook-reply.json
  03-file-change.jsonl          + …
  04-resume.jsonl               + …
```

Decoder tests decode the recorded native stream → assert exact `AgentEvent[]` equality, and (for
approvals) assert the reply bytes the CLI accepts. Re-recording on a CLI upgrade is the
protocol-drift regression signal.
