diff --git a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md
index 742367cec8..07adae41a2 100644
--- a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md
+++ b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md
@@ -237,9 +237,9 @@ already-emitted terminal stay graceful.
### What shipped for 020
-Tool-result images reach Cursor as real `McpImageContent`. The final design differs
-from the original plan in three ways, each forced by a review that proved the plan
-would have broken a working request:
+The Cursor ENCODER now emits tool-result images as real `McpImageContent`. The final
+design differs from the original plan in three ways, each forced by a review that
+proved the plan would have broken a working request:
1. **Bounding is post-serialization, not a byte budget.** A step is one blob shared
with the call's arguments, text, and framing, so `toolCallStep` serializes and
@@ -263,3 +263,43 @@ measurement cycle in `030` stands, and the phase closes NOOP unless the user
supplies a failing case. Note the probe did not isolate the top-level freeform
surface — the cursor agent reached `apply_patch` through code mode — so this is
"not reproduced", not "proven absent".
+
+## Final status — all phases
+
+| Phase | Outcome | Verified |
+|-------|---------|----------|
+| `010` clean-EOF terminal | **SHIPPED** `54f68daf5` | 7 rounds; lidge 608/0 |
+| `020` tool-result images | **SHIPPED** `878b067e8..cc906b0fc` | 5 rounds; byte-equality verified twice; lidge 624/0 |
+| `030` xai apply_patch | **NOT REPRODUCED** | live probe: both providers used `apply_patch` |
+| `040` server-side cancel | **SHIPPED** `f145fd513..c9681d043` | lidge 630/0 |
+| `050` terminal-less turns + #422 | **SHIPPED** `aa800ae65..1651002c5` | 8 rounds; lidge 12800/0 across 830 files |
+
+Final full suite at `1651002c59`: **12800 pass / 0 fail**, typecheck clean.
+Pre-campaign baseline was 12761/0 across 826 files.
+
+### What the campaign actually found
+
+The user's report was "Computer Use keeps disconnecting mid tool call". The decode
+found three distinct ways a Cursor turn could lose work, and the last one turned
+out not to be Cursor-specific at all:
+
+1. A clean stream EOF dropped an open tool call and reported success (`010`).
+2. Every screenshot reached the model as placeholder text, though the wire had
+ always supported images (`020`).
+3. A cancel Cursor sent us was indistinguishable from one we sent, so the turn
+ vanished entirely (`040`).
+4. Underneath all three: the bridge reported a turn with no terminal as
+ `completed` — and on a compaction turn installed its partial output as
+ replacement history (`050`). That one affected every provider.
+
+The xai `apply_patch` symptom did not reproduce when probed live, so no code was
+written for it.
+
+### Remaining follow-ups
+
+- **Kiro** `completionMode: "disabled"` and **ordinary Google mode** erase
+ truncation reasons before the bridge can act (`050`). Adapter-side, each its
+ own unit.
+- **User-message images** are still placeholdered although `SelectedImage`
+ supports blob/inline data (`002`). Separate capability.
+- **`030`** stays open as a measurement cycle pending a reproducible failing case.
diff --git a/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md b/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md
index d7c3b55b94..f4a889e77b 100644
--- a/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md
+++ b/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md
@@ -124,3 +124,28 @@ adapter directory)
All nine pass, typecheck clean, cursor suite green on `ssh lidge`, pushed.
+## POST-SHIP CORRECTION (audit r1, 2026-08-18)
+
+This phase shipped the ENCODER, and the encoder is correct. It does **not** deliver
+the end-to-end capability, and the earlier wording in `000_index.md` overstated it.
+
+Every Cursor model is listed in `noVisionModels`
+(`src/providers/registry.ts:978-982`), so before the adapter ever runs, the vision
+sidecar replaces image parts with text descriptions — or strips them fail-closed
+when no sidecar plan exists (`src/server/responses/core.ts:2225-2243`). That
+preprocessing explicitly covers `toolResult` messages
+(`src/vision/index.ts:252-259`, replacement at `:565-581`).
+
+`tests/cursor-tool-result-image.test.ts` calls `encodeCursorRunRequest` directly
+with hand-built `rawMessages`, so it proves encoder support and nothing about the
+production path.
+
+The registry comment claiming "Cursor's wire protocol never forwards image parts"
+is now half-stale: still true for USER images (`request-builder.ts:206-214`
+flattens them), no longer true for tool results.
+
+Closing this gap is its own unit, not a wording fix: dropping Cursor from
+`noVisionModels` alone would leave user images with neither an image nor a
+description. It needs role-aware vision policy plus an end-to-end regression
+through the server path. Tracked as follow-up 1 in
+`devlog/_plan/260818_cursor_call_integration/000_plan.md`.
diff --git a/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md
new file mode 100644
index 0000000000..08397649fe
--- /dev/null
+++ b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md
@@ -0,0 +1,171 @@
+# 050 — Phase 5: a buffered turn that never terminated is not "completed"
+
+The last follow-up recorded in `000_index.md`. Deferred from `010` pending
+evidence; that evidence now exists and is worse than the note assumed.
+
+## Measured, not assumed
+
+`buildResponseJSON` defaults to `"completed"` whenever no error or incomplete
+event is present (`bridge.ts:1830-1834`). Probed directly against the current
+tree:
+
+| Adapter events | Result |
+|----------------|--------|
+| `[text]`, no terminal | `status: "completed"`, no `incomplete_details` |
+| `[tool_call_start, tool_call_delta("{\"code\":\"tru")]` | `status: "completed"` with a `function_call` item, `status: "completed"`, `arguments: "{\"code\":\"tru"` |
+| same + `tool_call_end` | `status: "failed"`, item `status: "incomplete"` |
+
+The second row is the defect. A truncated tool call — invalid JSON, never closed —
+is handed back as a **successful** turn containing an apparently complete
+function call. A caller that trusts `status` will try to execute it.
+
+The third row is the same bridge, on the same arguments, getting it right. The
+rejection logic already exists (`bridge.ts:1070-1080`); it is reached only when
+an explicit `tool_call_end` arrives. When the stream simply stops, nothing runs
+it.
+
+## Why this is in scope
+
+`010` and `040` both closed *adapter-side* routes to this shape: a truncated EOF
+and a server cancel now raise typed errors, so those paths no longer reach the
+buffered default. This phase closes the default itself, which is what makes the
+guarantee hold for any adapter that ends a stream without a terminal — including
+future ones nobody has audited.
+
+## Scope warning
+
+`src/bridge.ts` is shared by **every** provider. This phase therefore:
+
+- changes only the no-terminal case, leaving every explicit `done`/`error`/
+ `incomplete` path byte-identical;
+- runs the **full** suite on `ssh lidge`, not the cursor subset. A baseline full
+ run at `6d97442839` is captured before the change so any new failure is
+ attributable.
+
+## Contract
+
+| Buffered turn | Status |
+|---------------|--------|
+| explicit `done` | `completed` — unchanged |
+| explicit `error` | `failed` — unchanged |
+| `incomplete` / `max_tokens` / `content_filter` | `incomplete` — unchanged |
+| no terminal, no open tool call | `incomplete`, `incomplete_details.reason = "adapter_eof"` |
+| no terminal, tool call left open | `incomplete`, and the item is **not** `completed` |
+
+Streaming already reports `adapter_eof` for the fourth row (`bridge.ts:1283`), so
+this aligns the buffered path with the streaming one rather than inventing a new
+signal.
+
+## Diff-level plan
+
+**`src/bridge.ts`**
+
+- In `buildResponseJSONWithBudget`, track whether any adapter terminal
+ (`done`/`error`/`incomplete`) was observed.
+- When none was, resolve `status` to `"incomplete"` with
+ `incomplete_details: { reason: "adapter_eof" }`, matching the streaming path's
+ wording exactly.
+- An unclosed tool call must not carry item `status: "completed"`. Reuse the
+ existing incomplete-item marking rather than adding a second notion of
+ "unfinished".
+- Do not touch `stopReason` handling, usage reporting, or compaction.
+
+## Tests (`tests/bridge-nonstreaming-terminal.test.ts`)
+
+1. Text with no terminal -> `incomplete` + `adapter_eof`, not `completed`. Red today.
+2. Open tool call with truncated arguments and no `tool_call_end` -> turn is not
+ `completed` and the item is not `completed`. Red today; this is the executable-
+ garbage case.
+3. Parity: the same events through streaming and buffered agree on terminal
+ status. This is the assertion that keeps the two paths from drifting again.
+4. Explicit `done` -> still `completed` (regression).
+5. Explicit `error` -> still `failed`; explicit `incomplete` -> still `incomplete`
+ with its own reason preserved (regression).
+
+## Done when
+
+All five pass, `bun run typecheck` clean, and the **full** suite on `ssh lidge`
+matches the pre-change baseline. Tests 1 and 2 demonstrated red beforehand.
+
+
+## Shipped, and what the audit chain changed
+
+Five review rounds. The plan's core claim survived; nearly every detail did not.
+
+| Commit | What it closed |
+|--------|----------------|
+| `aa800ae65` | The default itself: a buffered turn with no adapter terminal is `incomplete`/`adapter_eof`, and an open tool call is no longer emitted as a completed `function_call` with half-written JSON. |
+| `44fde398b` | The #422 compaction guard could only see explicit failure events, so a terminal-less turn still installed replacement history. |
+| `f73f09c9e` | Streaming emitted the compaction item *before* reading `stopReason`; Google's `parseResponse` dropped `finishReason` entirely. |
+| `95f73db17` | `stopReason` is an open-ended string and adapters disagree (`length`, `refusal`); canonical-only matching left the guard bypassable. |
+| `71730023a` | **My own regression:** suppressing the item without downgrading the turn produced `completed` with zero compaction items — the shape codex-rs fatals on. Suppression and status now come from one decision. |
+| `ea5e61677` | Anthropic `pause_turn` is unfinished by definition; AI SDK `error` is a failure, so Command Code emits a real error terminal instead of a stop reason. |
+
+The lesson worth keeping: each round fixed the previous round's fix. Round 4 found
+that my round-3 change had made things *worse* in one direction — a suppressed
+compaction item with a success status is more dangerous than the bug it replaced,
+because codex-rs treats zero items as fatal. Widening a guard without widening
+what it reports is not a partial fix; it is a new failure.
+
+## Open follow-ups (adapter-side, deliberately not folded in)
+
+Both erase truncation metadata **before** the bridge can defend anything, so they
+cannot be fixed here:
+
+- **Kiro** (`kiro.ts:1315`, `:1485`): in `completionMode: "disabled"` — which routed
+ compaction selects, because it removes tools — the normalized reason is observed
+ and then the final `done` omits `stopReason`. `MAX_TOKENS` and
+ `MODEL_CONTEXT_WINDOW_EXCEEDED` both vanish.
+- **Google ordinary mode** (`google.ts:779`, `:947`): only `MAX_TOKENS` and five safety
+ values are forwarded. `MALFORMED_RESPONSE`, `UNEXPECTED_TOOL_CALL`, `IMAGE_SAFETY`,
+ and `LANGUAGE` become reasonless `done` events. The Vertex/CCA fail-closed guard
+ covers only part of this.
+
+Each is its own unit with its own truncation subsystem. Recording them beats
+half-fixing them inside a bridge phase.
+
+## Final state (eight rounds)
+
+| Commit | Closed |
+|--------|--------|
+| `ea5e61677` | Anthropic `pause_turn` is unfinished by definition; AI SDK `error` is a failure, so Command Code emits a real error terminal. |
+| `6478cbb02` | Removing `error` from the shared table was safe only for Command Code — Anthropic forwards `stop_reason` verbatim, so it needed its own error terminal on the buffered and streaming paths. Both terminals carry usage. |
+| `1651002c5` | Anthropic has a **third** terminal path: the EOF branch for providers that close after `message_delta` without `message_stop` bypasses `emitDone` entirely and still reported success. |
+
+Round 8: **PASS, no findings.** Verified exhaustively that Anthropic's terminal
+paths are `message_stop`/`emitDone`, the compatible-provider EOF branch, and
+buffered `parseResponse` — and that `anthropicEofTolerance` is not a fourth,
+since it runs only when no stop reason was received and delegates back through
+`emitDone`.
+
+Final verification on `ssh lidge` at `1651002c59`: typecheck clean,
+**12800 pass / 0 fail** across 830 files. The pre-phase baseline was 12761/0.
+
+### Two tests that were protecting nothing
+
+Worth recording, because both looked like coverage:
+
+- The Anthropic streaming test was titled "carrying usage" and never asserted
+ usage. Removing usage from the error terminal kept it green.
+- The Command Code coverage hand-constructed the downstream error event instead
+ of driving the parser, so it stayed green while the adapter still emitted a
+ clean `done`.
+
+Both now drive the real parsers, and dropping usage from either error terminal
+turns the suite red.
+
+### What the round count actually bought
+
+Eight rounds, and the finding that justified them arrived at round 7 — after two
+rounds had already declared the area closed. Two of the defects were regressions
+I introduced while fixing the previous round's finding:
+
+1. Suppressing the compaction item without downgrading the turn produced
+ `completed` with zero compaction items, which codex-rs treats as fatal. A
+ half-widened guard was more dangerous than the bug it replaced.
+2. Removing `error` from the shared table fixed Command Code and silently
+ re-opened Anthropic, which forwards the same string verbatim.
+
+Both share a shape: a fix that is correct for the case in front of you and wrong
+for the one next to it. That is the argument for auditing revisions, not just
+first drafts.
diff --git a/devlog/_plan/260818_cursor_call_integration/000_plan.md b/devlog/_plan/260818_cursor_call_integration/000_plan.md
new file mode 100644
index 0000000000..aaf292b057
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/000_plan.md
@@ -0,0 +1,144 @@
+# 000 — Integrate cursor-call onto dev and reach release-ready state
+
+## Objective
+
+Land the `cursor-call` tool-call hardening campaign on the current `dev` head, then
+carry it to a release-ready state. The campaign shipped against `f64c0639`
+(merge-base); `dev` has moved 100+ commits since, and two of the source files we
+changed were changed there too — one of them for the SAME defect, in the opposite
+shape.
+
+**Moving-ref discipline (audit `r2` finding 2).** `dev` advances during this work:
+it was `87f7f970b` (+104) when the collision sweep ran and `e1bdbc1e5` (+124) a few
+minutes later. Every count below is therefore pinned to an immutable SHA, and the
+rebase re-reads `origin/dev` immediately before running rather than trusting a
+number written here. The COLLISION SET is what matters, and it was re-checked at
+the later head: still the same two paths.
+
+This unit is the integration record, not a re-decode. The decode unit is
+`devlog/_plan/260817_cursor_toolcall_decode/`.
+
+## Audit history
+
+Round `r1-20260818030046` returned **FAIL** with 6 findings; every one was verified
+against the tree and every one was accepted. See `005_audit_r1.md`. The findings
+changed the work-phase map (a new WP for the usage regression, gates moved BEFORE
+the merge, the stack claim replaced) — they were not absorbed as wording tweaks.
+
+## Evidence base
+
+| Fact | Command |
+|------|---------|
+| merge-base = `f64c06391` (immutable) | `git merge-base origin/dev cursor-call-prerebase-260818` |
+| snapshot `cursor-call-prerebase-260818` = `fe2237038`, **31 commits** (immutable) | `git rev-list --count ..cursor-call-prerebase-260818` |
+| `cursor-call` is a MOVING ref: 32 commits at `66b9df9ef`, 34 at `be1b881ec`, and it keeps growing as this unit is written | `git rev-list --count ..cursor-call` |
+| `origin/dev` is a MOVING ref: +104 at `87f7f970b`, +124 at `e1bdbc1e5` | `git rev-list --count ..origin/dev` |
+| snapshot touches **28** paths (18 source/test + 10 devlog) (immutable) | `git diff --name-only cursor-call-prerebase-260818` |
+| only 2 of the 28 were touched on dev — re-verified at `e1bdbc1e5` | per-path `git log --oneline ..origin/dev -- ` |
+
+The earlier draft said "18 files" while listing a devlog wildcard row; `r1` finding 6
+was right. 18 is the source+test count; 28 is the full path count.
+
+## Collision inventory (all 28 paths)
+
+18 source/test paths:
+
+| File | dev commits | Collision |
+|------|-------------|-----------|
+| `src/adapters/cursor/live-transport.ts` | 3 (`6a64db19d`, `08eb65d1f`, `1824a0148`) | **SEMANTIC** — same defect, opposite shape |
+| `src/adapters/google.ts` | 6 (`aca3c0241`, `0be660a2e`, `f6c88febf`, `812255d3a`, `d62cc4029`, `343e5d7a3`) | **TEXTUAL** — identity work; our hunk drifts 939 → 946 |
+| `src/adapters/anthropic.ts` | 0 | none |
+| `src/adapters/command-code.ts` | 0 | none |
+| `src/adapters/cursor/cursor-errors.ts` | 0 | none |
+| `src/adapters/cursor/native-exec.ts` | 0 | none |
+| `src/adapters/cursor/protobuf-request.ts` | 0 | none |
+| `src/adapters/cursor/request-builder.ts` | 0 | none |
+| `src/bridge.ts` | 0 | none |
+| `src/responses/truncated-stop-reason.ts` | 0 (absent on dev — we add it) | none |
+| `tests/anthropic-error-stop-reason.test.ts` | 0 | none |
+| `tests/bridge-nonstreaming-terminal.test.ts` | 0 | none |
+| `tests/command-code-error-finish.test.ts` | 0 | none |
+| `tests/cursor-cancel-provenance.test.ts` | 0 | none |
+| `tests/cursor-eof-terminal.test.ts` | 0 | none, but its EXPECTATION changes (see `010`) |
+| `tests/cursor-request-builder.test.ts` | 0 | none |
+| `tests/cursor-tool-result-image.test.ts` | 0 | none, but its COVERAGE is insufficient (see `005` F1) |
+| `tests/google-buffered-stop-reason.test.ts` | 0 | none |
+
+Plus 10 `devlog/_plan/260817_cursor_toolcall_decode/*` docs, zero dev commits.
+
+An INDIRECT-breakage sweep, run twice (once by the collision investigator, once
+adversarially in `r1`), found no compile break. `AdapterEvent.done.stopReason`
+still exists (`src/types.ts:366-387`), the Cursor tool-definition exports we
+reference are intact, and the adapter factory signature is compatible. **The real
+upstream hazard `r1` found is not a renamed import — it is request PREPROCESSING
+(finding F1).**
+
+## Loop-spec
+
+- Loop archetype: verifier-defined (typecheck + full suite on lidge decide done).
+- Write scope: the 18 source/test paths above, plus this unit. `src/vision/index.ts`
+ and `src/providers/registry.ts` are **out** of scope: audit `r2` finding 3 is right
+ that a conditional clause letting WP2b expand into vision policy contradicts the
+ explicit deferral of F1. WP2b is about EOF usage and authorizes nothing in vision.
+ No version bump, no npm publish, no `main` promotion.
+- Tool/credential scope: local git, `ssh lidge` for verification, `gh`/GitHub app
+ for PRs and the merge. Push to `origin/cursor-call` is pre-approved
+ (`--no-verify`); force-push is inherent to the requested rebase and the snapshot
+ branch is the recovery path.
+- Bounds: no stated token budget. Wall-clock dominated by the lidge suite (~8 min).
+ CI is NOT checked (user waived) — but see `005` F2 for what that waiver can and
+ cannot license.
+
+## Work-phase map (one phase = one full PABCD cycle)
+
+| WP | Doc | Slice | Depends on |
+|----|-----|-------|------------|
+| wp1-integration-roadmap | this unit + `005` | conflict inventory, audit absorption, roadmap (docs-only) | — |
+| wp2-rebase | `010` | rebase with evidence-based conflict resolution | wp1 |
+| wp2b-eof-usage | `015` | **NEW (r1 F3):** the surviving EOF error event must carry partial usage | wp2 |
+| wp3-remote-verify | `020` | typecheck + full suite + privacy:scan + audit:high on lidge | wp2b |
+| wp4-prs | `030` | PR(s) targeting `dev`, topology-honest, template filled | wp3 |
+| wp5-merge | `040` | merge onto `dev` + ancestry proof, with the governance position stated | wp4 |
+| wp6-release-gates | `050` | gates re-run on merged `dev` + go/no-go note | wp5 |
+
+`r1` F1 (Cursor tool-result images stripped upstream by the vision sidecar) is
+**NOT** folded into this integration. It is a real defect and it makes one
+capability claim in the decode unit's 020 overstated, but fixing it means changing
+vision preprocessing policy — a different subsystem, a different blast radius, and
+a decision about ordinary user images too. It is recorded in `005` and appended as
+a follow-up work-phase candidate, and the overstated claim gets corrected in the
+decode unit's docs. Landing a rebase does not make it worse.
+
+## Accept criteria (mirrored into the goalplan)
+
+- `c1-roadmap-unit` — this unit with research + diff-level decade docs.
+- `c2-conflict-inventory` — the 28-path table, produced by the named commands.
+- `c3-rebase-clean` — rebase lands, no conflict markers, dev head is an ancestor.
+- `c4-resolution-audited` — every resolution passes an adversarial audit round.
+- `c5-remote-green` — typecheck + full suite green on lidge at the SHA.
+- `c6-prs-open` — PR(s) against `dev` matching the ACTUAL topology, template filled.
+ (Revised by `r1` F4: "stacked" is no longer required if the history does not
+ support an honest split.)
+- `c7-merged-on-dev` — `git merge-base --is-ancestor` proves it, not an API reply.
+- `c8-release-gates` — privacy:scan, audit:high, typecheck, full suite green.
+- `c9-go-no-go` — a written note on whether to cut a version.
+- `c10-eof-usage` — **NEW:** the EOF truncation error carries partial usage, with a
+ regression test that fails before the fix.
+
+## Out of scope (carried follow-ups, NOT this unit)
+
+1. **Cursor vision preprocessing (`r1` F1)** — all Cursor models are in
+ `noVisionModels` (`src/providers/registry.ts:978-982`), so
+ `describeImagesInPlace`/`stripImagesInPlace` replaces tool-result images with
+ text before the adapter runs (`src/server/responses/core.ts:2225-2243`,
+ `src/vision/index.ts:252-259,565-581`). The 020 encoder work is correct but
+ currently unreachable in production.
+2. Kiro `completionMode: "disabled"` drops `stopReason` (`kiro.ts:1315`, `:1485`).
+3. Google ordinary mode forwards only `MAX_TOKENS` + five safety values; four
+ other reasons become reasonless `done` (dev `google.ts:786-795`).
+4. User-message images still flattened (`request-builder.ts:206-214`).
+5. Phase 030 (xai apply_patch) remains NOT REPRODUCED. New information: dev landed
+ `bc229433a` + `8a4040384`, which stop the code-mode guidance from forbidding a
+ separately-advertised top-level `apply_patch` — the same affordance surface 030
+ suspected, fixed independently on dev. Re-probing needs a user-supplied failing
+ case.
diff --git a/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md b/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md
new file mode 100644
index 0000000000..b487eeee49
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md
@@ -0,0 +1,135 @@
+# 005 — Audit round r1-20260818030046: FAIL, 6 findings, all accepted
+
+Reviewer: independent sol/high agent, read-only, told to break the plan before any
+rebase step ran. Verdict **FAIL**. Every finding was re-verified against the tree
+before absorption; none was waved through and none was argued away.
+
+## F1 (High) — Cursor tool-result images never reach the encoder in production
+
+**Verified.** The chain:
+
+1. `src/providers/registry.ts:978-982` puts **every** Cursor model in
+ `noVisionModels`, with the comment "Cursor's wire protocol never forwards image
+ parts (request-builder emits an unsupported-content marker), so the vision
+ sidecar covers ALL cursor models."
+2. `src/server/responses/core.ts:2225-2243` runs the sidecar before the adapter:
+ `describeImagesInPlace` when a plan exists, else `stripImagesInPlace` fail-closed.
+3. `src/vision/index.ts:252-259` — `carriesImages()` explicitly includes
+ `toolResult`. `:565-581` replaces each image part with a text description.
+4. `tests/cursor-tool-result-image.test.ts:52-77` calls `encodeCursorRunRequest`
+ directly with hand-built `rawMessages`, so it never crosses that preprocessing.
+
+So the 020 encoder work is correct in itself and **unreachable in production**. The
+registry comment it was built against is now half-stale: the request-builder marker
+is still true for USER images, but no longer true for tool results.
+
+**Disposition: accepted, and explicitly NOT fixed in this unit.** The fix is a
+capability-policy change spanning `src/providers/registry.ts` and
+`src/vision/index.ts`, and the reviewer is right that simply dropping Cursor from
+`noVisionModels` is unsafe because user-message images are still flattened at
+`src/adapters/cursor/request-builder.ts:206-214` — the model would then get
+neither the image nor a description. It needs role-aware preprocessing plus an
+end-to-end regression through the server path.
+
+Two things this unit DOES do about it: record it as follow-up 1 in `000`, and
+correct the overstated capability claim so no later doc reads as if the capability
+shipped end-to-end.
+
+## F2 (High) — the merge bypasses a gate `MAINTAINERS.md` requires
+
+**Verified.** `MAINTAINERS.md:48-49`: "A pull request requires approval from at
+least one maintainer and successful required CI checks before merge."
+`AGENTS.md:251-253` makes `MAINTAINERS.md` authoritative over `AGENTS.md`.
+
+The user waived CI checking and granted admin merge. That is the repository owner
+exercising owner authority, which is a real thing — but it does not make the merge
+*policy-satisfying*, and lidge is Linux-only while CI covers Linux, Windows, and
+macOS. Windows-sensitive surfaces are exactly where this repository has been bitten
+before.
+
+**Disposition: accepted as a stated governance exception, not as compliance.**
+`040` now records: (a) the user's waiver is the authority for merging without CI;
+(b) the merge is therefore an owner-authorized exception; (c) the platform gap is
+named — Linux-only evidence; (d) the readiness note in `050` must not claim
+"policy-compliant release-ready", only "gates green on Linux, CI waived by owner".
+A release-readiness claim that hides this is the failure mode.
+
+## F3 (Medium) — the surviving EOF shape drops partial usage
+
+**Verified, and this one needs code.**
+
+- Thrown path: `attachPartialUsage` (`live-transport.ts:1195-1199`) puts
+ `partialUsage` on the error, and `src/adapters/cursor.ts:181-192` copies it into
+ the emitted `error` event.
+- Event path: `finalizeTurnEvents` returns
+ `[{ type: "error", message }]` with **no usage**
+ (`protobuf-events.ts:1361-1372`), even though `CursorServerMessage`'s error
+ variant carries `usage?: OcxUsage` (`src/adapters/cursor/types.ts:44-48`) and
+ `resolvedTurnUsage(state)` is right there at `:1340`, already used by the `done`
+ branch at `:1376`.
+
+So choosing dev's shape (correct on its own merits) would silently trade away
+usage reporting on truncated turns. That is a real regression, not a style point.
+
+**Disposition: accepted. New work-phase `wp2b-eof-usage`, doc `015`.** The EOF
+error gets `usage: resolvedTurnUsage(state)`, with a regression test that fails
+before the change.
+
+## F4 (Medium) — the three-PR stack cannot be formed at clean boundaries
+
+**Verified.** `git log --oneline --reverse ..cursor-call` shows ten devlog
+commits before the first code commit, then docs interleaved after each
+implementation phase (`dfb6fb884`, `6d9744283`, `3f5bf955d`, `f10108315`,
+`fe2237038`, `66b9df9ef`). A "PR1 = adapter code only, PR3 = all devlog" split
+requires reordering or cherry-picking, which makes it not a stack of this history.
+
+The reviewer also caught a verification hole: `020` verifies only the final tip,
+while `030` planned to reuse that evidence for every layer. `AGENTS.md:178-180`
+wants each non-trivial PR verified.
+
+**Disposition: accepted. `030` is rewritten** to open ONE PR from `cursor-call`
+to `dev`, with the reasoning recorded, and criterion `c6` is reworded from
+"stacked PRs" to "PR(s) matching the actual topology". The user asked for a stacked
+PR; the honest answer is that this history is one linear chain and a fabricated
+split would be less reviewable, so the plan says so out loud instead of
+manufacturing three PRs whose contents do not match their titles.
+
+## F5 (Medium) — gates sequenced after the merge, and `audit:high` missing
+
+**Verified.** `scripts/release.ts:374` runs `bun run audit:high` and `:380` runs
+`bun run privacy:scan`; `package.json:52` shows `prepush` runs typecheck, gui
+lint, test, and privacy:scan. Deferring privacy:scan until after the merge means a
+PR could be merged with it red.
+
+**Disposition: accepted.** `020` now runs `privacy:scan` and `audit:high` BEFORE
+the PR, `030` requires the docs determination before ticking the template box, and
+`050` re-runs the gates on merged `dev` as confirmation rather than as first
+contact.
+
+## F6 (Low) — the inventory's counts were wrong
+
+**Verified.** The snapshot touches 28 paths, not 18; the table collapsed ten devlog
+files into one row while the prose said "all 18 files". And `cursor-call` is now 32
+commits (the plan commit itself), so `origin/dev..cursor-call` no longer returns 31.
+
+**Disposition: accepted.** `000` now states 28 paths (18 source/test + 10 devlog)
+and 32 commits, and distinguishes the snapshot ref from the moving branch.
+
+## What survived the attack
+
+Recorded because a surviving claim is evidence too:
+
+- No compile break from dev's changes to `src/types.ts`, `tool-definitions.ts`,
+ `tool-catalog-nudge.ts`, `parser.ts`, `router.ts`, `core.ts`, `registry.ts`.
+- A thrown `CursorStreamTruncatedError` would NOT cause a retry: retry needs no
+ emitted event, an uncommitted request, and a transient error
+ (`transport-retry.ts:92-105`), and the request is committed on HTTP/2 connect.
+ So "the event shape loses a useful retry" is not a reason to keep the throw.
+- The literal merged EOF block uses the right variables and preserves dev's guard
+ ordering; `|| this.emittedTerminal` swallows no dev-covered case.
+- `CursorStreamTruncatedError` becomes dead code after the import is dropped, but
+ compiles.
+- The Google patch location is right: `parseResponse` at `:812`, `candidates` in
+ scope from `:894`, insertion after the truncation guard is safe.
+- GUI lint/build N/A for a source-only diff is reasonable.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md b/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md
new file mode 100644
index 0000000000..648c50c6ca
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md
@@ -0,0 +1,108 @@
+# 006 — Audit round r3-20260818032759: FAIL, 5 findings, all accepted
+
+Fresh reviewer (r1/r2's reviewer was retired; a final gate must not reuse a
+contaminated one). Verdict **FAIL**: every CODE resolution passed, and the
+integration/release WORKFLOW had five defects.
+
+Round `r2` was aborted as inconclusive — its reviewer produced a NEAR-PASS report
+but exited without the CLI recording a verdict, and an unrecorded verdict is not a
+verdict (REVIEW-BINDING-01). Its three findings were already absorbed at
+`2ea12062d`; `r3` re-verified them independently and they held.
+
+## What passed (recorded, because a survived claim is evidence)
+
+- **Rebase executability.** Every identifier in `010`'s literal block exists with
+ compatible types, and the guard order preserves dev's precedence:
+ incomplete-frame → zero-frame → existing terminal/expected close → open-tool
+ truncation → assistant-text synthesis
+ (`origin/dev:src/adapters/cursor/live-transport.ts:1016-1051`). dev's EOF tests at
+ `tests/cursor-hardening.test.ts:396-600` stay consistent.
+- **WP2b.** Moving `partialUsageFromEventState` down creates no cycle;
+ `protobuf-events.ts` already has `OcxUsage`, the state fields, and
+ `resolvedTurnUsage` (`:1340`). Reporting proven token consumption is correct even
+ though the tool call was never committed.
+- **Consumer trace.** `message-mapper.ts:29` forwards error usage; the bridge
+ reports it only on the failed response and suppresses compaction history
+ (`bridge.ts:1228-1253`, `:1800-1860`).
+- **Collision set.** `fe2237038` is 31 commits / 28 paths (18 source+test, 10
+ devlog). Live and local `origin/dev` both `e1bdbc1e5`; still only
+ `live-transport.ts` and `google.ts` collide. No new collision.
+- **Hidden dev behavior.** dev's parser, replay, tool-catalog-nudge, code-mode, and
+ registry changes contradict none of the campaign's eight test files.
+- **Governance wording.** Accurately an owner-authorized exception, not compliance
+ (`MAINTAINERS.md:48-50`), and the no-Windows-surface claim is true of the 28-path
+ diff.
+
+## F1 (High) — the tested `dev` base was not pinned through merge
+
+`dev` moves, and it moved twice during planning. `020` verified only the rebased
+branch SHA, and `040`'s ancestry check runs *after* the merge. So GitHub could
+construct a merge result nobody tested and put it on `dev`, with WP6 discovering it
+afterwards.
+
+**Accepted.** `020` now records `VERIFIED_BASE` from
+`git ls-remote origin refs/heads/dev` — the LIVE head, following
+`scripts/release.ts:327-335`, which uses `ls-remote` precisely because the local
+tracking ref goes stale. `040` re-reads it before EVERY merge in the stack and stops
+if it moved.
+
+## F2 (High) — remote verification could destroy unrelated work
+
+Both remote phases ran `git checkout -f` in the shared `~/Developer/opencodex`
+checkout without proving it clean. That silently discards tracked uncommitted work —
+in a phase that calls itself "verification only".
+
+**Accepted.** `020` and `050` now use a dedicated `git worktree add /tmp/ocx-*`
+and never touch the shared checkout's HEAD. `git worktree list` on lidge already
+shows a dozen `/tmp/ocx-*` verification worktrees, so this is that host's existing
+pattern, not a new invention.
+
+## F3 (Medium) — an honest three-PR stack DOES exist
+
+`r1` killed a fabricated adapter/bridge/docs split, and `030` over-corrected to one
+PR. `r3` showed the campaign's own phase boundaries are already clean commits, with
+no reordering needed:
+
+| PR | Range | Commits |
+|----|-------|---------|
+| 1 | `..dfb6fb884` | 17 — Cursor EOF + tool-result wire |
+| 2 | `dfb6fb884..6d9744283` | 3 — unexpected CANCEL (depends on `emittedTerminal`) |
+| 3 | `6d9744283..HEAD` | 15 — bridge/adapter terminals + integration docs |
+
+Verified: PR1's file set is the Cursor wire files plus decode docs; PR2's is
+`cursor-errors.ts`, `live-transport.ts`, its provenance test, and `040_*.md`; PR3's
+is the bridge/adapter files. The dependency is real, not decorative — PR2's guard
+reads PR1's `emittedTerminal`, and PR3's bridge logic is what makes PR1/PR2's error
+events reportable.
+
+**Accepted, and it restores what the user asked for.** `030` is rewritten as a real
+stack; `020` adds a per-layer verification table because `AGENTS.md:178-180` wants
+each layer's own evidence, not the tip's borrowed. Criterion `c6` goes back to
+requiring a stack.
+
+The lesson: `r1` was right that THAT split was fake, and `030` drew the wrong
+conclusion from it — "no honest split exists" instead of "that split was the wrong
+one". Absorbing a finding is not the same as absorbing its narrowest reading.
+
+## F4 (Medium) — `build:gui` is not N/A for a readiness claim
+
+`050` skipped it because no `gui/` path changed. But `prepublishOnly`
+(`package.json:49`) runs `audit:high`, `typecheck`, and `build:gui` on **every**
+publish regardless, and `build:gui` also runs `prepare:package` (`:46-47`).
+
+**Accepted.** `build:gui` moves into both `020` and `050`. `lint:gui` stays N/A
+with evidence. `050` must also state that publication additionally requires a
+successful Cross-platform CI run AND a successful Service lifecycle run at the exact
+release SHA (`scripts/release.ts:393-401`) — otherwise a "go" reads as if publishing
+were one command away.
+
+## F5 (Medium) — the release baseline was already stale
+
+`050` said `origin/main = 474584bcd`; it is `0013b2347`. `v2.24.2` the TAG still
+points at `474584bcd`, which is a different fact.
+
+**Accepted.** `050` now requires reading `git ls-remote`, `npm view dist-tags`, and
+`gh release list` at write time, and states both the tag target and the `main` tip
+rather than conflating them. Same root cause as F1: this plan cannot cache a moving
+ref.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md b/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md
new file mode 100644
index 0000000000..65cae5d601
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md
@@ -0,0 +1,92 @@
+# 007 — Audit round r4-20260818033946: FAIL, 4 findings, all accepted
+
+Fourth reviewer, dispatched as an `explorer` role so the `SubagentStop` observer
+actually records the verdict — rounds `r2` and `r3` had to be aborted as
+inconclusive because their reviewers ran outside that matcher
+(`hooks/subagent-stop-observing-review.json` matches `^(explorer)?$`). Their findings
+were still absorbed; only the FSM record was missing.
+
+Verdict **FAIL**: every code judgment held, and four EXECUTION-procedure defects
+remained.
+
+## What passed
+
+- Both remote phases use dedicated worktrees, no `checkout -f` on the shared lidge
+ checkout (`020:19`, `050:16`).
+- `build:gui` and the publish caveat are in both gate phases (`020:49`, `050:32`).
+- Release-state reads are live (`050:49`).
+- WP2b's helper move and re-export are technically correct
+ (`protobuf-events.ts:1340`, `message-mapper.ts:28`).
+- **PR1 and PR2 tests do not depend on PR3 code** — the stack is genuinely layered.
+- No release-authority gate forgotten: exact-SHA Cross-platform CI and Service
+ lifecycle are already required before publication (`050:73`).
+
+## F1 (High) — the base pin was recorded but not USED, and it cannot be one SHA
+
+Two problems in one finding.
+
+First, `010` still said `git rebase origin/dev` while `020` recorded a live SHA — so
+the pin was decorative. The remote had already moved again to `1645bb924` by the time
+`r4` ran (third observed value after `87f7f970b` and `e1bdbc1e5`).
+
+Second, and worse: `040` required every merge to see live `dev` equal to the original
+`VERIFIED_BASE`. That is false by construction the moment PR1 lands — PR1's merge
+result IS the new `dev`. The doc defined PR2's updated expectation but never repeated
+it for PR3.
+
+**Accepted.** `010` step 1 now captures `VERIFIED_BASE` with `git ls-remote` and
+rebases onto that SHA. `040` replaces the frozen check with an evolving
+`EXPECTED_DEV`: verified base before PR1, then each layer's merge result before the
+next, re-read live every time, with an explicit stop-and-rebase branch if it differs.
+
+## F2 (High) — the three-layer topology was not constructible as written
+
+Measured against the tree:
+
+- `..dfb6fb884` = 17 commits — correct.
+- `dfb6fb884..6d9744283` = 3 — correct.
+- `6d9744283..fe2237038` = **11**, `6d9744283..HEAD` = **16** — the doc said
+ "15 + WP2b".
+
+Worse than the miscount: `git diff --name-only 6d9744283 cursor-call` shows the top
+range also touches `src/adapters/cursor/request-builder.ts`,
+`tests/cursor-request-builder.test.ts`, and `tests/cursor-tool-result-image.test.ts`
+— all PR1-owned, edited later by `2ea12062d` (the `r2` honesty corrections). And the
+rebase REWRITES `dfb6fb884` and `6d9744283`, so the plan named branch points that
+will not exist when it runs. WP2b was declared part of PR1 with no instruction for
+how it gets there.
+
+**Accepted, and `030` is rewritten around OWNERSHIP rather than commit ranges.** Each
+layer is defined by the subsystem it changes; the doc now carries a five-step
+procedure: map the rewritten boundaries by SUBJECT LINE (the rebase preserves order),
+move the late PR1-owned edits below the PR1 boundary (fix-forward cherry-pick, or
+`rebase -i` split if that is not clean — and record which route was taken), land
+WP2b inside PR1 before the branch is cut, create the branches, then re-verify every
+layer's file set and refuse to open a mislabeled PR.
+
+## F3 (Medium) — `f145fd513` hits a second, unplanned conflict
+
+`010` said every commit after `54f68daf5` applies cleanly. Not true: step 2 removes
+`CursorStreamTruncatedError` from the import at `live-transport.ts:51`, and
+`f145fd513` edits that exact line to add `CursorUnexpectedCancelError` while still
+listing the removed symbol.
+
+The reviewer also confirmed the good news: the functional context survives. The
+`emittedTerminal` write in `push()` (`:541`) and both `classifyTurnFailure` throw
+sites (`:642`) still exist after the step-2 resolution, so this is an import line and
+nothing more.
+
+**Accepted.** `010` gains an explicit step 3 with the literal resolved import line
+and a warning not to let the conflict marker tempt a wider edit.
+
+## F4 (Medium) — WP2b's own contract test was gated one layer too high
+
+`015` re-exports `partialUsageFromEventState` specifically so
+`tests/cursor-interaction-query.test.ts` keeps working (five dynamic imports at
+`:150`, `:164`, `:172`, `:189`, `:195`), and names that file in its verification.
+But `020`'s per-layer table assigned it to PR3 while WP2b lands in PR1 — so PR1
+could have merged with the re-export untested.
+
+**Accepted.** The test moves to PR1's gate, and the table now names WP2b as part of
+PR1 rather than PR3.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md b/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md
new file mode 100644
index 0000000000..1bec34fd26
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md
@@ -0,0 +1,85 @@
+# 008 — Audit round r5-20260818035046: FAIL, and the approach changes
+
+Fifth reviewer, narrow scope: verify `r4`'s four closures and answer whether the
+sequence is executable end to end.
+
+Verdict **FAIL**. Two of four closures held; `r4`-F2's did not, and the reviewer was
+right that the sequence could not be carried out as written.
+
+## Closures that held
+
+- **r4-F1 (evolving base pin).** `040:26` correctly evolves `EXPECTED_DEV` through
+ PR1, PR2, and PR3, with a live read and a stop-and-rebase branch before every
+ merge.
+- **r4-F3 (second conflict at the import line).** `010:176`'s resolved import
+ contains exactly the surviving symbols, and `f145fd513` has no other dependency on
+ `CursorStreamTruncatedError` — verified by reading the commit's own diff.
+- **r4-F4 (WP2b's contract test).** `020:105` has it in PR1, and every other test in
+ the table imports code its layer owns.
+
+## F1 (High) — the stack procedure was not executable
+
+Four concrete defects in `030`'s five steps:
+
+1. Steps 2-3 placed commits on `cursor-call-wire` **before step 4 created it**, and
+ step 4 created it at the OLD `PR1_TIP`, which would have excluded exactly those
+ commits.
+2. No restack of PR2/PR3 after PR1 changed.
+3. "Let the original commit become a no-op during the retarget" is not a thing a
+ retarget can do — the commit still exists in the child's history.
+4. No handling for two late DOC edits.
+
+## F2 (High) — the partition still leaked
+
+`6d9744283..cursor-call` also edits `devlog/_plan/260817_cursor_toolcall_decode/000_index.md`
+and `020_phase2-toolresult-image-passthrough.md` (by `be1b881ec`, the F1 capability
+correction). Those docs belong to PR1 by the Owns table, yet the procedure only moved
+the three `2ea12062d` source/test paths.
+
+Also: `cursor-interaction-query.test.ts` sat in PR1's Owns set while `015` explicitly
+preserves that file unchanged — so an exact file-set match was impossible by
+construction.
+
+## F3 (Medium) — the first literal execution failure
+
+`010:163` wrote `VERIFIED_BASE=\$(...)` and `git rebase \$VERIFIED_BASE`. Under zsh
+the escaped form assigns the command TEXT and passes the literal variable name to
+`git rebase`. Fixed to `$(...)` and `"$VERIFIED_BASE"`.
+
+## The real lesson: stop re-slicing a finished history
+
+Four rounds attacked the split and each produced a different broken procedure. The
+common cause is that all four tried to **re-slice a completed linear history**, which
+forces commits to move between layers, and every mechanism for moving them
+(cherry-pick, `rebase -i`, retarget) broke a different invariant.
+
+`030` is now rewritten to build the stack **forward**. Each layer branch is cut from
+its base and its files are checked out from `FINAL` (the rebased `cursor-call` tip),
+then committed as that layer's contribution. Two consequences:
+
+- **PR1 ∪ PR2 ∪ PR3 is identical to `cursor-call` by construction**, because every
+ layer's tree comes from `FINAL`. That is the property the previous procedures kept
+ failing to guarantee, and `030` step 4 now checks it mechanically
+ (`git diff EXPECTED_DEV cursor-call --stat` equals the union of the three layer
+ diffs).
+- **The late doc edits stop being a special case.** Step 1 takes the whole
+ `260817_cursor_toolcall_decode/` directory from `FINAL`, so `be1b881ec`'s edits are
+ included automatically. `r5` was right about the leak; taking final state rather
+ than mid-history state is the fix.
+
+One file needs care under forward construction: `live-transport.ts`. `FINAL`'s
+version contains PR2's `classifyTurnFailure`, so PR1 takes that single file from the
+rebased wire-work boundary commit instead (its subject is unique in the range —
+`git log --format='%s' | sort | uniq -d` returns nothing, confirmed by `r5`), and PR2
+takes it from `FINAL`. Documented in `030` step 1.
+
+`030` also now states a fallback out loud: if the union check fails for a genuine
+interleaving reason, open ONE PR and say why. Iterating on the split a fifth time is
+not on the table.
+
+## Note on the rebuilt layers
+
+Forward construction creates new commit objects, so the campaign's original messages
+are carried over deliberately — the devlog cites them. `cursor-call` remains the
+canonical history and the PR bodies say so.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md b/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md
new file mode 100644
index 0000000000..de9719010e
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md
@@ -0,0 +1,72 @@
+# 009 — Audit round r6-20260818035826: FAIL, and the category error was mine
+
+Sixth reviewer, narrow scope: does the forward-construction stack procedure work
+mechanically? Verdict **FAIL**, four blockers.
+
+## What r6 found
+
+1. **PR3 was not stacked on PR2 (High).** Steps 1-2 created NEW commits from
+ `EXPECTED_DEV` while step 3 kept the original `cursor-call`. So
+ `cursor-call-cancel` was not an ancestor of `cursor-call`, and a PR against it
+ would compare from the merge base and show the entire original series. A retarget
+ cannot repair ancestry.
+2. **PR1 copied PR2/PR3 content (High).** Taking `cursor-errors.ts` from `FINAL`
+ hands PR1 the `CursorUnexpectedCancelError` that is supposed to be PR2's, making
+ PR2's own checkout a no-op. Taking the whole decode directory hands PR1 the
+ `040` and `050` phase docs owned by PR2 and PR3.
+3. **WP2b's `live-transport.ts` part landed in PR2 (High).** PR1 deliberately took
+ that file from an earlier boundary, so WP2b's helper deletion + import + re-export
+ was missing there and appeared in PR2's `FINAL` checkout instead. Executed
+ literally, PR1 would carry two copies of the helper.
+4. **The union check proved nothing (Medium).** A whole-range `--stat` is fixed by
+ its endpoints; it stays identical no matter which layer content lands in. It would
+ have caught neither `r5`'s doc leak nor `r4`'s source leak.
+
+r6 also confirmed `git checkout [ -- ` behaves as assumed, and that the
+`live-transport.ts` boundary exception was internally coherent
+(`dfb6fb884`: 4 `emittedTerminal`, 0 `classifyTurnFailure`; `6d9744283`: 4 of each).
+
+## The category error
+
+Four consecutive failures on the same question, each on a different mechanism. Under
+LOOP-REPAIR-01 that is the point to stop patching the answer and re-read the
+question.
+
+I had been requiring the layers to be **subsystem-pure** — each touching only its own
+files. That came from `r4` F2 noting the top commit range also edits PR1-owned files,
+which I treated as a defect in the split.
+
+It was not a defect. **A stacked PR promises reviewable increments in dependency
+order, not subsystem purity.** Purity requires moving content between commits, the
+history is already final, and every mechanism for moving it — cherry-pick,
+`rebase -i` split, forward tree copy — broke a different git invariant. `r3` F3 had
+the right answer originally; I over-corrected it away.
+
+## The fix
+
+`030` now cuts the rebased history at two existing commits and creates branches
+there. Every property the four broken versions fought for comes free:
+
+- ancestry is automatic (one linear history) — closes r6-1;
+- union equals the branch by construction (the ranges partition it) — closes r6-4;
+- no commit moves, so nothing can be dropped, duplicated, or contaminated —
+ closes r6-2 and r6-3.
+
+Step 3 proves it with four commands: two `--is-ancestor` checks and three range
+counts that must sum to the whole.
+
+## What moved as a result
+
+WP2b and `tests/cursor-interaction-query.test.ts` are now BOTH in PR3, where WP2b
+lands chronologically. `r4` F4's principle stands — a change and its contract test
+belong together — and this satisfies it in the other direction. PR1 remains correct
+without WP2b: PR1 makes a truncated turn reportable, PR3 makes it report tokens.
+That is what a stacked increment is.
+
+PR3's body must name the two things a reviewer would otherwise find odd: it carries
+WP2b, and it carries the late honesty corrections to PR1-owned files (`2ea12062d`'s
+comments, `be1b881ec`'s two decode docs).
+
+`030` keeps a fallback: if step 3 fails, open one PR and say why. There is no sixth
+splitting scheme.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md
new file mode 100644
index 0000000000..02bb6aa1cd
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md
@@ -0,0 +1,221 @@
+# 010 — WP2: rebase cursor-call onto dev with evidence-based conflict resolution
+
+> **EXECUTION AUTHORITY: `cursor-call-integration.zsh pin | rebase | push`.**
+> The commands below are the reasoning, not the runbook. Seven audit rounds proved a
+> markdown file cannot enforce that a variable is bound before it is read (`019`), so
+> the script owns what runs and this doc owns why. If they disagree the script is
+> right and this doc is stale — fix the doc.
+
+Two conflicts. Both were investigated by an independent read-only agent before any
+rebase step ran; the verdicts below are the resolution contract.
+
+## Conflict 1 — `src/adapters/cursor/live-transport.ts` (SEMANTIC)
+
+### The collision
+
+Our `54f68daf5` and dev's `6a64db19d`+`08eb65d1f`+`1824a0148` fix the SAME defect:
+a framed Cursor stream that ends at the HTTP/2 layer with no turn terminal while a
+client tool call is still open used to settle as success, so the deferred tool call
+vanished.
+
+| | Ours (`54f68daf5`) | dev (`6a64db19d`..`1824a0148`) |
+|---|---|---|
+| Mechanism | `settler.settleFail(new CursorStreamTruncatedError(...))` | `for (const e of finalizeTurnEvents(state)) push(e)` then `settleFinish()` |
+| Wire result | thrown transport failure | one `{type:"error"}` adapter event naming the open call |
+| Extra state | `emittedTerminal` (per-run flag set in `push()`) | `sawAssistantText` |
+
+### VERDICT — dev's error-event shape survives
+
+Reasons, in order of weight:
+
+1. `finalizeTurnEvents` (`src/adapters/cursor/protobuf-events.ts:1361`) is the
+ established adapter contract for this exact condition and already returns a
+ fail-closed `error` event. dev `1824a0148` records that CodeRabbit asked for a
+ throw here and it was **rejected on the merits**: throwing replaces a
+ domain-specific truncation message with a generic transport failure.
+2. Our phase-050 bridge work does the right thing with dev's shape. The error sets
+ both `errorEvent` and `sawTerminal`, so `buildResponseJSON` returns
+ `status: "failed"`, attaches no `adapter_eof`, and suppresses compaction
+ history. Streaming maps it to `response.failed`. No double-report.
+3. `settleFinish()` only ends transport iteration; the queued error stays the sole
+ adapter terminal.
+
+### What must survive from OUR commit
+
+`emittedTerminal` is NOT optional: `f145fd513` (unexpected server-side CANCEL
+provenance) reads it to avoid flipping an already-completed buffered turn to
+failed. Keep all three sites:
+
+```ts
+ private emittedTerminal = false;
+```
+
+```ts
+ const push = (message: CursorServerMessage) => {
+ const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
+ this.reserveTransportBytes(bytes);
+ if (message.type === "done" || message.type === "error") this.emittedTerminal = true;
+ queue.push({ message, bytes });
+ wake();
+ };
+```
+
+```ts
+ this.framesReceived = 0;
+ this.emittedTerminal = false;
+ this.sawAssistantText = false;
+```
+
+### The merged end-handler block (MODIFY)
+
+Take dev's block and add one guard — `|| this.emittedTerminal` — so EOF
+finalization cannot append a second terminal after a mapper error already failed
+the turn:
+
+```ts
+ if (state.terminated || this.expectedClose || this.emittedTerminal) {
+ releaseBacklogLease();
+ settler.settleFinish();
+ return;
+ }
+ // Open tools fail closed as a domain-specific truncation event. Throwing here would
+ // replace that event with a generic transport failure at the Cursor adapter boundary.
+ if (state.openToolCalls.size > 0) {
+ for (const event of finalizeTurnEvents(state)) push(event);
+ releaseBacklogLease();
+ settler.settleFinish();
+ return;
+ }
+ if (this.framesReceived > 0 && this.sawAssistantText) {
+ for (const event of finalizeTurnEvents(state)) push(event);
+ releaseBacklogLease();
+ settler.settleFinish();
+ return;
+ }
+ releaseBacklogLease();
+ settler.settleFinish();
+```
+
+Also drop `CursorStreamTruncatedError` from the `live-transport.ts` import, since
+this path no longer throws it.
+
+### `CursorStreamTruncatedError` itself (`src/adapters/cursor/cursor-errors.ts`)
+
+Keep the class. It is exported, it documents the condition, and removing it from
+the same commit that rewrites the transport would widen the diff for no verified
+gain. If the C-phase check shows it is unreferenced anywhere, note it as a
+follow-up rather than deleting it inside this rebase.
+
+### TESTS — our expectation changes
+
+`tests/cursor-eof-terminal.test.ts` case *"EOF with an open tool call fails instead
+of finishing silently"* asserts a THROWN error. Under the surviving shape that
+assertion is wrong on the merits: the requirement is "do not finish silently," and
+an explicit error event satisfies it more precisely than a thrown transport
+failure. Rewrite that case to:
+
+```ts
+expect(failure).toBeUndefined();
+expect(messages.some(m => m.type === "tool_call_end")).toBe(false);
+expect(messages.some(m => m.type === "done")).toBe(false);
+expect(messages.at(-1)).toMatchObject({
+ type: "error",
+ message: expect.stringContaining("call_open_1"),
+});
+```
+
+The other three cases in that file pass unchanged. All 33 cases in dev's
+`tests/cursor-hardening.test.ts` pass, including *"open tool call plus clean
+Connect EOF emits a truncation error, not a thrown failure"* — which is dev's
+regression test for exactly this decision.
+
+**The overlap is real and must be named in the commit message:** our 010 phase is
+superseded by dev's independent fix. What our branch still contributes on this
+file is `emittedTerminal` and the extra terminal guard.
+
+## Conflict 2 — `src/adapters/google.ts` (TEXTUAL)
+
+Our `f73f09c9e` adds buffered `finishReason` forwarding. dev's six commits are all
+request-side identity/rename work and never touch the response-parsing block, so
+the intent applies unchanged — only the line numbers drifted.
+
+- `parseResponse` now starts at dev `google.ts:812` (candidate read at `:894`).
+- Insertion point moved from former `:939` to current `:946`.
+
+MODIFY, at dev's current `:946`:
+
+```diff
+ const usage = json.usageMetadata as Record | undefined;
++ // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter
++ // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated
++ // turn as completed — and, on a compaction turn, installs the half-written summary as
++ // replacement history (#422).
++ const finishReason = candidates?.[0]?.finishReason as string | undefined;
++ const stopReason = finishReason === "MAX_TOKENS"
++ ? "max_tokens"
++ : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "")
++ ? "content_filter"
++ : undefined;
+ events.push({
+ type: "done",
+ usage: usageFromGemini(usage),
++ ...(stopReason ? { stopReason } : {}),
+ });
+ return finish(events);
+```
+
+## Procedure
+
+1. Pin the base first (audit `r3` F1 / `r4` F1). Never rebase onto the tracking ref:
+
+ git fetch origin dev
+ VERIFIED_BASE=$(git ls-remote origin refs/heads/dev | cut -f1)
+ git rebase "$VERIFIED_BASE"
+
+ Record `VERIFIED_BASE`; every later phase compares against it and `040` evolves
+ it through the stack. The snapshot `cursor-call-prerebase-260818` = `fe2237038` is
+ the recovery path. Observed drift while planning: `87f7f970b` → `e1bdbc1e5` →
+ `1645bb924`, which is why a cached SHA in this doc is never the rebase target.
+2. At the `54f68daf5` conflict: resolve to dev's block plus `emittedTerminal` and
+ the extra guard; drop the unused import; rewrite the one test expectation.
+ Amend the commit message to record the supersession.
+3. **Expect a SECOND conflict at `f145fd513` (audit `r4` F3).** Step 2 removes
+ `CursorStreamTruncatedError` from the import line at `live-transport.ts:51`, and
+ `f145fd513` edits that same line to add `CursorUnexpectedCancelError` while still
+ listing the removed symbol. Resolve to:
+
+ import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors";
+
+ Its functional context survives intact — the `emittedTerminal` write in `push()`
+ and both `classifyTurnFailure` throw sites still exist after the step-2
+ resolution (verified at `live-transport.ts:541` and `:642`), so this is an import
+ line only. Do not let the conflict marker tempt a wider edit.
+4. At any `google.ts` conflict: apply the hunk at dev's current location.
+5. Every OTHER commit should apply cleanly (zero dev commits on those paths). If one
+ does not, STOP and investigate rather than resolving mechanically.
+6. Adversarial audit round on the resolved diff before pushing.
+7. **Push the rebased branch (audit `r7`).** WP3 verifies on lidge by fetching
+ `origin/cursor-call`, so an unpushed rebase means lidge either fails on an unknown
+ revision or silently tests the stale pre-rebase code:
+
+ git push --force-with-lease --no-verify origin cursor-call
+ test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)"
+
+ `--force-with-lease` rather than `--force`: the rewrite is expected, clobbering
+ someone else's push is not. The snapshot `cursor-call-prerebase-260818` remains the
+ recovery path.
+
+## Verification (C)
+
+Local, focused (fast signal only):
+
+```
+bun test tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts \
+ tests/cursor-cancel-provenance.test.ts tests/cursor-tool-result-image.test.ts
+bun x tsc --noEmit
+rg -n '^<<<<<<<|^>>>>>>>|^=======$' src tests
+git merge-base --is-ancestor origin/dev cursor-call # exit 0
+```
+
+Authoritative verification is WP3 on `ssh lidge`. Expected: typecheck exit 0; the
+focused cursor files green; no conflict markers; dev head an ancestor.
diff --git a/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md b/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md
new file mode 100644
index 0000000000..6861ad9e77
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md
@@ -0,0 +1,65 @@
+# 00A — Audit round r7-20260818040721: NEAR-PASS, gate closed
+
+Seventh reviewer, scoped to the branch-pointer stack plus a whole-plan consistency
+sweep. **NEAR-PASS**: the stack passed on every property, and one execution blocker
+plus one naming inconsistency were found and fixed.
+
+## The stack passed
+
+Measured, not asserted:
+
+- History is linear: 39 commits, zero merges.
+- Boundary subjects unique and correctly ordered
+ (`git log --format='%s' ..cursor-call | sort | uniq -d` returns nothing).
+- Ranges partition exactly: 17 + 3 + 19 = 39, no commit in two ranges, none omitted.
+- Creating branches at those commits adds pointers only — no commit moves.
+- A rebase preserves relative order for a linear, non-interactive, non-autosquashed
+ series, so the boundaries survive the rewrite.
+
+That is all four properties the previous four schemes fought for, obtained by not
+fighting for subsystem purity.
+
+## Layering judged honest
+
+- **PR1** is independently gateable: its EOF resolution keeps `emittedTerminal` and
+ the terminal guard without needing PR2 or PR3, and its test asserts the standalone
+ error-event shape.
+- **PR2** honestly depends on PR1's `emittedTerminal` and adds CANCEL provenance as
+ its own increment.
+- **PR3** is broad but not a dumping ground: bridge/adapter terminal correctness is
+ its thesis, WP2b extends the same truncated-terminal path with usage, and the
+ cross-layer edits are identified comment/doc corrections that its body must name.
+- Running `cursor-eof-terminal.test.ts` in both PR1 and PR3 is meaningful rather than
+ redundant: PR1 verifies terminal SHAPE, PR3 adds the usage cases, and PR1's
+ `toMatchObject` tolerates the added field.
+- `tests/cursor-interaction-query.test.ts` is not edited at all — `015` preserves its
+ imports through the re-export — but PR3 owns running that existing contract.
+
+## Blocker — the rebased branch was never pushed before remote verification
+
+`020` has lidge fetch `origin/cursor-call` and create a worktree at the new SHA, and
+`010` said the audit happens "before pushing" — but no push command existed anywhere
+between them. lidge would have fetched the PRE-rebase branch and then either failed
+on an unknown revision or, worse, silently tested stale code without the rebase or
+WP2b. `origin/cursor-call` was still at `9f8ccec9d` when `r7` checked.
+
+**Fixed.** `010` gains step 7:
+
+ git push --force-with-lease --no-verify origin cursor-call
+ test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)"
+
+`--force-with-lease` rather than `--force`: the rewrite is expected, clobbering
+someone else's push is not. `020` also re-confirms the SHA on lidge before installing.
+
+## Naming inconsistency
+
+`030`'s prose still called the construction base `EXPECTED_DEV`, which `040` defines
+as the EVOLVING merge-time variable, while `030`'s own commands correctly used
+`VERIFIED_BASE`. Renamed throughout `030`; `EXPECTED_DEV` now appears only in `040`,
+where it is initialized from `VERIFIED_BASE` and advanced per merge.
+
+## Terminal state of the roadmap cycle
+
+Seven rounds, 27 findings, all verified against the tree and absorbed. The plan is
+executable end to end as written.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md
new file mode 100644
index 0000000000..58a562105d
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md
@@ -0,0 +1,87 @@
+# 010a — Audit rounds r7 (PASS) and r8 (NEAR-PASS): the plan is executable
+
+## Why two rounds recorded together
+
+`r7` walked the whole sequence and returned **PASS** with no blocking findings. That
+verdict could not be recorded: the goalplan's `activeWorkPhaseId` was `null`, and the
+review observer discards a sign-off whose round targets a work-phase that is not
+active (`review-observer.ts:99-101`). Every earlier round had been silently
+discarded for the same reason, which is why `r2`-`r6` all had to be aborted as
+inconclusive after their findings were absorbed. Fixed by setting
+`activeWorkPhaseId = wp1-integration-roadmap`.
+
+`r8` then re-confirmed independently and returned **NEAR-PASS** with exactly one
+finding.
+
+## r7 (PASS) — what it verified
+
+- The stack cut: both boundary subjects occur exactly once, the 39-commit range is
+ linear with no merges, and `17 + 3 + 19 = 39`. Neither boundary commit can be
+ dropped as empty — both boundary docs are absent from `dev`, and the overlapping
+ EOF commit stays non-empty because of `emittedTerminal`.
+- All four `r6` findings are **structurally impossible** under the new procedure, not
+ merely unlikely: commits are referenced, never copied or rearranged.
+- Retargeting is safe: a merge commit preserves PR1's commits, so after PR1 lands its
+ tip remains the merge base of `dev` and PR2, leaving PR2's effective diff exactly
+ `PR1_TIP..PR2_TIP`. The no-squash rule is what protects this.
+- PR1 without WP2b is a complete, correct change: PR1 makes a truncated turn
+ reportable, WP2b later makes it report tokens. Nothing in PR1 imports the relocated
+ helper, and PR1's rewritten test asserts terminal shape without requiring usage.
+- PR3's inventory is complete — 27 files, exactly the bridge/adapter work, the
+ integration unit, and the late corrections `030` requires the PR body to disclose.
+
+## r8 (NEAR-PASS) — the one finding, accepted
+
+Step 3's proof was incomplete: it asserted `wire → cancel → tip` but never
+`VERIFIED_BASE → wire`. The counts do not cover that gap, because
+`git rev-list --count A..B` counts commits reachable from B and not A **even when A
+is not an ancestor of B**. The reviewer demonstrated it: against `dev` at
+`1645bb924`, `git merge-base --is-ancestor 1645bb924 dfb6fb884` exits 1 while the
+three counts still sum correctly.
+
+So a stack could have passed step 3 while its bottom did not sit on the verified
+base — precisely the class of defect this plan has been failing on. Added:
+
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire # exit 0
+
+with the reasoning inline so a future reader does not delete it as redundant.
+
+`r8` also independently re-confirmed both conflict resolutions, WP2b's resolver
+choice and re-export, and the governance position, and found no first-failing step
+once the assertion is added.
+
+## r8's blocking finding: the push handed off the wrong tip
+
+`010` step 7 pushes the rebase tip, and `020` verifies whatever `origin/cursor-call`
+points at. But the declared order is WP2 → WP2b → WP3, and WP2b changes code AFTER
+step 7 runs while carrying only local checks. So lidge would have authoritatively
+verified a tree without WP2b in it, and PR3's WP2b implementation would have reached
+`dev` backed by nothing but a local `bun test`.
+
+The earlier push is not wrong, it is just not the handoff. `015` now ends with its
+own push and SHA assertion, and `020` says explicitly which of the two pushes it
+consumes:
+
+ git push --force-with-lease --no-verify origin cursor-call
+ test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)"
+
+This is the same class as `r7`'s finding — a phase boundary where the artifact one
+side produces is not the artifact the other side reads — which is why it survived
+seven rounds of reading each document on its own terms.
+
+## Round tally
+
+| Round | Verdict | Findings | Recorded |
+|-------|---------|----------|----------|
+| r1 | FAIL | 6 | aborted (observer gap) |
+| r2 | NEAR-PASS | 3 | aborted (observer gap) |
+| r3 | FAIL | 5 | aborted (observer gap) |
+| r4 | FAIL | 4 | aborted (observer gap) |
+| r5 | FAIL | 3 | aborted (observer gap) |
+| r6 | FAIL | 4 | aborted (observer gap) |
+| r7 | PASS | 0 | aborted (observer gap — cause found here) |
+| r8 | NEAR-PASS | 1 | recorded |
+
+26 findings, every one verified against the tree and absorbed. Four of the eight
+rounds attacked the same question (how to split the stack) and the fourth failure
+was the signal that the question itself was wrong — recorded in `009`.
diff --git a/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md b/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md
new file mode 100644
index 0000000000..a9d21eb3d3
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md
@@ -0,0 +1,60 @@
+# 014 — Audit round r10-20260818042302: NEAR-PASS, the third boundary defect
+
+Scope: confirm `r8`'s fix, then sweep EVERY phase boundary for the same class of
+defect — one phase produces an artifact, the next reads a different one. Two
+consecutive rounds had found that shape, so the round was aimed at a third instance
+rather than at re-reading settled conclusions.
+
+It found one.
+
+## r8's fix confirmed
+
+- `015` ends with its own `--force-with-lease` push and an exact remote/local SHA
+ assertion (`015:144`).
+- `020` explicitly consumes WP2b's later push, not `010`'s checkpoint (`020:33`).
+- Both snippets parse correctly under zsh; substitutions unescaped, `test A = B`
+ well-formed.
+- WP2→WP2b, WP2b→WP3 and WP5→WP6 are coherent; WP6 gates the named merged-`dev` SHA
+ in a dedicated worktree (`050:18`).
+
+## The finding: the verified tree was never bound to the merged PR heads
+
+`020` verifies one specific SHA. But `030` cut branches from `cursor-call` — a
+MUTABLE ref — without asserting it still equaled that SHA, and `040` checked only the
+live `dev` base before each merge, never the PR's own head.
+
+The consequence is subtle, which is why it survived nine rounds: a force-push to any
+PR head introduces commits no gate has seen, and `040`'s post-merge ancestry check
+**still passes**, because the verified tip remains an ancestor of a superset. The
+check that was supposed to catch a bad merge is structurally incapable of catching
+this one.
+
+## The fix — one named SHA threaded through three phases
+
+`020` now records it:
+
+ VERIFIED_TIP=$(git rev-parse cursor-call) # after WP2b's push, before any gate
+
+`030` step 0 refuses to cut branches unless `cursor-call` still equals it, and step 5
+records each PR's expected head (`PR1_TIP`, `PR2_TIP`, `VERIFIED_TIP`).
+
+`040` asserts each head immediately before merging:
+
+ gh pr view --json headRefOid --jq .headRefOid # must equal PR_HEAD
+
+with the reasoning inline, so nobody deletes it as redundant with the base check —
+the base check proves `dev` has not moved and says nothing about what the PR points
+at.
+
+## Why this class kept appearing
+
+Three rounds, three instances, same shape: `r7` (nothing pushed the rebase before
+remote verification), `r8` (the push preceded WP2b, so the wrong tip was verified),
+`r10` (the verified tip was never bound to what actually merges). Each document was
+correct read alone. The defect only exists in the seam.
+
+The general lesson, recorded so the next unit inherits it: **a multi-phase plan needs
+its artifacts NAMED and asserted across every handoff, not merely described
+correctly within each phase.** `VERIFIED_BASE`, `EXPECTED_DEV` and now
+`VERIFIED_TIP` are that naming; the assertions are what make the naming load-bearing.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md
new file mode 100644
index 0000000000..0beba7d975
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md
@@ -0,0 +1,158 @@
+# 015 — WP2b: the surviving EOF truncation error must carry partial usage
+
+Origin: audit `r1` finding F3. This work-phase exists **because** `010` chose dev's
+error-event shape; without it, choosing that shape would be a usage regression.
+
+Revised by audit `r2` finding 1: the value must be PARTIAL-failure usage, not
+clean-turn usage.
+
+## The defect
+
+Two paths report a truncated Cursor turn, and only one of them reports tokens.
+
+| Path | Usage |
+|------|-------|
+| thrown transport failure | `attachPartialUsage` (`live-transport.ts:1193-1197`) → `cursor.ts:181-192` copies `partialUsage` into the error event |
+| `finalizeTurnEvents` open-tool branch | none (`protobuf-events.ts:1367-1372`) |
+
+`CursorServerMessage`'s error variant already carries usage
+(`src/adapters/cursor/types.ts:44-48`). So the omission is an oversight in the
+open-tool branch, not a design constraint.
+
+Consequence: a turn that consumed real tokens and then truncated mid-tool-call
+reports `usageStatus: unreported` with 0 tokens — the exact failure mode
+`attachPartialUsage`'s own doc comment says it exists to prevent.
+
+## Which resolver (audit r2 finding 1)
+
+The first draft said `resolvedTurnUsage(state)`. That is wrong in one case, and the
+reason is worth stating because it is the same class of mistake this campaign made
+once before.
+
+`resolvedTurnUsage` (`protobuf-events.ts:1340-1352`) is the CLEAN-turn resolver: it
+falls back to the session carry-forward, then the request-local estimate, so it
+returns a number even when this turn produced no token signal at all.
+
+`partialUsageFromEventState` (`live-transport.ts:1178-1188`) exists precisely
+because that is wrong for a failure. It returns `undefined` unless this turn
+produced a checkpoint or a positive output delta, on its own stated grounds: "a
+carry-forward value belongs to an earlier successful turn ... cannot by itself prove
+that a first-frame failure consumed anything."
+
+An unconditional `resolvedTurnUsage` would therefore make the EOF error report
+stale or inferred consumption exactly where the thrown path correctly reports none.
+That trades one wrong number (0) for a different wrong number.
+
+Use the failure-specific helper. It currently lives in `live-transport.ts` while
+`finalizeTurnEvents` lives in `protobuf-events.ts`, and `protobuf-events.ts` imports
+nothing from the transport. So the helper moves DOWN to `protobuf-events.ts` (next
+to `resolvedTurnUsage`, which it already calls) and `live-transport.ts` imports it
+from there. That is the direction the dependency already runs; the reverse would
+create a cycle.
+
+## MODIFY — `src/adapters/cursor/protobuf-events.ts`
+
+Move `partialUsageFromEventState` here from `live-transport.ts`, keeping its
+exported name and its doc comment (it is exported for unit testing and
+`live-transport.ts` keeps using it via import).
+
+Then, in `finalizeTurnEvents`, the open-tool branch:
+
+ for (const callId of openCallIds) state.translatorBudget?.closeCall(callId);
+ state.openToolCalls.clear();
+ // A truncated turn still consumed tokens, and the error variant carries usage
+ // (types.ts CursorServerMessage). Use the FAILURE resolver, not resolvedTurnUsage:
+ // a carry-forward or request estimate belongs to an earlier successful turn and must
+ // not be reported as this turn's consumption. Absent when nothing was proven, which
+ // matches the thrown path exactly.
+ const partial = partialUsageFromEventState(state);
+ return [{
+ type: "error",
+ message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.`,
+ ...(partial ? { usage: partial } : {}),
+ }];
+
+Note the spread: no `usage` key at all when this turn proved nothing.
+
+## MODIFY — `src/adapters/cursor/live-transport.ts`
+
+Delete the local `partialUsageFromEventState` definition and import it from
+`./protobuf-events` alongside the existing `finalizeTurnEvents` import.
+
+**RE-EXPORT it.** `tests/cursor-interaction-query.test.ts` imports it from
+`live-transport.ts` five times (`:150`, `:164`, `:172`, `:189`, `:195`) — that file
+is dev's existing contract for partial-usage reporting and this work-phase has no
+business rewriting it. So:
+
+ export { partialUsageFromEventState } from "./protobuf-events";
+
+keeps every existing import path working while the definition lives in one place.
+Verified with `rg -n 'partialUsageFromEventState' tests src`.
+
+Import-cycle check: `protobuf-events.ts` imports only `../../types`, `./gen/agent_pb`,
+`./arg-codec`, `./arg-normalize`, `./types`, and `../../lib/translator-budget` — nothing
+from `live-transport.ts`. Moving the helper down therefore adds no cycle; moving
+`finalizeTurnEvents` up would have.
+
+## Confirmed before writing: the consumer forwards it
+
+`src/adapters/cursor/message-mapper.ts:29` maps an error message to
+`{ type: "error", message, ...(message.usage ? { usage: message.usage } : {}) }`, and
+`src/adapters/cursor.ts:127-142` emits the mapped event unchanged. The patch site is
+right and no mapper change is needed. (`cursor.ts:181-192` is the THROWN path's
+`err.partialUsage` handling, unrelated to an event that flowed through the mapper.)
+
+## TESTS — `tests/cursor-eof-terminal.test.ts`
+
+Two cases, both driven red first.
+
+Positive — a real token signal this turn:
+
+ test("an EOF truncation error reports the tokens the turn already consumed", async () => {
+ // Assistant text plus a tokenDelta (or checkpoint) BEFORE the open tool call,
+ // then clean EOF with no terminal. Red before the fix: usage is undefined.
+ expect(errorEvent.usage).toBeDefined();
+ expect(errorEvent.usage?.outputTokens ?? 0).toBeGreaterThan(0);
+ });
+
+Negative — carry-forward only, which audit `r2` asked for. Without it, a later
+change could satisfy the positive case by reporting a previous turn's tokens:
+
+ test("an EOF truncation with no token signal this turn reports no usage at all", async () => {
+ // Seed a session carry-forward / request estimate, then open a tool call and EOF
+ // with NO checkpoint and NO tokenDelta this turn.
+ expect(errorEvent.usage).toBeUndefined();
+ });
+
+The rewritten case from `010` asserts the SHAPE (error event, no `done`, no
+`tool_call_end`); these assert the USAGE. Keeping them separate means a future
+change cannot quietly satisfy one by breaking the other — the mistake this campaign
+already made once, when a test titled "carrying usage" never asserted usage.
+
+`010`'s assertion uses `toMatchObject`, which tolerates the added `usage` property,
+so the two docs do not conflict (confirmed in audit `r2`).
+
+## Verification (C)
+
+ bun test tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts \
+ tests/cursor-interaction-query.test.ts
+ bun x tsc --noEmit
+
+`tests/cursor-interaction-query.test.ts:148-185` is in the list because it is the
+existing contract for partial-usage reporting; this change must not disturb it.
+
+## Push before handing off to WP3 (audit `r8`)
+
+`010` step 7 pushes the rebase tip, and that push happens BEFORE this work-phase
+exists. WP3 then verifies whatever `origin/cursor-call` points at — so without a
+second push here, lidge would authoritatively verify a tip that does not contain
+WP2b, and PR3's WP2b implementation would reach `dev` with only local checks behind
+it.
+
+So this work-phase ends with:
+
+ git push --force-with-lease --no-verify origin cursor-call
+ test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)"
+
+This is the push WP3 hands off from. `010`'s earlier push stays (it is a harmless
+checkpoint after the rebase), but it is not the verification handoff.
diff --git a/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md b/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md
new file mode 100644
index 0000000000..80f8959e37
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md
@@ -0,0 +1,59 @@
+# 016 — Audit round r13-20260818042842: NEAR-PASS, the assertions had to become commands
+
+`r13` did something the previous twelve rounds did not: it RAN the snippets. Four
+blockers, all of the same kind — the plan described the right check in prose but did
+not express it as something a shell would execute.
+
+## What it found
+
+1. **`VERIFIED_TIP` was captured after the gates, not before.** `020` created the
+ worktree at a placeholder `` and only named `VERIFIED_TIP` in a later
+ section. If `cursor-call` moved during the ~8-minute suite, the recorded tip and
+ the tested tree would differ — the exact defect `r10` closed one layer up, reopened
+ inside the phase that owns it.
+
+2. **`PR1_HEAD = ` is not an assignment.** The reviewer probed it: zsh
+ exits 127 with `command not found: PR1_HEAD`, because `NAME = value` runs `NAME`
+ as a command. The whole PR-head binding was a legend, not code.
+
+3. **`040` printed `headRefOid` instead of comparing it.** A value the operator has
+ to eyeball is not a gate.
+
+4. **`PR1_TIP`/`PR2_TIP` were read by eye** and never asserted against the created
+ branch tips. The ancestry and count checks prove topology, not identity.
+
+5. **`MERGED_DEV` was a fresh mutable-ref read**, so a concurrent push after PR3
+ landed would be silently attributed to this campaign while the ancestry test still
+ passed.
+
+## The fix
+
+Every one became an executable assertion:
+
+- `020` captures `VERIFIED_TIP` from `ls-remote` BEFORE the worktree, builds the
+ worktree at that SHA, and asserts the remote HEAD equals it after checkout.
+- `030` step 1 binds `PR1_TIP`/`PR2_TIP` with `git log ... | grep -F ... | cut`
+ instead of "read them off that list".
+- `030` step 5 uses real assignments (`PR1_HEAD=$(git rev-parse ...)`) and then
+ `test "$PR1_HEAD" = "$PR1_TIP"` — the assertion that actually binds branch tips to
+ the verified tree.
+- `040` merges behind `test "$(gh pr view --json headRefOid --jq .headRefOid)" = "$PR1_HEAD"`.
+- `040` takes `MERGED_DEV` from `gh pr view --json mergeCommit` — the merge
+ itself — and separately asserts `dev` still points there, with an explicit branch
+ for what to do if someone pushed after us.
+
+Verified by running the extraction against the real tree:
+
+ PR1_TIP=dfb6fb884df1df819aaf0d9d2ddfd07408860ea3
+ PR2_TIP=6d974428396fc1cb283353142e10f07074aecc00
+
+which are exactly the two boundary commits `030` names.
+
+## The pattern, four rounds running
+
+`r7`, `r8`, `r10` and now `r13` all found the same failure mode at different
+altitudes: an artifact that one phase produces and another consumes, where the
+binding lives in prose instead of in a command. Each document read correctly on its
+own. The plan is only as strong as its seams, and a seam is only real when it is a
+`test`.
+
diff --git a/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md b/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md
new file mode 100644
index 0000000000..28985e88b3
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md
@@ -0,0 +1,73 @@
+# 016 — Audit round r12: the artifact-chain class, found twice more
+
+## The pattern, now named
+
+Rounds `r7`, `r8`, `r10`, `r12` and `r13` all found the SAME class of defect: a
+phase boundary where the artifact one phase produces is not the artifact the next
+phase reads.
+
+| Round | The gap |
+|-------|---------|
+| r7 | No push between the rebase (WP2) and remote verification (WP3) — lidge would fetch the pre-rebase branch |
+| r8 | That push ran BEFORE WP2b changed code, so lidge would bless a tree without WP2b |
+| r10 | The chain stopped at WP3 — nothing downstream asserted the tree being cut, reviewed, and merged was still the verified SHA |
+| r12 | `VERIFIED_TIP` was recorded AFTER the gates ran, and per-layer gates had no execution site at all |
+| r13 | `PR1_HEAD = ` was prose, not an assignment — under zsh it exits 127 |
+
+Each document reads correctly on its own terms, which is why these survived rounds of
+reading them one at a time. The defect only surfaces when you ask at every boundary:
+what does this side produce, and does the other side bind to it?
+
+## r12 finding 1 (High) — `VERIFIED_TIP` recorded after it was used
+
+`020` created the lidge worktree at an unnamed ``, ran every gate, and only then
+introduced `VERIFIED_TIP` in a section claiming to be "before any gate". If
+`cursor-call` moved during the ~8-minute suite, `030` would bind to a different tree
+than the one tested.
+
+**Closed.** The capture moved to the top of the worktree section: read the LIVE remote
+with `git ls-remote`, assert local and remote agree, create the worktree AT that SHA,
+re-assert `git rev-parse HEAD` inside it before installing. Every gate then runs in
+`/tmp/ocx-cc-${VERIFIED_TIP:0:9}`, so the path itself carries the SHA.
+
+## r12 finding 2 (High) — per-layer evidence had no execution site
+
+`020`'s table said WHAT each layer runs and never WHERE. Running PR1's tests at
+`VERIFIED_TIP` proves nothing about PR1: that tree already contains PR2's and PR3's
+code, so a PR1 test could pass because of something a PR1 reviewer never sees. Yet
+`030` requires each PR body to cite commands, output, and a SHA.
+
+**Closed.** `020` gains the execution procedure and `030` step 5 names the sequencing.
+Because the layer branches do not exist until `030` step 2, WP3 is ordered:
+
+1. gates at `VERIFIED_TIP` — full suite, typecheck, privacy:scan, audit:high,
+ build:gui. This is PR3's evidence.
+2. `030` steps 0-4 — bind, cut, prove the partition, record the head SHAs.
+3. one lidge worktree per layer head, pinned and asserted, running that layer's
+ typecheck plus its own test files.
+4. `030` step 6 — push and open the PRs, each citing its own run.
+
+`PR3_HEAD` equals `VERIFIED_TIP`, so step 1 already covers it. Every layer's evidence
+now names the same SHA `040` asserts with `gh pr view --json headRefOid` before
+merging.
+
+## What r12 confirmed holds
+
+- WP2 → WP2b: checkpoint push and equality assertion exist; WP2b's later push
+ supersedes them as authoritative.
+- WP4 → WP5: expected heads recorded and checked before every merge.
+ `PR3_HEAD = VERIFIED_TIP` is correct because PR3's head IS `cursor-call`.
+- WP5 → WP6: `040` produces `MERGED_DEV`; `050` gates that exact SHA instead of
+ re-reading a moving `dev`.
+- WP6's note requires each gate's command, output, and SHA.
+- Stack proof at the audited tip: no duplicate subjects, zero merges, ancestry chain
+ passes, ranges `17 + 3 + 27 = 47`.
+- Both `010` conflict resolutions and `015`'s failure-specific usage design: no drift.
+
+## Tally
+
+Thirteen rounds, 35 findings, every one verified against the tree and absorbed. Two
+clusters account for most: four rounds on how to split the stack (resolved by
+abandoning subsystem purity — `009`), and five on artifact-chain boundaries (resolved
+here).
+
diff --git a/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md
new file mode 100644
index 0000000000..ab9b54fdf3
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md
@@ -0,0 +1,111 @@
+# 018 — Audit round r14: three defects found by RUNNING the plan
+
+`r14` did what `r13` started: it executed every shell fragment in the unit against a
+scratch zsh with real SHAs substituted, simulating only the mutating commands. Three
+High findings, all closed.
+
+## F1 — the authoritative gates were not bound to the pinned worktree
+
+`020` created `/tmp/ocx-cc-` on lidge, asserted its HEAD, and then listed the
+gates as bare local commands:
+
+ bun x tsc --noEmit
+ bun run privacy:scan
+ ...
+
+Copied into a shell, those run against whatever directory the operator is in. The
+phase could report green for a tree that is not `VERIFIED_TIP` — which is the whole
+thing `r10` and `r12` were about.
+
+**Closed.** The gates are now a loop that runs each one over ssh inside the worktree
+and re-asserts the SHA first:
+
+ ssh lidge "cd $CC_WT && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && $GATE"
+
+## F2 — the per-layer fragment could not run
+
+Two separate breakages in one block:
+
+1. It used `$PR1_HEAD` and `$PR2_HEAD`, which `030` does not assign until step 4,
+ while `020` ordered this section after step 3. A zsh probe with unset-variable
+ checking fails outright.
+2. `bun test ` is a zsh parse error
+ (`unmatched '`), not an instruction. A placeholder inside a code block is a bug.
+
+**Closed.** The section now runs after `030` step 4, spells out `PR1_TESTS` and
+`PR2_TESTS` as real variables, and wraps the per-layer work in a `run_layer` function
+that pins, asserts, installs, typechecks, and runs that layer's files.
+
+## F3 — the sixth artifact-chain gap: no PR BASE assertion
+
+`040` asserted the live `dev` SHA (has `dev` moved?) and each `headRefOid` (has the
+PR head moved?). Neither answers "is this PR still pointing at `dev`". A retarget to
+`main`, or to a parent branch that has since merged, passes both — and
+`gh pr merge` would merge into that base. `030` printed the bases for inspection,
+which is not a gate.
+
+**Closed.** The pre-merge check for every layer is now three assertions together:
+`baseRefName == dev`, `headRefOid == EXPECTED_HEAD`, live `dev == EXPECTED_DEV`.
+
+## The artifact-chain sweep r14 ran
+
+| Boundary | Artifact | Binding | Result |
+|---|---|---|---|
+| WP2 → WP2b | rebased `cursor-call`, `VERIFIED_BASE` | push + remote/local equality | PASS |
+| WP2b → WP3 | WP2b's remote tip | `VERIFIED_TIP` from live remote, local equality, worktree HEAD assertion | PASS |
+| WP3 → WP4 | gate evidence | branch tips bound to `VERIFIED_TIP` | **FAIL → fixed (F1, F2)** |
+| WP4 → WP5 | PR heads + base topology | head + live-`dev` checks | **FAIL → fixed (F3)** |
+| WP5 → WP6 | PR3 merge OID as `MERGED_DEV` | worktree at `MERGED_DEV` + HEAD equality | PASS |
+| WP6 → note | gate outputs | note requires command, output, SHA, fresh refs | PASS |
+
+## What r14 confirmed
+
+- Stack at the audited tip: unique subjects, zero merges, ancestry intact,
+ `17 + 3 + 31 = 51`.
+- Both `010` conflict resolutions still coherent against the live `dev`.
+- `015`'s failure-specific usage choice, re-export, and no-cycle property.
+- `040`'s governance position claims an owner-authorized exception, not compliance.
+- It also ran the focused Cursor tests: **71 pass, 0 fail**, and typecheck exit 0.
+
+## A second r14 pass found three MORE unbound artifacts
+
+The same round, re-run against the fixes above, went through every named variable
+rather than every code block. Six of nine failed:
+
+1. **`VERIFIED_BASE` was captured twice.** `010:166` pins it before the rebase;
+ `020` captured it AGAIN afterwards. A second `ls-remote` overwrites the pin with a
+ newer `dev`, and every later assertion then compares against a base the campaign
+ never rebased onto. `020` now inherits and asserts it
+ (`test -n` + `merge-base --is-ancestor "$VERIFIED_BASE" cursor-call`).
+
+2. **`git branch cursor-call-wire ` fails `zsh -n`.** The angle-bracket form
+ is not shell syntax. `030` captured the boundary SHAs correctly and then did not
+ consume them. Fixed in both the branch creation and the `git show --stat` checks.
+
+3. **`040` carried THREE merge procedures** that disagreed. One referenced `$PRN`,
+ `$PRN_HEAD` and `$PR_NEXT` — none of which any phase assigns. One omitted both the
+ head assertion and the `EXPECTED_DEV` update. The sharpest point: the
+ angle-bracket forms in `040` parse only because zsh reads them as REDIRECTIONS,
+ which is worse than failing — they run and bind nothing.
+
+ Now there is one `merge_layer` function taking the PR number and its expected
+ head, asserting base + head + `EXPECTED_DEV` before merging and advancing
+ `EXPECTED_DEV` from the merge commit inside the function.
+
+Verified by running the chain under zsh: parses clean, extractions return
+`dfb6fb884…` and `6d974428…`, the two boundary commits `030` names.
+
+### Side effect worth recording
+
+The reviewer executed one `020` snippet during isolation and left worktree
+`/tmp/ocx-L-` plus branch `ocx-L-` on lidge. Both removed; nothing was pushed or
+edited there. Worth noting that a READ-ONLY brief still produced remote state — the
+snippets are executable now, which is the point, and an auditor running them is a
+foreseeable consequence. Later briefs say so explicitly.
+
+## Tally
+
+Fourteen rounds, 38 findings, every one verified and absorbed. Six of them are the
+artifact-chain class (`r7`, `r8`, `r10`, `r12`, `r13`, `r14`) and four were the
+stack-split cluster (`r3`-`r6`). The lesson `r13` and `r14` add: a plan that is only
+READ will keep hiding fragments that cannot RUN.
diff --git a/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md b/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md
new file mode 100644
index 0000000000..7dabf115a2
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md
@@ -0,0 +1,185 @@
+# 019 — The plan becomes a program
+
+Fifteen audit rounds. Six of them (`r7`, `r8`, `r10`, `r12`, `r13`, `r14`, `r15`)
+found the same defect class at different altitudes: a binding written as prose that
+no shell would enforce. Each round I fixed the instances it named, and the next round
+found new ones — `VERIFIED_BASE` captured twice, `git branch cursor-call-wire
+` failing `zsh -n`, three contradictory merge ladders, `` that zsh
+reads as a redirection, `PR1_TIP` consumed at line 88 and assigned at line 94, PR
+numbers referenced but never assigned anywhere.
+
+Seven rounds of the same failure is not seven mistakes. It is one wrong idea:
+**I was trying to make a document behave like a program.**
+
+A markdown file cannot enforce that a variable is assigned before it is read. It
+cannot fail. Every fix I wrote was a promise that the next reader would execute the
+fragments in the right order with the right values in scope — and every audit proved
+that promise false, because the promise is unenforceable by construction.
+
+## What changed
+
+`cursor-call-integration.zsh` in this directory is now the executable form of
+`010`/`020`/`030`/`040`/`050`. The decade docs keep their job — the EVIDENCE and the
+REASONING for each decision, which is what a reviewer needs and what a script cannot
+carry. The script owns what runs.
+
+Properties the prose could never have:
+
+- **`set -euo pipefail`** — an unset variable is a hard error, not a silent empty
+ string. The entire class of finding that consumed seven rounds is now impossible.
+- **State on disk** (`.tmp/cursor-call-integration.env`, gitignored). Each step reads
+ what earlier steps recorded, so a compaction or a disconnect costs nothing, and
+ `need VERIFIED_TIP` fails loudly instead of proceeding with an empty value.
+- **Every assertion is a `test` or an `||die`.** There is no printed value for an
+ operator to eyeball.
+- **Idempotent steps** — `git branch -f`, `worktree add ... || true`, and
+ `EXPECTED_DEV` re-derived from state on each run.
+- **Nothing merges or pushes implicitly.** The operator names the step.
+
+## Verified, not asserted
+
+ zsh -n cursor-call-integration.zsh -> PARSE_OK
+ zsh cursor-call-integration.zsh state -> "no state yet"
+ zsh cursor-call-integration.zsh pin -> recorded VERIFIED_BASE=1645bb924…
+ zsh cursor-call-integration.zsh state -> the value persisted
+ zsh cursor-call-integration.zsh cut -> FATAL: VERIFIED_TIP is not set (exit 1)
+ zsh cursor-call-integration.zsh merge -> FATAL: VERIFIED_TIP is not set (exit 1)
+ zsh cursor-call-integration.zsh release_gates -> FATAL: MERGED_DEV is not set (exit 1)
+ zsh cursor-call-integration.zsh record_prs 1 2 -> FATAL: usage (exit 1)
+ zsh cursor-call-integration.zsh bogus -> FATAL: unknown step (exit 1)
+
+Those failures are the point: the ordering the prose could only request, the script
+enforces.
+
+## Step map
+
+| Step | Doc | What it does |
+|------|-----|--------------|
+| `pin` | `010` | `VERIFIED_BASE` from live `ls-remote`, recorded |
+| `rebase` | `010` | rebase onto it, assert ancestry, refuse conflict markers |
+| `push` | `010`/`015` | `--force-with-lease`, assert remote == local |
+| `verify` | `020` | `VERIFIED_TIP`, lidge worktree at that SHA, five gates each re-asserting HEAD |
+| `cut` | `030` | boundaries by subject, three ancestry assertions, count partition, push branches |
+| `verify_layers` | `020`/`030` | push the two layer branches, then typecheck + that layer's own tests AT ITS OWN HEAD |
+| `record_prs` | `030` | operator records the three PR numbers after `gh pr create` |
+| `merge` | `040` | per layer: base==dev, head==expected, live dev==EXPECTED_DEV, then merge and advance from the merge commit |
+| `release_gates` | `050` | `MERGED_DEV` worktree on lidge, five gates |
+| `release_state` | `050` | live main/dev/tags/dist-tags/releases for the readiness note |
+| `cleanup` | `020`/`050` | remove the verification worktrees both phases require removing |
+
+## What round 15 found in the FIRST version of this script
+
+Writing the program did not make the program correct — it made its defects findable.
+The audit ran it and found seven, three of them fatal:
+
+- **`LIDGE_HOME=~/Developer/opencodex` expanded LOCALLY.** zsh resolved `~` to
+ `/Users/jun`, so every ssh command sent a macOS path to a Linux host. The remote
+ gates could not have run at all. Now single-quoted `'$HOME/Developer/opencodex'`,
+ expanded by the remote shell.
+- **`merge` could not resume.** It restarted at PR1 every time, so a disconnect
+ between `gh pr merge` and `save` would re-attempt a merged PR. `merge_layer` now
+ reads the PR state first: `MERGED` adopts its merge commit and returns, `OPEN`
+ proceeds, anything else is fatal.
+- **Layers were never verified at their own heads**, which `AGENTS.md:178-180`
+ requires. `cut` pushed and moved on. That work is now `verify_layers`, and
+ `merge` refuses without `LAYERS_GREEN_AT`.
+
+And four smaller ones: `|| true` on worktree creation swallowed real failures and
+accepted a dirty tree at the right HEAD; neither worktree was ever removed; the
+conflict scan missed a lone `=======`; `push`/`verify` did not require
+`VERIFIED_BASE`; re-running `pin` silently invalidated every downstream artifact;
+and the state file was `source`d without validating what went into it.
+
+Three more I found by running it myself: `save` appended instead of replacing,
+`ROOT` counted `..` wrong and wrote state to `devlog/.tmp/`, and two steps never
+called `load_state`.
+
+Ten defects in a 200-line script, none of which fifteen rounds of reading prose had
+surfaced. That is the case for the rewrite, and also the case for not trusting the
+rewrite until it has been run.
+
+## And round 16 found eight more, one of which bricked the whole thing
+
+The fixes for round 15 introduced a fatal bug and left four holes:
+
+- **The value validator rejected EVERYTHING.** `[[ "$v" == [A-Za-z0-9._/-]## ]]`
+ needs `EXTENDED_GLOB`, which `set -euo pipefail` does not enable. Every `save`
+ died, so `pin` could not record a base and nothing downstream could run at all.
+ The security fix had bricked the script, and `zsh -n` cannot see it because the
+ syntax is valid — only running it shows the match failing.
+- **`LAYERS_GREEN_AT` was a presence check.** It stored `PR2_HEAD` and `merge`
+ only asked whether it was non-empty, so verifying old layers then re-cutting new
+ ones inherited the marker. It now stores `${PR1_HEAD}+${PR2_HEAD}` and `merge`
+ compares it against the current heads.
+- **`--repin` invalidated nothing.** The guard refused a silent re-pin, but the
+ override rewrote `VERIFIED_BASE` and left every downstream artifact looking valid.
+ It now clears them all, which is what makes the guard meaningful.
+- **The MERGED resume path adopted a merge unchecked.** A PR merged by anyone, from
+ any head, into any base, would have been accepted as campaign output. It now
+ asserts base, head, and that the merge commit contains the verified head.
+- **PR3's merge not being on `dev` was a log line.** Now fatal — the release gates
+ would otherwise run on a commit that never landed.
+- **`cleanup` could not clean a failed run.** The worktree paths were saved only
+ after all gates passed, so the failure case left them unreachable. Saved on
+ creation now.
+- **`release_state` did not require the release gates**, so a readiness note could
+ be prepared before anything verified the merged tree.
+- **`rebase` restarted instead of continuing.** `010` expects two conflicts; a
+ re-run mid-rebase would have discarded the resolution. It now detects
+ `rebase-merge`/`rebase-apply` and continues.
+
+Plus one of my own: the driver never passed `$@` to `step_pin`, so `--repin` could
+not reach the guard it was written for.
+
+Verified after the fixes: `pin` records, `record_prs` stores three numbers, the
+re-pin guard refuses, and `pin --repin` clears every downstream key.
+
+## Round 16's second pass: seven more, and a rule about save ordering
+
+- **A MERGED layer did not prove its predecessor.** It checked base, head, and that
+ the merge contained the verified head — but not that it was merged ONTO the
+ `EXPECTED_DEV` this campaign produced. A merge made after an unrelated commit
+ landed on `dev` would have been adopted as the next layer. Now the merge commit's
+ first parent must equal `EXPECTED_DEV`.
+- **`MERGED_DEV` was saved BEFORE its proofs.** A disconnect or a failing ancestry
+ assertion left a durable key that `release_gates` trusts on the next invocation —
+ reopening the exact bypass the proof existed to close. It is now the last thing
+ `merge` does.
+
+ The general rule this produces: **a durable key is a CLAIM that its proofs passed,
+ so it is written after them, never before.** That is now true of every `save` in
+ the file except the worktree paths, which are deliberately the opposite — they are
+ cleanup handles, so they must exist BEFORE the thing that might fail.
+- **`--repin` orphaned the remote worktrees.** It cleared `CC_WORKTREE` and
+ `DEV_WORKTREE` without removing them first, so `cleanup` lost its only handle. It
+ now removes them before forgetting them.
+- **A failed per-layer gate left an untracked worktree.** `LAYER_WORKTREE` is
+ recorded before the gates and cleared on success.
+- **`subject_sha` could return two SHAs.** Two commits sharing a subject would have
+ produced a two-line "SHA" that every later assertion compared against. It now
+ fails unless exactly one matches.
+- **Gate markers were not evidence.** `020` and `050` require the command, its
+ output and the SHA. Every gate now appends a receipt to
+ `.tmp/cursor-call-receipts.log`, including on failure, and the readiness note
+ quotes from it.
+- **`record_prs` recorded three unchecked numbers.** It now verifies each PR points
+ at its layer head and carries all three template sections. The body's SUBSTANCE
+ stays the agent's to write; its STRUCTURE is checkable, so it is checked.
+
+Round 16 also drew the line the script should not cross: creating the PRs and
+authoring their descriptions, the `docs-site/` determination, the readiness note's
+judgment, and semantic conflict resolution are all work that requires understanding
+what the change means. The script validates those afterwards rather than inventing
+them.
+
+`record_prs` is deliberately manual: PR numbers do not exist until `gh pr create`
+returns them, and inventing a way to guess them would reintroduce exactly the
+unbound-value problem this file exists to end.
+
+## What the docs still own
+
+The script says what runs. It does not say why dev's error-event EOF shape beat ours,
+why WP2b must use `partialUsageFromEventState` rather than `resolvedTurnUsage`, why
+the stack splits where it does, or why the merge is an owner-authorized exception
+rather than policy compliance. Those live in `010`, `015`, `030` and `040`, and a
+reviewer needs them more than they need the commands.
diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md
new file mode 100644
index 0000000000..7766d89d0c
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md
@@ -0,0 +1,210 @@
+# 020 — WP3: full remote verification on ssh lidge
+
+> **EXECUTION AUTHORITY: `cursor-call-integration.zsh verify`.**
+> The commands below are the reasoning, not the runbook. Seven audit rounds proved a
+> markdown file cannot enforce that a variable is bound before it is read (`019`), so
+> the script owns what runs and this doc owns why. If they disagree the script is
+> right and this doc is stale — fix the doc.
+
+Revised by audit `r1` F5 (gates moved before the PR) and audit `r3` F1+F2 (base
+pinning, and never `checkout -f` a shared checkout).
+
+## Why remote, and why the FULL suite
+
+The campaign touches `src/bridge.ts`, `src/adapters/google.ts`,
+`src/adapters/anthropic.ts`, `src/adapters/command-code.ts` — shared runtime, not a
+scoped adapter change. `AGENTS.md` §Commands requires `bun run typecheck` and
+`bun run test` before a non-trivial PR is review-ready.
+
+Standing user contract: the authoritative suite runs on `ssh lidge`, never locally.
+
+lidge: `/home/lidgeai/Developer/opencodex`, bun 1.3.14, 16 cores.
+
+`--isolate` is required: the flat suite bleeds environment between files without it.
+
+## Use a DEDICATED worktree, never `checkout -f` the shared clone (r3 F2)
+
+`~/Developer/opencodex` is a shared working checkout, and `git checkout -f` there
+would silently discard any tracked uncommitted work. `git worktree list` on lidge
+already shows a dozen `/tmp/ocx-*` verification worktrees, so this is the
+established pattern there:
+
+Capture the tip FIRST and build the worktree AT it, so the SHA this phase records is
+provably the SHA it tested (audit `r13`):
+
+ VERIFIED_TIP=$(git ls-remote origin refs/heads/cursor-call | cut -f1)
+ test "$VERIFIED_TIP" = "$(git rev-parse cursor-call)" # local and remote agree
+ ssh lidge "cd ~/Developer/opencodex && git fetch origin cursor-call dev && git worktree add /tmp/ocx-cc-${VERIFIED_TIP:0:9} $VERIFIED_TIP"
+ ssh lidge "cd /tmp/ocx-cc-${VERIFIED_TIP:0:9} && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && bun install --frozen-lockfile"
+
+Every gate below then runs in `/tmp/ocx-cc-${VERIFIED_TIP:0:9}`.
+
+`VERIFIED_TIP` is the tip pushed at the END of WP2b, not `010`'s post-rebase checkpoint
+push (audit `r8`). WP2b changes code after `010` step 7 runs, so verifying the
+earlier tip would authoritatively bless a tree without WP2b in it. Both work-phases
+push and assert `git ls-remote` matches `git rev-parse cursor-call`; this phase
+consumes the later one. Confirm the SHA here too before installing:
+
+```
+ssh lidge 'cd ~/Developer/opencodex && git rev-parse origin/cursor-call' # == local rebase tip
+```
+
+Remove the worktree when the phase closes (`git worktree remove`), and never touch
+the shared checkout's HEAD.
+
+## Pin the base (r3 F1), and remember it EVOLVES (r4 F1)
+
+`dev` moves, so `VERIFIED_BASE` is captured ONCE — in `010` step 1, at the moment
+the rebase runs — and inherited here. Do NOT re-capture it in this phase (audit
+`r14`): a second `ls-remote` after the rebase would silently overwrite the pin with
+a newer `dev`, and every later assertion would then compare against a base the
+campaign never rebased onto. Assert instead:
+
+ test -n "$VERIFIED_BASE"
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call # exit 0
+
+`VERIFIED_BASE` is what `010` step 1 rebased ONTO — not
+`origin/dev`, which can be minutes stale (`scripts/release.ts:327-335` uses
+`ls-remote` for exactly this reason). Observed drift during planning alone:
+`87f7f970b` → `e1bdbc1e5` → `1645bb924`.
+
+`VERIFIED_BASE` is the value `040` checks before merging PR1. It then becomes each
+layer's merge result in turn (`040`'s `EXPECTED_DEV`), because after PR1 lands the
+live `dev` head legitimately differs from the original.
+
+## Gates
+
+Every gate runs INSIDE the pinned worktree, over ssh, with the SHA re-asserted first
+(audit `r14`). Written as bare local commands they would execute against whatever
+directory the operator happens to be in, and could pass for a tree that is not
+`VERIFIED_TIP`:
+
+ CC_WT="/tmp/ocx-cc-${VERIFIED_TIP:0:9}"
+ for GATE in \
+ "bun x tsc --noEmit" \
+ "bun run privacy:scan" \
+ "bun run audit:high" \
+ "bun run build:gui" \
+ "bun test --isolate tests"
+ do
+ ssh lidge "cd $CC_WT && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && $GATE"
+ done
+
+`build:gui` precedes the suite only because it is the shorter of the two long gates;
+order is not load-bearing. Run the suite as a managed background session and poll.
+
+`audit:high` and `privacy:scan` are in `scripts/release.ts:374,380`.
+`build:gui` is here because `prepublishOnly` (`package.json:49`) runs it on every
+publish regardless of whether `gui/` changed, and it also runs `prepare:package`.
+"No gui/ path changed" is therefore not a reason to skip it for a readiness claim.
+
+Run the suite and the gui build as managed background sessions and poll.
+
+## Expected evidence
+
+- `bun x tsc --noEmit` → exit 0, no output.
+- `bun run privacy:scan` → exit 0.
+- `bun run audit:high` → exit 0. If it reports a pre-existing advisory that also
+ fails at `VERIFIED_BASE`, record that comparison rather than blaming this branch.
+- `bun test --isolate tests` → **0 fail**. Pass counts move as dev grows; the bar is
+ 0 fail. (Data points: 12761 at the old base, 12800 at the campaign tip.)
+- `bun run build:gui` → exit 0.
+
+## Platform gap (state it, do not paper over it)
+
+lidge is Linux. Repository CI covers Linux, Windows, and macOS. This campaign's
+28-path diff contains no shim, installer, PowerShell, platform dispatch, or Windows
+path handling — verified in audit `r3`. That is why Linux evidence is adequate *for
+this diff*, and it is not a claim that Linux equals CI.
+
+## Known flake (do NOT call it a regression without isolation)
+
+`tests/request-pacing.test.ts` and `tests/codex-auth-api.test.ts` have failed under
+parallel load and passed in isolation on BOTH the pre- and post-campaign SHAs. If
+either fails, re-run that file alone first.
+
+## Repair discipline
+
+LOOP-REPAIR-01: read the failure delta, repair only that delta, re-verify. Two
+consecutive failed repairs of the same failure → root-cause mode. Three → back to P
+with a changed plan.
+
+## Verification (C)
+
+Typecheck, privacy:scan, audit:high, and build:gui each exit 0, and `0 fail` from
+`bun test --isolate tests` — each quoted with the SHA it ran against, plus the
+recorded `VERIFIED_BASE`.
+
+## Record `VERIFIED_TIP` (audit `r10`)
+
+The SHA these gates ran against is the ONLY tree this campaign has authoritative
+evidence for. It is captured ABOVE, before the worktree is created — not here, and
+not after the gates (audit `r13`): a value read afterwards could differ from the tree
+that was actually tested if `cursor-call` moved during the ~8-minute suite.
+
+Every later phase binds to it: `030` refuses to cut branches unless `cursor-call`
+still equals `VERIFIED_TIP`, and `040` compares each PR's `headRefOid` against its
+expected SHA immediately before merging. Without that chain, a force-push to any PR
+head could introduce commits nobody verified while `040`'s post-merge ancestry check
+still passes — the verified tip stays an ancestor either way.
+
+## Per-layer verification (r3 F3)
+
+Because `030` now opens a real 3-PR stack, each layer needs its own evidence
+(`AGENTS.md:178-180`). Full suite on the TOP of the stack; per-layer verification is
+typecheck plus the tests that layer owns:
+
+| Layer | Focused tests |
+|-------|---------------|
+| PR1 (Cursor EOF + tool-result wire) | `tests/cursor-eof-terminal.test.ts`, `tests/cursor-hardening.test.ts`, `tests/cursor-tool-result-image.test.ts`, `tests/cursor-request-builder.test.ts` |
+| PR2 (unexpected CANCEL) | `tests/cursor-cancel-provenance.test.ts`, `tests/cursor-hardening.test.ts` |
+| PR3 (bridge/adapter terminals + **WP2b**) | `tests/bridge-nonstreaming-terminal.test.ts`, `tests/anthropic-error-stop-reason.test.ts`, `tests/command-code-error-finish.test.ts`, `tests/google-buffered-stop-reason.test.ts`, `tests/cursor-eof-terminal.test.ts`, `tests/cursor-interaction-query.test.ts` + FULL suite |
+
+WP2b and `tests/cursor-interaction-query.test.ts` are BOTH in PR3 (audit `r6`
+finding 3 resolved this way rather than by moving WP2b down). `r4` F4's rule was
+right — a change and its contract test belong in the same layer — and the honest
+placement is PR3, where WP2b lands chronologically. PR1 stays correct without it:
+PR1 makes a truncated turn reportable, PR3 makes it report tokens.
+
+`tests/cursor-eof-terminal.test.ts` appears in both PR1 and PR3 because WP2b adds
+cases to it. Each layer runs the file as it stands at that layer.
+
+### Run them AT the layer tips, not at the stack tip (audit `r12`)
+
+The table above says WHAT each layer runs; without this it never said WHERE. Running
+PR1's tests at `VERIFIED_TIP` proves nothing about PR1, because that tree already
+contains PR2's and PR3's code — a PR1 test could pass only because of something a
+reviewer of PR1 will never see.
+
+The layer branches do not exist until `030` step 2, and their head variables are not
+assigned until `030` step 4 (audit `r14`: an earlier draft of this section used
+`$PR1_HEAD` before that assignment). So this half of WP3 runs AFTER `030` step 4:
+
+1. `020` first half: the gate loop above at `VERIFIED_TIP` — the stack-tip evidence
+ PR3 cites.
+2. `030` steps 0-4: bind to `VERIFIED_TIP`, cut `cursor-call-wire` and
+ `cursor-call-cancel`, prove the partition, and assign `PR1_HEAD`/`PR2_HEAD`.
+3. `020` this half: one worktree per layer, pinned to that layer's head. The test
+ file list is spelled out per layer rather than left as a placeholder — a literal
+ `` is a zsh parse error, not an instruction:
+
+ PR1_TESTS="tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts tests/cursor-tool-result-image.test.ts tests/cursor-request-builder.test.ts"
+ PR2_TESTS="tests/cursor-cancel-provenance.test.ts tests/cursor-hardening.test.ts"
+
+ run_layer() {
+ local SHA="$1" TESTS="$2" WT="/tmp/ocx-L-${1:0:9}"
+ ssh lidge "cd ~/Developer/opencodex && git fetch origin && git worktree add $WT $SHA"
+ ssh lidge "cd $WT && test \"\$(git rev-parse HEAD)\" = \"$SHA\" && bun install --frozen-lockfile"
+ ssh lidge "cd $WT && bun x tsc --noEmit"
+ ssh lidge "cd $WT && bun test $TESTS"
+ }
+
+ run_layer "$PR1_HEAD" "$PR1_TESTS"
+ run_layer "$PR2_HEAD" "$PR2_TESTS"
+
+ `PR3_HEAD` equals `VERIFIED_TIP` and step 1 already covered it — do not re-run.
+4. `030` step 6: push the branches and open the PRs, each citing ITS OWN run.
+
+Every layer's evidence therefore names a SHA equal to that PR's head, which is the
+same SHA `040` asserts with `gh pr view --json headRefOid` before merging. Remove the
+worktrees when the phase closes.
diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md
new file mode 100644
index 0000000000..6a8e5d26a8
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md
@@ -0,0 +1,194 @@
+# 030 — WP4: the stacked pull requests against dev
+
+> **EXECUTION AUTHORITY: `cursor-call-integration.zsh cut | record_prs`.**
+> The commands below are the reasoning, not the runbook. Seven audit rounds proved a
+> markdown file cannot enforce that a variable is bound before it is read (`019`), so
+> the script owns what runs and this doc owns why. If they disagree the script is
+> right and this doc is stale — fix the doc.
+
+Five versions. `r1` F4 killed a fabricated split; `r3` F3 found phase boundaries;
+`r4` F2 killed the commit-range version for "ownership impurity"; `r5` killed the
+ownership version's procedure; `r6` killed the forward-construction version on
+ancestry, overbroad pathspecs, and WP2b landing in the wrong layer.
+
+Four consecutive failures on the same question is the signal to re-examine the
+question, not to patch the fifth answer (LOOP-REPAIR-01).
+
+## The mistake was mine, and it was a category error
+
+I was requiring the stack layers to be **subsystem-pure** — each layer touching only
+its own files. That came from `r4` F2's observation that the top commit range also
+edits PR1-owned files, which I read as a defect in the split.
+
+It is not a defect. **A stacked PR does not promise subsystem purity. It promises
+reviewable increments in dependency order.** Every mechanism I then invented to
+achieve purity — cherry-pick, `rebase -i` split, forward tree copy — broke a
+different git invariant, because purity requires moving content between commits and
+the history is already final.
+
+The natural stack is the rebased history itself, cut at existing commits.
+
+## The stack
+
+After the WP2 rebase, `VERIFIED_BASE..cursor-call` is one linear history. Cut it:
+
+| PR | Head branch | Base | Range | Content |
+|----|-------------|------|-------|---------|
+| 1 | `cursor-call-wire` | `VERIFIED_BASE` | `VERIFIED_BASE..PR1_TIP` | Decode research docs + Cursor wire hardening: the EOF resolution (`emittedTerminal`), tool-result image encoder, and their tests |
+| 2 | `cursor-call-cancel` | `cursor-call-wire` | `PR1_TIP..PR2_TIP` | Unexpected server-side CANCEL provenance (reads PR1's `emittedTerminal`) |
+| 3 | `cursor-call` | `cursor-call-cancel` | `PR2_TIP..cursor-call` | Bridge/adapter terminal semantics, WP2b, the integration unit, and the late honesty corrections to PR1's files |
+
+`PR1_TIP` = the rebased commit whose subject is
+`docs(devlog): record what shipped for 010 and 020, and why 030 did not`.
+`PR2_TIP` = the rebased `docs(devlog): record what shipped for 040`.
+Both subjects are unique in the range — verified in `r5`:
+`git log --format='%s' VERIFIED_BASE..cursor-call | sort | uniq -d` returns nothing.
+
+Every property the previous four versions fought for is now free:
+
+- **Ancestry** is automatic — all three branches are commits on one linear history,
+ so `cursor-call-wire` is an ancestor of `cursor-call-cancel` is an ancestor of
+ `cursor-call`. This was `r6`'s finding 1.
+- **Union = the branch** is automatic — the three ranges partition the history
+ exactly. This was `r4` F2, `r5` F2, and `r6` F4.
+- **No commit ever moves**, so nothing can be dropped or duplicated.
+
+## What PR3 legitimately contains, stated up front
+
+PR3's range includes three kinds of change a reviewer should expect:
+
+1. Bridge/adapter terminal work — its main subject.
+2. **WP2b** (the EOF truncation error's partial usage). It edits
+ `protobuf-events.ts`, which PR1's EOF resolution selects, so a reader might expect
+ it in PR1. It is in PR3 because that is where it lands chronologically, and PR1 is
+ not *wrong* without it — PR1 makes truncation reportable, PR3 makes it report
+ tokens. That is a normal stacked increment. `tests/cursor-interaction-query.test.ts`
+ (WP2b's contract test) is therefore also PR3's, which resolves `r4` F4 the other
+ way: both move together.
+3. **Late corrections to PR1-owned files** — `2ea12062d`'s comment fixes in
+ `request-builder.ts` and two cursor tests, and `be1b881ec`'s two decode docs. These
+ are the honesty corrections from audits `r1`/`r2`. Say so in PR3's body rather
+ than letting a reviewer wonder why a bridge PR touches a cursor comment.
+
+## Procedure
+
+Run after the WP2 rebase and WP2b are on `cursor-call`. Nothing here rewrites
+anything.
+
+0. **Bind to the verified tree (audit `r10`).** `cursor-call` is mutable and `020`
+ verified one specific SHA:
+
+ test "$(git rev-parse cursor-call)" = "$VERIFIED_TIP"
+
+ If it fails, the branch moved after verification and the gates no longer describe
+ what is about to be reviewed. Re-run `020` rather than cutting branches from an
+ unverified tree.
+
+1. Find the boundaries in the REBASED history (the rebase preserves order, and the
+ original SHAs no longer exist):
+
+ git log --format='%h %s' "$VERIFIED_BASE"..cursor-call
+
+ Read `PR1_TIP` and `PR2_TIP` off that list by subject, then confirm each:
+
+ git show --stat "$PR1_TIP" # must be the 010/020 shipped-record doc commit
+ git show --stat "$PR2_TIP" # must be the 040 shipped-record doc commit
+
+ Bind them to variables rather than reading them by eye, so step 5's assertions
+ have something to compare against:
+
+ PR1_TIP=$(git log --format='%H %s' "$VERIFIED_BASE"..cursor-call | grep -F 'record what shipped for 010 and 020' | cut -d' ' -f1)
+ PR2_TIP=$(git log --format='%H %s' "$VERIFIED_BASE"..cursor-call | grep -F 'record what shipped for 040' | cut -d' ' -f1)
+ test -n "$PR1_TIP" && test -n "$PR2_TIP"
+
+2. Create the branches at those commits, consuming the captured variables (audit
+ `r14`: the angle-bracket form is not shell syntax and fails `zsh -n`):
+
+ git branch cursor-call-wire "$PR1_TIP"
+ git branch cursor-call-cancel "$PR2_TIP"
+
+3. Prove the stack mechanically — all three ancestry assertions plus the count
+ identity must pass:
+
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire # exit 0
+ git merge-base --is-ancestor cursor-call-wire cursor-call-cancel # exit 0
+ git merge-base --is-ancestor cursor-call-cancel cursor-call # exit 0
+ git rev-list --count "$VERIFIED_BASE"..cursor-call-wire
+ git rev-list --count cursor-call-wire..cursor-call-cancel
+ git rev-list --count cursor-call-cancel..cursor-call
+ # the counts must sum to:
+ git rev-list --count "$VERIFIED_BASE"..cursor-call
+
+ The FIRST assertion is not redundant (audit `r8`): `rev-list --count A..B` counts
+ commits reachable from B and not A even when A is not an ancestor of B, so the
+ three counts can sum correctly while the bottom of the stack does not actually sit
+ on the verified base. Demonstrated: against `dev` at `1645bb924`,
+ `git merge-base --is-ancestor 1645bb924 dfb6fb884` exits 1 while the counts still
+ add up. Only the ancestry chain `VERIFIED_BASE → wire → cancel → tip`, together
+ with the counts, establishes the partition.
+
+4. **Record each PR's expected head SHA as real variables.** `040` asserts these
+ immediately before merging. Written as executable assignments, not a legend — a
+ probe of the earlier prose form exited 127 under zsh because `NAME = value` runs
+ `NAME` as a command (audit `r13`):
+
+ PR1_HEAD=$(git rev-parse cursor-call-wire)
+ PR2_HEAD=$(git rev-parse cursor-call-cancel)
+ PR3_HEAD=$(git rev-parse cursor-call)
+
+ Then assert they are the SHAs step 1 identified and step 0 pinned, which is what
+ binds the branch tips to the verified tree (the ancestry and count checks above
+ prove topology, not identity):
+
+ test "$PR1_HEAD" = "$PR1_TIP"
+ test "$PR2_HEAD" = "$PR2_TIP"
+ test "$PR3_HEAD" = "$VERIFIED_TIP"
+
+5. **Run each layer's gates before opening its PR (audit `r12`).** The layer branches
+ only exist from step 2 onward, which is why `020`'s per-layer section runs HERE
+ rather than earlier: one lidge worktree pinned to each layer head, per the
+ procedure in `020`. A PR body must cite a run at ITS OWN head — the stack-tip run
+ belongs to PR3 alone, because PR1's tests passing at the stack tip prove nothing
+ about a tree that excludes PR2 and PR3.
+
+6. Push the two new branches and open the PRs bottom-up, each citing its own run.
+
+## Policy constraints (`AGENTS.md`)
+
+- `dev` is the only integration target. Never `main`.
+- Stacked children targeting an OPEN parent's head branch are intentional;
+ `enforce-target` skips the wrong-base gate for them (`AGENTS.md:218-225`).
+ Retarget each child to `dev` after its parent lands.
+- `.github/PULL_REQUEST_TEMPLATE.md` requires **Summary**, **Verification**,
+ **Checklist**; `enforce-target` rejects thin descriptions.
+- Each layer carries its OWN verification evidence (`AGENTS.md:178-180`), per `020`.
+
+## Description content
+
+- **Summary** — the defect and the wire behavior before/after for that layer. Three
+ mandatory honest notes: (a) in PR1, that dev independently fixed the clean-EOF
+ defect and our surviving contribution is `emittedTerminal` plus one guard;
+ (b) wherever tool-result images appear, that the ENCODER supports them and nothing
+ reaches Cursor today because all Cursor models are in `noVisionModels`; (c) in PR3,
+ that its range also carries WP2b and the late corrections to PR1-owned files, and
+ why.
+- **Verification** — that layer's own commands, output, and SHA.
+- **Checklist** — three boxes, honestly, `docs-site/` determination made here.
+- No `Closes #`.
+
+## Verification (C)
+
+```
+gh pr list --state open --json number,baseRefName,headRefName,title
+gh pr view --json body
+```
+
+PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; step 3's
+ancestry chain and count identity recorded; all three template sections non-thin in
+each.
+
+## Fallback
+
+If step 3 fails — which would mean the rebase did not preserve order as expected —
+open ONE PR from `cursor-call` to `dev` and say why in the body. Do not invent a
+sixth splitting scheme.
diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md
new file mode 100644
index 0000000000..5c3d542e73
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md
@@ -0,0 +1,160 @@
+# 040 — WP5: merge the stack onto dev + ancestry proof
+
+> **EXECUTION AUTHORITY: `cursor-call-integration.zsh merge`.**
+> The commands below are the reasoning, not the runbook. Seven audit rounds proved a
+> markdown file cannot enforce that a variable is bound before it is read (`019`), so
+> the script owns what runs and this doc owns why. If they disagree the script is
+> right and this doc is stale — fix the doc.
+
+Revised by `r1` F2 (governance honesty), `r3` F1 (base pinning), and `r4` F1 (the
+pin has to EVOLVE through the stack).
+
+## Authority, stated precisely
+
+The user granted admin merge authority for this branch ("admin 권한으로") and waived
+CI checking. That is the repository owner exercising owner authority.
+
+What it is NOT: compliance with `MAINTAINERS.md:48-49`, which requires maintainer
+approval **and** successful required CI checks before merge. `AGENTS.md:251-253`
+makes `MAINTAINERS.md` authoritative.
+
+So each merge is an **owner-authorized exception**:
+
+- lidge is Linux; CI covers Linux + Windows + macOS.
+- This diff touches no Windows-sensitive surface — no shims, installer, PowerShell,
+ platform dispatch, or Windows path handling (verified in `r3` and `r4`).
+- `050`'s note may say "gates green on Linux; CI waived by the owner". It may **not**
+ say "policy-compliant" or "all required checks passed".
+
+If the user wants full compliance instead, let required CI run on each PR head
+before merging. One-line change to this plan.
+
+## `EXPECTED_DEV` evolves — it is not one frozen SHA (r4 F1)
+
+`VERIFIED_BASE` (the SHA WP3 verified against) is correct as the value to check
+before PR1. After PR1 merges, `dev` legitimately moves to PR1's merge result, so
+comparing PR2 against the original value would fail by construction.
+
+The invariant is: **before merging layer N, the live `dev` head must equal the SHA
+that layer N's base was verified against.** Maintain one variable:
+
+ EXPECTED_DEV="$VERIFIED_BASE" # from 020, the rebase target
+
+`EXPECTED_DEV` advances to the MERGE COMMIT of the layer just landed, not to a fresh
+read of `dev` — same reason as `MERGED_DEV` below (audit `r13`). The single
+executable merge ladder is in **Procedure** below; this section only states the
+invariant (audit `r14`: three scattered half-procedures disagreed with each other).
+
+Read the live head with `git ls-remote origin refs/heads/dev` every time, never
+`origin/dev` — the tracking ref goes stale within minutes
+(`scripts/release.ts:327-335` uses `ls-remote` for exactly this reason). Observed
+drift during planning alone: `87f7f970b` → `e1bdbc1e5` → `1645bb924`.
+
+**If a check fails**, someone else pushed to `dev`. Stop: rebase the remaining
+layers onto the new head, re-run the affected gates from `020`, and update
+`EXPECTED_DEV`. Merging a stale base lets GitHub construct a merge result nobody
+tested and put it on `dev` — and the ancestry check below runs afterwards, too late
+to prevent it.
+
+## Procedure
+
+Merge in dependency order. The full ladder is at the end of this section; the two
+subsections below explain why each of its three assertions exists.
+
+### Also assert the PR HEAD, not just the base (audit `r10`)
+
+The base check proves `dev` has not moved. It says nothing about what the PR itself
+now points at. A force-push to a PR head — by anyone, including a well-meaning
+rebase — would merge commits that never went through `020`'s gates, and the
+post-merge ancestry check below would still pass, because the verified tip remains an
+ancestor of a superset.
+
+So before EACH merge, compare the PR's live head against the SHA `030` step 5
+recorded:
+
+ test "$(gh pr view --json headRefOid --jq .headRefOid)" = "$PR1_HEAD"
+ gh pr merge --merge --admin
+
+`test`, not a printed value: a comparison the operator has to eyeball is not a gate
+(audit `r13`). Repeat with `$PR2_HEAD` and `$PR3_HEAD` for the other two layers.
+
+### And assert the PR's live BASE (audit `r14`)
+
+The two checks above cover "has `dev` moved" and "has the PR head moved". Neither
+covers "is this PR still pointing at `dev`". A retarget — to `main`, or to a parent
+branch that has since merged — passes both, and `gh pr merge` would then merge into
+whatever base the PR now names. `030` prints the bases for inspection, which is not a
+gate.
+
+So the pre-merge check for EVERY layer is all three at once. This is the ONE merge
+ladder for the whole phase — `PR1`/`PR2`/`PR3` are the numbers `030` step 4 returns
+when the PRs are opened, and `PR1_HEAD`/`PR2_HEAD`/`PR3_HEAD` are the SHAs `030`
+step 5 captured:
+
+ merge_layer () { # $1 = PR number, $2 = its expected head SHA
+ local pr="$1" expected_head="$2"
+ test "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" = "dev"
+ test "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" = "$expected_head"
+ test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV"
+ gh pr merge "$pr" --merge --admin
+ EXPECTED_DEV=$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid)
+ test -n "$EXPECTED_DEV"
+ }
+
+ merge_layer "$PR1" "$PR1_HEAD"
+ gh pr edit "$PR2" --base dev # parent landed; retarget the child
+ merge_layer "$PR2" "$PR2_HEAD"
+ gh pr edit "$PR3" --base dev
+ merge_layer "$PR3" "$PR3_HEAD"
+
+`EXPECTED_DEV` advances inside the function, from the merge commit of the layer just
+landed — so the next layer's base assertion compares against what THIS campaign
+produced, not against a fresh read that would absorb someone else's push.
+
+`dev` is the only acceptable base for all three layers at merge time: PR1 targets it
+from the start, and PR2/PR3 are retargeted to it as their parents land
+(`AGENTS.md:218-225`). A base of `main` would be a policy violation, and a base still
+naming a merged parent branch would produce an empty or wrong diff.
+
+PR3's expected head is `VERIFIED_TIP` — the exact SHA `020` ran the full suite
+against. If any head differs, stop: either re-verify that tree through `020` or
+reset the branch to the recorded SHA. Never merge a head no gate has seen.
+
+Do NOT squash. The commit-by-commit history is the audit trail for five campaign
+phases plus four integration audit rounds, and the devlog references specific SHAs.
+
+## Ancestry proof (the actual criterion)
+
+A merge API response is not proof:
+
+```
+git fetch origin dev
+git merge-base --is-ancestor origin/dev # exit 0
+git log --oneline -10 origin/dev
+```
+
+## Verification (C)
+
+For each layer: the pre-merge `ls-remote` SHA equal to the then-current
+`EXPECTED_DEV`, recorded. Then exit 0 from `--is-ancestor` for the final tip, plus
+the `origin/dev` log showing all three merges.
+
+## Hand `MERGED_DEV` to WP6
+
+After PR3 merges, take the result from the MERGE ITSELF rather than re-reading a
+mutable ref — a fresh `ls-remote` would silently pick up a concurrent push and
+attribute someone else's commit to this campaign (audit `r13`):
+
+ MERGED_DEV=$(gh pr view --json mergeCommit --jq .mergeCommit.oid)
+ test -n "$MERGED_DEV"
+ git fetch origin dev
+ test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$MERGED_DEV" # nobody pushed after us
+ git merge-base --is-ancestor "$VERIFIED_TIP" "$MERGED_DEV" # exit 0
+
+If the third assertion fails, someone pushed after PR3 landed. That is not
+necessarily wrong, but `050` must then gate `MERGED_DEV` explicitly and say in the
+readiness note that `dev` has moved past it.
+
+`050` gates exactly that SHA. Same reason as every other named artifact here: a
+phase that re-reads a mutable ref is not verifying what the previous phase produced
+(audits `r7`, `r8`, `r10`).
diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md
new file mode 100644
index 0000000000..6cb8ee20d8
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md
@@ -0,0 +1,101 @@
+# 050 — WP6: release gates on dev + go/no-go note
+
+> **EXECUTION AUTHORITY: `cursor-call-integration.zsh release_gates`.**
+> The commands below are the reasoning, not the runbook. Seven audit rounds proved a
+> markdown file cannot enforce that a variable is bound before it is read (`019`), so
+> the script owns what runs and this doc owns why. If they disagree the script is
+> right and this doc is stale — fix the doc.
+
+Revised by audit `r1` F5 (these are a RE-RUN, not first contact — first contact is
+`020`, before the PRs) and audit `r3` F2/F4/F5.
+
+## Scope boundary (explicit)
+
+IN: re-running the gates on merged `dev` and writing an evidence-backed readiness
+note.
+
+OUT unless the user says otherwise: `npm publish`, any version bump, `main`
+promotion, tag creation. `scripts/release.ts` is the release authority and the
+repository's OIDC workflow is the only publish mechanism — never a direct
+`npm publish`.
+
+## Gates (dedicated worktree, r3 F2)
+
+`MERGED_DEV` is inherited from `040` — the OID of PR3's merge commit, not a fresh
+read. Build the worktree at it and assert what landed there:
+
+ DEVDIR=/tmp/ocx-dev-${MERGED_DEV:0:9}
+ ssh lidge "cd ~/Developer/opencodex && git fetch origin dev && git worktree add $DEVDIR $MERGED_DEV"
+ ssh lidge "cd $DEVDIR && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\" && bun install --frozen-lockfile"
+ ssh lidge "cd $DEVDIR && bun x tsc --noEmit"
+ ssh lidge "cd $DEVDIR && bun run privacy:scan"
+ ssh lidge "cd $DEVDIR && bun run audit:high"
+ ssh lidge "cd $DEVDIR && bun run build:gui"
+ ssh lidge "cd $DEVDIR && bun test --isolate tests"
+
+`MERGED_DEV` is the SHA `dev` carried when PR3 landed, taken from the merge commit
+itself in `040` and already proven to descend from `VERIFIED_TIP`. Do not
+substitute a fresh read of `origin/dev`: if someone else pushed in between, these
+gates would describe a tree this campaign never produced, and a green result would be
+attributed to work that is not ours (audit `r13` sweep).
+
+Never `checkout -f` the shared `~/Developer/opencodex`. Remove the worktree when
+done.
+
+`build:gui` is NOT optional for a readiness claim even though no `gui/` path
+changed: `prepublishOnly` (`package.json:49`) runs `audit:high`, `typecheck`, and
+`build:gui` on every publish, and `build:gui` also runs `prepare:package`
+(`package.json:46-47`). `lint:gui` stays N/A with its evidence
+(`git diff --name-only` showing no `gui/` paths).
+
+## Docs-site determination (must already be made at `030`)
+
+- Cursor tool-result images: the encoder supports them, production strips them
+ upstream (`005` F1). **Do not document a capability that does not reach the
+ provider.** If `docs-site/` says the Cursor adapter cannot send images, that text
+ is still accurate end-to-end and stays.
+- Truncated-turn reporting (`failed` instead of `completed`) is a correctness fix in
+ a failure path, not a documented feature. No docs change.
+
+Record the determination and its reasoning; a bare "no docs needed" is not evidence.
+
+## Live refs, read at write time (r3 F5)
+
+Do not copy a ref from this plan into the note. Re-read them, using the live-remote
+discipline of `scripts/release.ts:327-335`:
+
+```
+git ls-remote origin refs/heads/main refs/heads/dev
+git ls-remote --tags origin | tail -5
+npm view @bitkyc08/opencodex dist-tags
+gh release list --limit 3
+```
+
+Known drift already observed: `main` was `474584bcd` when the campaign started,
+then `0013b2347`, and the plan's own draft was stale within the hour. `v2.24.2` the
+TAG still points at `474584bcd`, which is a different thing from the `main` tip —
+state both, do not conflate them.
+
+## Go/no-go note
+
+Write `060_release_readiness.md` with:
+
+- every gate, its command, its output, and the SHA it ran against;
+- the governance position from `040` verbatim: gates green on Linux, CI waived by
+ the owner, each merge an owner-authorized exception;
+- **what publication would still require even after a go decision**: the release
+ authority waits for a successful Cross-platform CI run AND a successful Service
+ lifecycle run at the exact release SHA (`scripts/release.ts:393-401`). A readiness
+ note that omits this implies publishing is one command away when it is not;
+- the open follow-ups from `000` a reader would otherwise assume were fixed —
+ especially F1, since the campaign's own docs previously overstated it;
+- whether `dev` is releasable as-is;
+- an explicit recommendation on cutting a version, with the reason, against the
+ freshly-read version state. A provider-correctness batch of this size is a
+ minor-bump candidate, but the decision is the maintainer's — state the
+ recommendation, do not act on it.
+
+## Verification (C)
+
+All gate commands exit 0 at a named `dev` SHA, the live refs in the note match a
+`git ls-remote` run recorded alongside them, and the note is committed.
diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh
new file mode 100755
index 0000000000..cc7db1499c
--- /dev/null
+++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh
@@ -0,0 +1,434 @@
+#!/usr/bin/env zsh
+# cursor-call integration driver — the executable form of
+# devlog/_plan/260818_cursor_call_integration/{010,020,030,040,050}.
+#
+# Fifteen audit rounds found the same defect class over and over: a binding written
+# as prose that no shell would enforce. Six rounds of fixing the prose kept producing
+# new unbound variables, because a document is not a program. This file is the program.
+# The decade docs explain WHY each assertion exists; this decides WHAT runs.
+#
+# Every step is idempotent to re-run and refuses to continue on a failed assertion.
+# Nothing here merges or pushes without the operator invoking that step by name.
+
+set -euo pipefail
+# `##` in a pattern needs EXTENDED_GLOB. Without it the validator below rejected
+# every value, including SHAs — the script could not save anything at all (audit r16).
+setopt EXTENDED_GLOB
+
+# Ask git for the root rather than counting `..` — the script lives three levels down
+# (devlog/_plan//), and an off-by-one put the state file in devlog/.tmp/, which
+# is a different directory that happens to also be gitignored. A wrong-but-hidden
+# path is exactly the kind of thing this script exists to stop trusting.
+ROOT="$(cd "${0:A:h}" && git rev-parse --show-toplevel)" || { print -r -- "[cc] FATAL: not in a git repo" >&2; exit 1 }
+cd "$ROOT"
+STATE="$ROOT/.tmp/cursor-call-integration.env"
+mkdir -p "${STATE:h}"
+
+log () { print -r -- "[cc] $*" >&2 }
+die () { print -r -- "[cc] FATAL: $*" >&2; exit 1 }
+# Rewrite the key rather than appending: a re-run must not leave two rows for one
+# artifact, because `source` would take the last and a reader would see the first.
+save () {
+ local key="$1" value="$2" tmp="$STATE.tmp"
+ # The state file is `source`d, so an unvalidated value is code. Only SHAs, PR
+ # numbers and plain identifiers ever go in here (audit r15).
+ [[ "$value" == [A-Za-z0-9._/+-]## ]] \
+ || die "refusing to save $key: value is not a plain token"
+ : >| "$tmp"
+ [[ -f "$STATE" ]] && grep -v "^${key}=" "$STATE" >> "$tmp" || true
+ print -r -- "${key}=${value}" >> "$tmp"
+ mv "$tmp" "$STATE"
+ typeset -g "$key"="$value"
+ log "recorded $key=$value"
+}
+
+# Re-reading state is what makes each step independently re-runnable after a
+# compaction, a disconnect, or a day off.
+load_state () { [[ -f "$STATE" ]] && source "$STATE" || true }
+
+# 020 and 050 require the command, its output and the SHA it ran against as evidence.
+# A marker in the state file is not that, so every gate also appends a receipt here
+# (audit r16). The readiness note quotes from it.
+RECEIPTS="$ROOT/.tmp/cursor-call-receipts.log"
+receipt () {
+ local sha="$1" cmd="$2"; shift 2
+ {
+ print -r -- "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) sha=$sha"
+ print -r -- "$ cd && $cmd"
+ print -r -- "$@"
+ } >> "$RECEIPTS"
+}
+
+need () {
+ local name="$1"
+ [[ -n "${(P)name:-}" ]] || die "$name is not set — run the earlier step first (state: $STATE)"
+}
+
+live_dev () { git ls-remote origin refs/heads/dev | cut -f1 }
+live_branch () { git ls-remote origin "refs/heads/$1" | cut -f1 }
+
+# Create the worktree only if it is missing, then prove it is at the expected SHA AND
+# clean. `|| true` on the add would swallow a real failure, and a pre-existing dirty
+# worktree can sit at the right HEAD while its tree says something else (audit r15).
+remote_worktree () {
+ local wt="$1" sha="$2"
+ ssh lidge "cd $LIDGE_HOME && { git worktree list --porcelain | grep -qx 'worktree $wt' || git worktree add $wt $sha; }" \
+ || die "could not create $wt at $sha on lidge"
+ ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && test -z \"\$(git status --porcelain)\"" \
+ || die "$wt is not a clean checkout of $sha"
+}
+
+remote_worktree_remove () {
+ ssh lidge "cd $LIDGE_HOME && git worktree remove --force $1" \
+ || log "NOTE: could not remove $1 — remove it by hand"
+}
+
+# ---------------------------------------------------------------- 010: rebase
+
+step_pin () {
+ load_state
+ # Re-pinning after downstream artifacts exist silently invalidates them: every
+ # later assertion would compare against a base the branch was never rebased onto
+ # (audit r15). Force the operator to be explicit.
+ if [[ -n "${VERIFIED_TIP:-}" && "${1:-}" != "--repin" ]]; then
+ die "VERIFIED_TIP already exists; re-pinning invalidates it. Re-run from scratch, or pass --repin and then re-run rebase/push/verify/cut."
+ fi
+ # --repin means every downstream artifact describes a base that no longer applies.
+ # Drop them rather than leaving stale values that still look valid (audit r16).
+ if [[ "${1:-}" == "--repin" && -f "$STATE" ]]; then
+ # Remove the remote worktrees BEFORE forgetting their paths, or --repin orphans
+ # them on lidge with no handle left to clean them up (audit r16).
+ [[ -n "${CC_WORKTREE:-}" ]] && remote_worktree_remove "$CC_WORKTREE"
+ [[ -n "${DEV_WORKTREE:-}" ]] && remote_worktree_remove "$DEV_WORKTREE"
+ local keep; keep="$STATE.keep"
+ grep -vE '^(VERIFIED_BASE|VERIFIED_TIP|GATES_GREEN_AT|LAYERS_GREEN_AT|PR[123]_HEAD|PR[123]|EXPECTED_DEV|MERGED_DEV|RELEASE_GATES_GREEN_AT|CC_WORKTREE|DEV_WORKTREE)=' "$STATE" > "$keep" || : >| "$keep"
+ mv "$keep" "$STATE"
+ log "--repin: cleared every downstream artifact; re-run rebase, push, verify, cut, verify_layers"
+ fi
+ git fetch origin dev
+ local base; base="$(live_dev)"
+ [[ -n "$base" ]] || die "could not read live dev"
+ save VERIFIED_BASE "$base"
+}
+
+step_rebase () {
+ load_state; need VERIFIED_BASE
+ git rev-parse --verify cursor-call-prerebase-260818 >/dev/null \
+ || die "snapshot branch missing — it is the only recovery path"
+ # 010 EXPECTS two conflicts. Re-running this step mid-rebase must continue, never
+ # restart — `git rebase ` on a conflicted tree aborts with its own error and
+ # would lose the resolution (audit r16).
+ if [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; then
+ log "a rebase is in progress — continuing it"
+ git rebase --continue
+ else
+ git rebase "$VERIFIED_BASE"
+ fi
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \
+ || die "rebase did not land on VERIFIED_BASE"
+ ! grep -rEn "^(<<<<<<<|>>>>>>>|=======$)" src tests >/dev/null 2>&1 \
+ || die "conflict markers survived the rebase"
+}
+
+step_push () {
+ load_state
+ need VERIFIED_BASE
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \
+ || die "cursor-call is not on VERIFIED_BASE — run rebase before pushing"
+ git push --force-with-lease --no-verify origin cursor-call
+ [[ "$(live_branch cursor-call)" == "$(git rev-parse cursor-call)" ]] \
+ || die "remote cursor-call does not match local after push"
+}
+
+# ------------------------------------------------------- 020: remote verification
+
+# Single-quoted and NOT tilde-expanded: zsh would expand ~ to the LOCAL home, and
+# /Users/jun/... does not exist on lidge. The remote shell expands this (audit r15).
+LIDGE_HOME='$HOME/Developer/opencodex'
+
+step_verify () {
+ load_state
+ need VERIFIED_BASE
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \
+ || die "cursor-call is not on VERIFIED_BASE — verifying the wrong tree"
+ local tip; tip="$(live_branch cursor-call)"
+ [[ "$tip" == "$(git rev-parse cursor-call)" ]] \
+ || die "local and remote cursor-call disagree — push first"
+ save VERIFIED_TIP "$tip"
+ local wt="/tmp/ocx-cc-${tip:0:9}"
+ ssh lidge "cd $LIDGE_HOME && git fetch origin cursor-call dev" || die "lidge fetch failed"
+ remote_worktree "$wt" "$tip"
+ save CC_WORKTREE "$wt"
+ ssh lidge "cd $wt && bun install --frozen-lockfile"
+ local gate
+ for gate in "bun x tsc --noEmit" "bun run privacy:scan" "bun run audit:high" "bun run build:gui" "bun test --isolate tests"; do
+ log "gate: $gate"
+ local out
+ out="$(ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\" && $gate" 2>&1)" \
+ || { receipt "$tip" "$gate" "$out"; die "gate failed at $tip: $gate" }
+ receipt "$tip" "$gate" "$(print -r -- "$out" | tail -20)"
+ done
+ save GATES_GREEN_AT "$tip"
+}
+
+# --------------------------------------------------------------- 030: the stack
+
+# Exactly one match, or fail. Two commits sharing a subject would otherwise return
+# two SHAs and every later assertion would compare against a two-line string (r16).
+subject_sha () {
+ local hits; hits="$(git log --format="%H %s" "$VERIFIED_BASE"..cursor-call | grep -F "$1" || true)"
+ local n; n="$(print -r -- "$hits" | grep -c . || true)"
+ [[ "$n" -eq 1 ]] || die "subject '$1' matched $n commits, expected exactly 1"
+ print -r -- "$hits" | cut -d" " -f1
+}
+
+step_cut () {
+ load_state; need VERIFIED_BASE; need VERIFIED_TIP; need GATES_GREEN_AT
+ [[ "$(git rev-parse cursor-call)" == "$VERIFIED_TIP" ]] \
+ || die "cursor-call moved since verification — re-run step_verify"
+ [[ "$GATES_GREEN_AT" == "$VERIFIED_TIP" ]] \
+ || die "the gates were green for a different tree"
+ local p1 p2
+ p1="$(subject_sha "record what shipped for 010 and 020")"
+ p2="$(subject_sha "record what shipped for 040")"
+ [[ -n "$p1" && -n "$p2" ]] || die "could not locate both stack boundaries by subject"
+ [[ "$(print -r -- "$p1" | wc -l)" -eq 0 ]] || true
+ git branch -f cursor-call-wire "$p1"
+ git branch -f cursor-call-cancel "$p2"
+ git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire || die "wire is not on the verified base"
+ git merge-base --is-ancestor cursor-call-wire cursor-call-cancel || die "cancel is not on wire"
+ git merge-base --is-ancestor cursor-call-cancel cursor-call || die "tip is not on cancel"
+ local a b c total
+ a="$(git rev-list --count "$VERIFIED_BASE"..cursor-call-wire)"
+ b="$(git rev-list --count cursor-call-wire..cursor-call-cancel)"
+ c="$(git rev-list --count cursor-call-cancel..cursor-call)"
+ total="$(git rev-list --count "$VERIFIED_BASE"..cursor-call)"
+ (( a + b + c == total )) || die "layers $a+$b+$c do not partition $total"
+ log "partition ok: $a + $b + $c = $total"
+ save PR1_HEAD "$p1"
+ save PR2_HEAD "$p2"
+ save PR3_HEAD "$VERIFIED_TIP"
+ log "layers cut; run 'verify_layers' before pushing (AGENTS.md:178-180 wants each layer verified at its own SHA)"
+}
+
+# AGENTS.md requires each non-trivial PR to carry its own verification, so a layer is
+# gated at ITS head, not at the tip's. Full suite stays on the tip (step_verify);
+# here each lower layer gets typecheck plus the tests it owns.
+PR1_TESTS="tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts tests/cursor-tool-result-image.test.ts tests/cursor-request-builder.test.ts"
+PR2_TESTS="tests/cursor-cancel-provenance.test.ts tests/cursor-hardening.test.ts"
+
+verify_layer () {
+ local sha="$1" tests="$2" wt="/tmp/ocx-layer-${1:0:9}"
+ ssh lidge "cd $LIDGE_HOME && git fetch origin cursor-call-wire cursor-call-cancel" || die "lidge fetch failed"
+ # Recorded before the gates so a failure leaves a cleanup handle (audit r16).
+ save LAYER_WORKTREE "$wt"
+ remote_worktree "$wt" "$sha"
+ ssh lidge "cd $wt && bun install --frozen-lockfile"
+ ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && bun x tsc --noEmit" \
+ || die "typecheck failed at layer $sha"
+ ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && bun test $tests" \
+ || die "focused tests failed at layer $sha"
+ remote_worktree_remove "$wt"
+ save LAYER_WORKTREE "none"
+}
+
+step_verify_layers () {
+ load_state; need PR1_HEAD; need PR2_HEAD; need GATES_GREEN_AT
+ git push --no-verify origin cursor-call-wire cursor-call-cancel
+ verify_layer "$PR1_HEAD" "$PR1_TESTS"
+ verify_layer "$PR2_HEAD" "$PR2_TESTS"
+ # Record WHICH heads were verified, so a later re-cut invalidates the marker
+ # instead of inheriting it (audit r16).
+ save LAYERS_GREEN_AT "${PR1_HEAD}+${PR2_HEAD}"
+ log "layers verified — open the PRs bottom-up, then run: record_prs "
+}
+
+# PR numbers are recorded by the operator right after `gh pr create`, because only
+# then do they exist. Every later step asserts them rather than assuming.
+step_record_prs () {
+ load_state
+ [[ $# -eq 3 ]] || die "usage: step_record_prs "
+ need PR1_HEAD; need PR2_HEAD; need PR3_HEAD
+ # 030 requires each PR to point at its layer head and to carry all three template
+ # sections. Recording three unchecked numbers would let a mislabeled PR through
+ # (audit r16). The BODY's substance is the agent's to write; its STRUCTURE is
+ # checkable, so check it.
+ local i=1 pr head body
+ for pr in "$1" "$2" "$3"; do
+ [[ "$pr" == [0-9]## ]] || die "PR $pr is not a number"
+ case $i in
+ 1) head="$PR1_HEAD" ;;
+ 2) head="$PR2_HEAD" ;;
+ 3) head="$PR3_HEAD" ;;
+ esac
+ [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$head" ]] \
+ || die "PR $pr does not point at its layer head $head"
+ body="$(gh pr view "$pr" --json body --jq .body)"
+ local section
+ for section in "## Summary" "## Verification" "## Checklist"; do
+ print -r -- "$body" | grep -qF "$section" \
+ || die "PR $pr is missing the '$section' section the template requires"
+ done
+ (( i++ ))
+ done
+ save PR1 "$1"; save PR2 "$2"; save PR3 "$3"
+}
+
+# ---------------------------------------------------------------- 040: the merge
+
+# Retargeting a merged PR is an error; retargeting one already on dev is a no-op.
+retarget_to_dev () {
+ local pr="$1"
+ local state; state="$(gh pr view "$pr" --json state --jq .state)"
+ [[ "$state" == "OPEN" ]] || { log "PR $pr is $state — no retarget needed"; return 0 }
+ [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \
+ && { log "PR $pr already targets dev"; return 0 }
+ gh pr edit "$pr" --base dev
+}
+
+merge_layer () {
+ local pr="$1" expected_head="$2"
+ [[ -n "$pr" && -n "$expected_head" ]] || die "merge_layer needs a PR number and its expected head"
+ # Resume, not restart. A merged layer is DONE: adopt its merge commit as the
+ # current EXPECTED_DEV and move on, so a disconnect between `gh pr merge` and
+ # `save` costs nothing and a re-run is a no-op (audit r15).
+ local state; state="$(gh pr view "$pr" --json state --jq .state)"
+ if [[ "$state" == "MERGED" ]]; then
+ local oid; oid="$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid)"
+ [[ -n "$oid" ]] || die "PR $pr reports MERGED with no merge commit"
+ # Adopting a merge unchecked would accept a PR someone merged from a different
+ # head, or into a different base, as this campaign's output (audit r16).
+ [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \
+ || die "PR $pr was merged into a base other than dev"
+ [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$expected_head" ]] \
+ || die "PR $pr was merged from a head we never verified"
+ git fetch origin dev >/dev/null 2>&1 || true
+ git merge-base --is-ancestor "$expected_head" "$oid" \
+ || die "PR $pr's merge commit does not contain the verified head"
+ # And it must have been merged ONTO the predecessor this campaign produced. A
+ # merge made after an unrelated commit landed on dev is someone else's history,
+ # not the next layer of this stack (audit r16). First parent == base at merge.
+ local first_parent; first_parent="$(git rev-parse "${oid}^1" 2>/dev/null || true)"
+ [[ "$first_parent" == "$EXPECTED_DEV" ]] \
+ || die "PR $pr was merged onto $first_parent, not the expected $EXPECTED_DEV"
+ save EXPECTED_DEV "$oid"
+ log "PR $pr already merged — adopted $oid"
+ return 0
+ fi
+ [[ "$state" == "OPEN" ]] || die "PR $pr is $state, neither OPEN nor MERGED"
+ [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \
+ || die "PR $pr does not target dev"
+ [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$expected_head" ]] \
+ || die "PR $pr head moved off the verified SHA"
+ [[ "$(live_dev)" == "$EXPECTED_DEV" ]] \
+ || die "dev moved since the last layer — rebase and re-verify"
+ gh pr merge "$pr" --merge --admin
+ EXPECTED_DEV="$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid)"
+ [[ -n "$EXPECTED_DEV" ]] || die "could not read the merge commit for PR $pr"
+ save EXPECTED_DEV "$EXPECTED_DEV"
+}
+
+step_merge () {
+ load_state
+ need VERIFIED_BASE; need VERIFIED_TIP
+ need PR1; need PR2; need PR3
+ need PR1_HEAD; need PR2_HEAD; need PR3_HEAD
+ need LAYERS_GREEN_AT
+ [[ "$LAYERS_GREEN_AT" == "${PR1_HEAD}+${PR2_HEAD}" ]] \
+ || die "the layer gates were green for different heads ($LAYERS_GREEN_AT) — re-run verify_layers"
+ EXPECTED_DEV="${EXPECTED_DEV:-$VERIFIED_BASE}"
+ merge_layer "$PR1" "$PR1_HEAD"
+ retarget_to_dev "$PR2"
+ merge_layer "$PR2" "$PR2_HEAD"
+ retarget_to_dev "$PR3"
+ merge_layer "$PR3" "$PR3_HEAD"
+ local merged; merged="$(gh pr view "$PR3" --json mergeCommit --jq .mergeCommit.oid)"
+ [[ -n "$merged" ]] || die "PR3 has no merge commit"
+ git fetch origin dev
+ git merge-base --is-ancestor "$VERIFIED_TIP" "$merged" \
+ || die "the verified tip is not an ancestor of the merge result"
+ # dev may legitimately advance past our merge, but the merge must BE on dev — a
+ # merge commit that never landed there would send the release gates somewhere else.
+ git merge-base --is-ancestor "$merged" "$(live_dev)" \
+ || die "PR3's merge commit is not on dev"
+ # Saved LAST: a durable MERGED_DEV is a claim that both proofs passed, and
+ # release_gates trusts it on a later invocation (audit r16).
+ save MERGED_DEV "$merged"
+ [[ "$(live_dev)" == "$merged" ]] \
+ || log "NOTE: dev has moved past our merge — 050 must say so in the readiness note"
+}
+
+# ------------------------------------------------------- 050: release gates on dev
+
+step_release_gates () {
+ load_state; need MERGED_DEV
+ local wt="/tmp/ocx-dev-${MERGED_DEV:0:9}"
+ ssh lidge "cd $LIDGE_HOME && git fetch origin dev" || die "lidge fetch failed"
+ remote_worktree "$wt" "$MERGED_DEV"
+ save DEV_WORKTREE "$wt"
+ ssh lidge "cd $wt && bun install --frozen-lockfile"
+ local gate
+ for gate in "bun x tsc --noEmit" "bun run privacy:scan" "bun run audit:high" "bun run build:gui" "bun test --isolate tests"; do
+ log "dev gate: $gate"
+ local out
+ out="$(ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\" && $gate" 2>&1)" \
+ || { receipt "$MERGED_DEV" "$gate" "$out"; die "release gate failed on dev: $gate" }
+ receipt "$MERGED_DEV" "$gate" "$(print -r -- "$out" | tail -20)"
+ done
+ save RELEASE_GATES_GREEN_AT "$MERGED_DEV"
+}
+
+# 050 requires the readiness note to quote LIVE release state, never a cached ref.
+# Printed for the note, not saved: these are facts about a moment, not artifacts the
+# later steps assert against.
+step_release_state () {
+ load_state; need MERGED_DEV; need RELEASE_GATES_GREEN_AT
+ [[ "$RELEASE_GATES_GREEN_AT" == "$MERGED_DEV" ]] \
+ || die "the release gates were green for $RELEASE_GATES_GREEN_AT, not $MERGED_DEV"
+ print -r -- "MERGED_DEV=$MERGED_DEV"
+ print -r -- "live main=$(git ls-remote origin refs/heads/main | cut -f1)"
+ print -r -- "live dev=$(live_dev)"
+ print -r -- "--- latest tags"
+ git ls-remote --tags origin | tail -5
+ print -r -- "--- npm dist-tags"
+ npm view @bitkyc08/opencodex dist-tags 2>&1 | head -10
+ print -r -- "--- releases"
+ gh release list --limit 3 2>&1 | head -5
+ print -r -- "--- write 060_release_readiness.md with the gate output, these refs, the"
+ print -r -- "--- governance position from 040, the open follow-ups, and a go/no-go."
+}
+
+# 020 and 050 both require the verification worktrees to be removed when their phase
+# closes. Kept as its own step so a failed gate leaves the tree available to inspect.
+step_cleanup () {
+ load_state
+ [[ -n "${CC_WORKTREE:-}" ]] && remote_worktree_remove "$CC_WORKTREE"
+ [[ -n "${DEV_WORKTREE:-}" ]] && remote_worktree_remove "$DEV_WORKTREE"
+ [[ -n "${LAYER_WORKTREE:-}" && "$LAYER_WORKTREE" != "none" ]] && remote_worktree_remove "$LAYER_WORKTREE"
+ log "cleanup done"
+}
+
+# ------------------------------------------------------------------------ driver
+
+main () {
+ local step="${1:-}"
+ [[ -n "$step" ]] || die "usage: cursor-call-integration.zsh [args] — steps: pin rebase push verify cut verify_layers record_prs merge release_gates release_state cleanup state"
+ shift
+ case "$step" in
+ pin) step_pin "$@" ;;
+ rebase) step_rebase ;;
+ push) step_push ;;
+ verify) step_verify ;;
+ cut) step_cut ;;
+ verify_layers) step_verify_layers ;;
+ record_prs) step_record_prs "$@" ;;
+ merge) step_merge ;;
+ release_gates) step_release_gates ;;
+ release_state) step_release_state ;;
+ cleanup) step_cleanup ;;
+ state) load_state; [[ -f "$STATE" ]] && cat "$STATE" || log "no state yet" ;;
+ *) die "unknown step: $step" ;;
+ esac
+}
+
+main "$@"
diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts
index 6eb8085be0..fd141ccfb8 100644
--- a/src/adapters/anthropic.ts
+++ b/src/adapters/anthropic.ts
@@ -989,6 +989,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
const emitDone = function* (): Generator {
if (emittedDone) return;
emittedDone = true;
+ // An `error` stop reason is a failed generation, not a stop. Forwarding it as `done`
+ // lets the turn report success and install replacement history on a compaction turn.
+ if (pendingStopReason === "error") {
+ yield {
+ type: "error",
+ message: "upstream ended the turn with stop_reason \"error\"",
+ status: 502,
+ errorType: "upstream_error",
+ usage: usageFromAnthropic(pendingUsage),
+ };
+ return;
+ }
yield {
type: "done",
usage: usageFromAnthropic(pendingUsage),
@@ -1143,6 +1155,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
if (!emittedDone) {
// Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
if (pendingStopReason !== undefined) {
+ // Same rule as emitDone: an `error` stop reason is a failed generation, not a stop.
+ // This branch bypasses emitDone entirely (it exists for providers that close after
+ // message_delta without message_stop), so the check has to be repeated here or the
+ // EOF route silently reports success.
+ if (pendingStopReason === "error") {
+ emittedDone = true;
+ yield {
+ type: "error",
+ message: "upstream ended the turn with stop_reason \"error\"",
+ status: 502,
+ errorType: "upstream_error",
+ usage: usageFromAnthropic(pendingUsage),
+ };
+ return;
+ }
const stopReason = pendingStopReason === "max_tokens"
? "max_tokens"
: pendingStopReason === "refusal" || pendingStopReason === "content_filter"
@@ -1210,6 +1237,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
}
const usage = json.usage as Record | undefined;
const stopReason = typeof json.stop_reason === "string" ? json.stop_reason : undefined;
+ // An Anthropic-compatible upstream can forward an `error` stop reason verbatim. As a
+ // `done` it reads as a clean completion, so the turn reports success and — on a compaction
+ // turn — installs its partial summary as replacement history (#422). Usage is preserved:
+ // a failed turn still consumed tokens.
+ if (stopReason === "error") {
+ events.push({
+ type: "error",
+ message: "upstream ended the turn with stop_reason \"error\"",
+ status: 502,
+ errorType: "upstream_error",
+ usage: usageFromAnthropic(usage),
+ });
+ retainTranslatedEventBatch(events, budget);
+ return events;
+ }
events.push({
type: "done",
usage: usageFromAnthropic(usage),
diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts
index 5299b455c2..156ba5130a 100644
--- a/src/adapters/command-code.ts
+++ b/src/adapters/command-code.ts
@@ -553,6 +553,23 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA
sawFinish = true;
const usageValue = event.totalUsage ?? event.usage;
const stopReason = typeof event.rawFinishReason === "string" ? event.rawFinishReason : typeof event.finishReason === "string" ? event.finishReason : undefined;
+ // The AI SDK's `error` finish reason means the generation failed upstream, not that it
+ // stopped. Reporting it as a `done` left the bridge to infer failure from a stop-reason
+ // string, which either read as a clean completion or (once classified) mislabelled an
+ // upstream error as a content filter and rejected it from the replay cache for the
+ // wrong reason.
+ if (stopReason === "error") {
+ // Keep the usage: a failed turn still consumed tokens, and dropping it makes the
+ // turn look free in accounting and reports zeros to the client.
+ yield {
+ type: "error",
+ message: "Command Code upstream ended the turn with finishReason \"error\"",
+ status: 502,
+ errorType: "upstream_error",
+ usage: usage(usageValue),
+ };
+ break;
+ }
yield { type: "done", usage: usage(usageValue), stopReason };
break;
}
diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts
index 5344689c99..da003f85a0 100644
--- a/src/adapters/cursor/request-builder.ts
+++ b/src/adapters/cursor/request-builder.ts
@@ -207,10 +207,12 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri
case "image":
// User-message images are still flattened here: this path builds the plain-text prompt, and
// the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not
- // populated by this adapter. Tool-result images DO reach Cursor as real McpImageContent
+ // populated by this adapter. The tool-result ENCODER does build real McpImageContent
// (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no
- // longer true of the adapter as a whole. Kept the same length to avoid shifting any
- // byte-budgeted prompt path.
+ // longer true of the encoder — but note that nothing reaches Cursor today either way:
+ // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar
+ // describes or strips images before this adapter runs. Kept the same length to avoid
+ // shifting any byte-budgeted prompt path.
return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`;
case "toolCall":
// Cursor does not accept OpenAI Responses assistant tool-call parts as native history here.
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 7f42938c93..ac496d46d6 100644
--- a/src/adapters/google.ts
+++ b/src/adapters/google.ts
@@ -946,9 +946,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}
const usage = json.usageMetadata as Record | undefined;
+ // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter
+ // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated
+ // turn as completed — and, on a compaction turn, installs the half-written summary as
+ // replacement history (#422).
+ const finishReason = candidates?.[0]?.finishReason as string | undefined;
+ const stopReason = finishReason === "MAX_TOKENS"
+ ? "max_tokens"
+ : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "")
+ ? "content_filter"
+ : undefined;
events.push({
type: "done",
usage: usageFromGemini(usage),
+ ...(stopReason ? { stopReason } : {}),
});
return finish(events);
},
diff --git a/src/bridge.ts b/src/bridge.ts
index c34e91734a..ebbf2c7cb9 100644
--- a/src/bridge.ts
+++ b/src/bridge.ts
@@ -9,6 +9,7 @@ import type {
import { coerceIntegerToolArguments } from "./lib/tool-argument-integers";
import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
import { encodeCompactionSummary } from "./responses/compaction";
+import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
import {
@@ -1154,7 +1155,11 @@ export function bridgeToResponsesSSE(
// After every close above, so the blob lands AFTER the assistant message it belongs
// to and the parser's backwards pairing finds it.
flushKiroRedactedReasoning();
- if (options?.compaction) {
+ // Truncated turns must never install replacement history (#422). The buffered path
+ // has always checked this; streaming emitted the item BEFORE reading stopReason, so
+ // a max_tokens/content_filter turn shipped a half-written summary and then declared
+ // itself incomplete — the same hazard, one branch over.
+ if (options?.compaction && !isTruncatedStopReason(event.stopReason)) {
// Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
const item = {
type: "compaction", id: `cmp_${uuid()}`,
@@ -1164,14 +1169,18 @@ export function bridgeToResponsesSSE(
retainFinishedItem(item as OutputItem, compactionTextBytes);
outputIndex++;
}
- if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") {
+ // Recognize every adapter's truncation vocabulary, not just the canonical pair.
+ // Suppression and terminal status must agree: withholding the compaction item while
+ // still reporting success hands codex-rs a completed response with zero compaction
+ // items, which it treats as fatal.
+ if (truncationReasonFor(event.stopReason)) {
// Upstream stopped before a normal completion. Surface as incomplete so the
// client can distinguish a truncated/filtered turn from a finished one.
const response = {
...responseSnapshot("incomplete", finishedItems, event.endTurn),
usage: responsesUsage(event.usage),
incomplete_details: {
- reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter",
+ reason: truncationReasonFor(event.stopReason) ?? "content_filter",
},
};
// Cache max-output partials so previous_response_id replay can continue them;
@@ -1467,7 +1476,15 @@ function buildResponseJSONWithBudget(
let incompleteEvent: Extract | undefined;
let endTurn: boolean | undefined;
let stopReason: string | undefined;
+ // The adapter's stop reason exactly as it arrived. `stopReason` above is deliberately narrowed
+ // to the two reasons that map onto a Responses `incomplete_details`; the raw value is what the
+ // truncation guard needs, because adapters disagree on vocabulary (`length`, `refusal`, ...).
+ let rawStopReason: string | undefined;
let cleanDone = false;
+ // Whether the adapter emitted ANY terminal (done/error/incomplete). Distinct from `cleanDone`,
+ // which is only true for a `done` without a stop reason. A buffered turn whose adapter simply
+ // stopped emitting has no terminal at all, and must not be reported as a success.
+ let sawTerminal = false;
let compactionText = "";
let compactionTextBytes = 0;
@@ -1782,20 +1799,30 @@ function buildResponseJSONWithBudget(
break;
case "error":
errorEvent = e;
+ sawTerminal = true;
usage = e.usage ?? usage;
break;
case "incomplete":
incompleteEvent = e;
+ sawTerminal = true;
endTurn = e.endTurn;
if (e.providerState) options?.onProviderState?.(e.providerState);
break;
case "done":
usage = e.usage;
+ sawTerminal = true;
endTurn = e.endTurn;
cleanDone = e.stopReason === undefined;
+ rawStopReason = e.stopReason;
if (e.providerState) options?.onProviderState?.(e.providerState);
// Match streaming: max_tokens and content_filter both terminate as incomplete.
- if (e.stopReason === "max_tokens" || e.stopReason === "content_filter") stopReason = e.stopReason;
+ // Normalize every adapter's truncation vocabulary to the canonical pair, so a raw
+ // `length` or `refusal` reaches the status/incomplete_details logic below instead of
+ // silently reading as a clean stop.
+ {
+ const truncation = truncationReasonFor(e.stopReason);
+ if (truncation) stopReason = truncation === "max_output_tokens" ? "max_tokens" : "content_filter";
+ }
break;
}
if (budget) releaseTranslatedEvent(e, budget);
@@ -1803,8 +1830,11 @@ function buildResponseJSONWithBudget(
flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined);
flushSummaryReasoning();
flushRawReasoning();
- // Open tool call on a failed/incomplete turn must not land as status:"completed".
- if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed");
+ // Open tool call on a failed/incomplete turn must not land as status:"completed" — and neither
+ // must one left open by a stream that stopped without any terminal at all. That case previously
+ // fell through to "completed", handing back a function_call whose arguments were half-written
+ // JSON, inside a turn also marked completed.
+ if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent || !sawTerminal ? "incomplete" : "completed");
if (batchKiroRedacted) {
// pushOutput reserves the item itself and releases the retained raw blob it replaces.
pushOutput({
@@ -1820,8 +1850,12 @@ function buildResponseJSONWithBudget(
options?.compaction
&& !errorEvent
&& !incompleteEvent
- && stopReason !== "max_tokens"
- && stopReason !== "content_filter"
+ // A stream that stopped without any terminal did not complete either. The original guard
+ // could only see explicit failure events, so an adapter EOF slipped past it and installed a
+ // truncated summary as replacement history — the exact #422 hazard, reached by a route that
+ // did not exist when the guard was written.
+ && sawTerminal
+ && !isTruncatedStopReason(rawStopReason)
) {
pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes);
}
@@ -1831,7 +1865,13 @@ function buildResponseJSONWithBudget(
? "failed"
: incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter"
? "incomplete"
- : "completed";
+ : sawTerminal
+ ? "completed"
+ // The adapter stopped emitting without any terminal, so the turn was cut short. Streaming
+ // already reports this as response.incomplete / adapter_eof (see the !terminated branch);
+ // defaulting the buffered path to "completed" handed callers a truncated turn — including
+ // one carrying a never-closed tool call with half-written JSON arguments — as a success.
+ : "incomplete";
options?.onUsage?.(incompleteEvent?.usage ?? usage);
return {
id: responseId, object: "response",
@@ -1851,6 +1891,10 @@ function buildResponseJSONWithBudget(
incomplete_details: { reason: "max_output_tokens" },
} : stopReason === "content_filter" ? {
incomplete_details: { reason: "content_filter" },
+ } : !sawTerminal ? {
+ // Same reason string the streaming path uses, so a caller sees one signal for one condition
+ // regardless of which surface it asked for.
+ incomplete_details: { reason: "adapter_eof" },
} : {}),
usage: responsesUsage(incompleteEvent?.usage ?? usage),
};
diff --git a/src/responses/truncated-stop-reason.ts b/src/responses/truncated-stop-reason.ts
new file mode 100644
index 0000000000..85042a763f
--- /dev/null
+++ b/src/responses/truncated-stop-reason.ts
@@ -0,0 +1,60 @@
+/**
+ * Whether a `done` event's `stopReason` means the turn was cut short rather than finishing, and
+ * which Responses `incomplete_details.reason` it maps to.
+ *
+ * `stopReason` is an open-ended string and adapters do not agree on a vocabulary: openai-chat and
+ * google normalize to `max_tokens`/`content_filter`, Command Code forwards the raw provider or AI
+ * SDK value (`length`, `content-filter`, `error`), and Anthropic forwards `stop_reason` verbatim
+ * (`refusal`, `model_context_window_exceeded`, ...). A guard that matched only the two canonical
+ * strings let those turns read as completed — and, on a compaction turn, install a half-written
+ * summary as replacement history (#422).
+ *
+ * Classifying here keeps that decision independent of which adapter produced the event, and keeps
+ * suppression and terminal status in agreement: a turn whose compaction item is withheld must not
+ * also report success, or codex-rs receives a completed response with zero compaction items and
+ * fatals.
+ *
+ * Unknown reasons are deliberately NOT truncated. This must never turn a healthy turn into a
+ * failure, and an unrecognized value is far more likely an ordinary stop.
+ */
+type TruncationKind = "max_output_tokens" | "content_filter";
+
+const TRUNCATED_STOP_REASONS = new Map([
+ // canonical (openai-chat, google)
+ ["max_tokens", "max_output_tokens"],
+ ["content_filter", "content_filter"],
+ // raw OpenAI / Command Code (AI SDK) finish reasons
+ ["length", "max_output_tokens"],
+ ["content-filter", "content_filter"],
+ // raw Anthropic stop reasons
+ ["max_output_tokens", "max_output_tokens"],
+ ["model_context_window_exceeded", "max_output_tokens"],
+ ["refusal", "content_filter"],
+ // Anthropic documents `pause_turn` as a long-running turn that the client is expected to
+ // CONTINUE. Whatever was produced so far is by definition unfinished, so it must not be
+ // installed as replacement history.
+ ["pause_turn", "max_output_tokens"],
+ // raw Gemini / Vertex finish reasons
+ ["malformed_function_call", "content_filter"],
+ ["malformed_response", "content_filter"],
+ ["unexpected_tool_call", "content_filter"],
+ ["safety", "content_filter"],
+ ["recitation", "content_filter"],
+ ["blocklist", "content_filter"],
+ ["prohibited_content", "content_filter"],
+ ["spii", "content_filter"],
+ ["image_safety", "content_filter"],
+ ["language", "content_filter"],
+ // Kiro
+ ["model_context_window_exceeded_exception", "max_output_tokens"],
+]);
+
+/** The `incomplete_details.reason` a truncated stop maps to, or undefined for a normal stop. */
+export function truncationReasonFor(stopReason: string | undefined): TruncationKind | undefined {
+ if (stopReason === undefined) return undefined;
+ return TRUNCATED_STOP_REASONS.get(stopReason.trim().toLowerCase());
+}
+
+export function isTruncatedStopReason(stopReason: string | undefined): boolean {
+ return truncationReasonFor(stopReason) !== undefined;
+}
diff --git a/tests/anthropic-error-stop-reason.test.ts b/tests/anthropic-error-stop-reason.test.ts
new file mode 100644
index 0000000000..d4b2a750d3
--- /dev/null
+++ b/tests/anthropic-error-stop-reason.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, test } from "bun:test";
+import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic";
+import { buildResponseJSON } from "../src/bridge";
+import type { AdapterEvent, OcxProviderConfig } from "../src/types";
+import { withTestTranslatorBudget } from "./helpers/translator-budget";
+
+const createAnthropicAdapter = (...args: Parameters) =>
+ withTestTranslatorBudget(createAnthropicAdapterProduction(...args));
+
+const provider: OcxProviderConfig = {
+ adapter: "anthropic",
+ baseUrl: "https://api.anthropic.com",
+ apiKey: "test-key",
+};
+
+/**
+ * These drive the REAL adapter parsers. An earlier version of this suite constructed the
+ * downstream error event by hand, so it stayed green while the adapter itself still emitted a
+ * clean `done` — the gap an audit caught.
+ */
+describe("an upstream error stop_reason is a failure, not a stop", () => {
+ test("buffered: stop_reason error yields an error event carrying usage", async () => {
+ const adapter = createAnthropicAdapter(provider);
+ const body = JSON.stringify({
+ content: [{ type: "text", text: "partial" }],
+ stop_reason: "error",
+ usage: { input_tokens: 10, output_tokens: 4 },
+ });
+ const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[];
+
+ const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number } } | undefined;
+ expect(error).toBeDefined();
+ expect(events.some(e => e.type === "done")).toBe(false);
+ // A failed turn still consumed tokens; dropping usage makes it look free in accounting.
+ expect(error?.usage?.inputTokens).toBe(10);
+ });
+
+ test("streaming: stop_reason error yields an error event carrying usage", async () => {
+ const adapter = createAnthropicAdapter(provider);
+ const frames = [
+ 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n',
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}\n\n',
+ 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"error"},"usage":{"output_tokens":4}}\n\n',
+ 'event: message_stop\ndata: {"type":"message_stop"}\n\n',
+ ].join("");
+ const events: AdapterEvent[] = [];
+ for await (const e of adapter.parseStream(new Response(frames, {
+ status: 200, headers: { "content-type": "text/event-stream" },
+ }))) events.push(e);
+
+ const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined;
+ expect(error).toBeDefined();
+ expect(events.some(e => e.type === "done")).toBe(false);
+ // Assert the usage this test is named for: without it the title was a claim the test
+ // never checked, and removing usage would have kept it green.
+ expect(error?.usage?.inputTokens).toBe(10);
+ expect(error?.usage?.outputTokens).toBe(4);
+ });
+
+ test("streaming EOF without message_stop also fails, and keeps usage", async () => {
+ // A compatible provider may close after message_delta without message_stop. That branch
+ // bypasses emitDone entirely, so it needs its own check — an audit found it still
+ // reporting success while the two other terminal paths were fixed.
+ const adapter = createAnthropicAdapter(provider);
+ const frames = [
+ 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n',
+ 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n',
+ 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}\n\n',
+ 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"error"},"usage":{"output_tokens":4}}\n\n',
+ // no message_stop: the stream just ends
+ ].join('');
+ const events: AdapterEvent[] = [];
+ for await (const e of adapter.parseStream(new Response(frames, {
+ status: 200, headers: { "content-type": "text/event-stream" },
+ }))) events.push(e);
+
+ const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number } } | undefined;
+ expect(error).toBeDefined();
+ expect(events.some(e => e.type === "done")).toBe(false);
+ expect(error?.usage?.inputTokens).toBe(10);
+
+ const json = buildResponseJSON(events, "anthropic/claude-opus-5", { compaction: true });
+ expect(json.status).toBe("failed");
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+
+ test("a turn that failed upstream installs no compaction history", async () => {
+ const adapter = createAnthropicAdapter(provider);
+ const body = JSON.stringify({
+ content: [{ type: "text", text: "half a summary" }],
+ stop_reason: "error",
+ usage: { input_tokens: 10, output_tokens: 4 },
+ });
+ const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[];
+ const json = buildResponseJSON(events, "anthropic/claude-opus-5", { compaction: true });
+
+ expect(json.status).toBe("failed");
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+
+ test("an ordinary stop_reason still completes normally", async () => {
+ const adapter = createAnthropicAdapter(provider);
+ const body = JSON.stringify({
+ content: [{ type: "text", text: "a whole answer" }],
+ stop_reason: "end_turn",
+ usage: { input_tokens: 10, output_tokens: 4 },
+ });
+ const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[];
+
+ expect(events.some(e => e.type === "done")).toBe(true);
+ expect(events.some(e => e.type === "error")).toBe(false);
+ });
+});
diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts
new file mode 100644
index 0000000000..a137b676a2
--- /dev/null
+++ b/tests/bridge-nonstreaming-terminal.test.ts
@@ -0,0 +1,312 @@
+import { describe, expect, test } from "bun:test";
+import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge";
+import { isTruncatedStopReason, truncationReasonFor } from "../src/responses/truncated-stop-reason";
+import type { AdapterEvent } from "../src/types";
+
+async function sseText(events: AdapterEvent[]): Promise {
+ async function* source(): AsyncGenerator {
+ for (const e of events) yield e;
+ }
+ return await new Response(bridgeToResponsesSSE(source(), "routed/model")).text();
+}
+
+function terminalEventNames(text: string): string[] {
+ return text.split("\n\n")
+ .map(f => f.trim())
+ .map(f => f.split("\n").find(l => l.startsWith("event: "))?.slice(7) ?? "")
+ .filter(n => n === "response.completed" || n === "response.incomplete" || n === "response.failed");
+}
+
+describe("buffered turns without an adapter terminal", () => {
+ test("text with no done/error is not reported as completed", () => {
+ const json = buildResponseJSON([{ type: "text", text: "partial answer" }], "routed/model");
+
+ // The adapter stopped emitting mid-turn. Calling that a success is the shape that let a
+ // truncated Cursor turn look finished.
+ expect(json.status).toBe("incomplete");
+ expect((json as { incomplete_details?: { reason?: string } }).incomplete_details?.reason).toBe("adapter_eof");
+ });
+
+ test("a tool call left open is never returned as a completed function call", () => {
+ const json = buildResponseJSON([
+ { type: "tool_call_start", id: "call_1", name: "js" },
+ { type: "tool_call_delta", arguments: '{"code":"tru' },
+ ], "routed/model");
+
+ // The worst shape: a caller trusting `status` would try to execute half-written JSON.
+ expect(json.status).toBe("incomplete");
+ const call = json.output.find(o => (o as { type: string }).type === "function_call") as
+ { status?: string; arguments?: string } | undefined;
+ expect(call).toBeDefined();
+ expect(call?.status).toBe("incomplete");
+ expect(call?.arguments).toBe('{"code":"tru');
+ });
+
+ test("streaming and buffered agree on the terminal for the same events", async () => {
+ const events: AdapterEvent[] = [
+ { type: "tool_call_start", id: "call_1", name: "js" },
+ { type: "tool_call_delta", arguments: '{"code":"tru' },
+ ];
+
+ // Parity is the property that keeps these two paths from drifting apart again.
+ expect(terminalEventNames(await sseText(events))).toEqual(["response.incomplete"]);
+ expect(buildResponseJSON(events, "routed/model").status).toBe("incomplete");
+ });
+
+ test("an explicit done still completes", () => {
+ const json = buildResponseJSON([
+ { type: "text", text: "answer" },
+ { type: "done" },
+ ], "routed/model");
+
+ expect(json.status).toBe("completed");
+ expect((json as { incomplete_details?: unknown }).incomplete_details).toBeUndefined();
+ });
+
+ test("explicit error and explicit incomplete keep their own outcomes", () => {
+ const failed = buildResponseJSON([
+ { type: "text", text: "partial" },
+ { type: "error", message: "upstream failed" },
+ ], "routed/model");
+ expect(failed.status).toBe("failed");
+
+ const incomplete = buildResponseJSON([
+ { type: "text", text: "partial" },
+ { type: "incomplete", reason: "max_output_tokens" },
+ ], "routed/model");
+ expect(incomplete.status).toBe("incomplete");
+ // The adapter's own reason must survive, not be overwritten by adapter_eof.
+ expect((incomplete as { incomplete_details?: { reason?: string } }).incomplete_details?.reason)
+ .toBe("max_output_tokens");
+ });
+
+ test("a completed tool call with a done event is unaffected", () => {
+ const json = buildResponseJSON([
+ { type: "tool_call_start", id: "call_1", name: "js" },
+ { type: "tool_call_delta", arguments: '{"code":"ok"}' },
+ { type: "tool_call_end", id: "call_1" },
+ { type: "done" },
+ ], "routed/model");
+
+ expect(json.status).toBe("completed");
+ const call = json.output.find(o => (o as { type: string }).type === "function_call") as
+ { status?: string } | undefined;
+ expect(call?.status).toBe("completed");
+ });
+});
+
+describe("compaction is never installed from a truncated turn", () => {
+ // #422: a compaction item becomes REPLACEMENT HISTORY. The original guard could only see
+ // explicit error/incomplete events, so a stream that stopped without any terminal slipped
+ // past it — installing a truncated summary as the conversation's new past.
+ test("no compaction item when the adapter emitted no terminal", () => {
+ const json = buildResponseJSON(
+ [{ type: "text", text: "half a summary" }],
+ "routed/model",
+ { compaction: true },
+ );
+
+ expect(json.status).toBe("incomplete");
+ expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(false);
+ });
+
+ test("compaction still emitted for a genuinely completed turn", () => {
+ const json = buildResponseJSON(
+ [{ type: "text", text: "a whole summary" }, { type: "done" }],
+ "routed/model",
+ { compaction: true },
+ );
+
+ expect(json.status).toBe("completed");
+ expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(true);
+ });
+
+ test("compaction stays suppressed for explicit failure terminals", () => {
+ for (const terminal of [
+ { type: "error", message: "upstream failed" } as const,
+ { type: "incomplete", reason: "max_output_tokens" } as const,
+ ]) {
+ const json = buildResponseJSON(
+ [{ type: "text", text: "partial" }, terminal],
+ "routed/model",
+ { compaction: true },
+ );
+ expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(false);
+ }
+ });
+});
+
+describe("streaming compaction respects the same #422 guard", () => {
+ async function streamCompaction(events: AdapterEvent[]): Promise<{ hasCompaction: boolean; terminals: string[] }> {
+ async function* source(): AsyncGenerator {
+ for (const e of events) yield e;
+ }
+ const text = await new Response(bridgeToResponsesSSE(
+ source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true },
+ )).text();
+ return { hasCompaction: text.includes('"type":"compaction"'), terminals: terminalEventNames(text) };
+ }
+
+ const delta = (t: string) => ({ type: "text_delta", text: t }) as AdapterEvent;
+
+ test("a max_tokens turn ships no compaction item", async () => {
+ // Streaming emitted the item BEFORE reading stopReason, so a truncated summary was installed
+ // as replacement history and the turn then declared itself incomplete.
+ const { hasCompaction, terminals } = await streamCompaction([
+ delta("half a summary"),
+ { type: "done", stopReason: "max_tokens" },
+ ]);
+
+ expect(hasCompaction).toBe(false);
+ expect(terminals).toEqual(["response.incomplete"]);
+ });
+
+ test("a content_filter turn ships no compaction item", async () => {
+ const { hasCompaction, terminals } = await streamCompaction([
+ delta("half a summary"),
+ { type: "done", stopReason: "content_filter" },
+ ]);
+
+ expect(hasCompaction).toBe(false);
+ expect(terminals).toEqual(["response.incomplete"]);
+ });
+
+ test("a clean compaction turn still ships exactly one compaction item", async () => {
+ // codex-rs takes the first compaction item and fatals on zero, so suppression must not widen.
+ const { hasCompaction, terminals } = await streamCompaction([
+ delta("a whole summary"),
+ { type: "done" },
+ ]);
+
+ expect(hasCompaction).toBe(true);
+ expect(terminals).toEqual(["response.completed"]);
+ });
+});
+
+describe("truncation is recognized regardless of adapter vocabulary", () => {
+ // stopReason is an open-ended string and adapters disagree: openai-chat normalizes to
+ // max_tokens/content_filter, Command Code forwards the raw "length", Anthropic forwards
+ // stop_reason verbatim. Matching only the canonical pair let those turns install a
+ // half-written summary as replacement history (#422).
+ const delta = (t: string) => ({ type: "text_delta", text: t }) as AdapterEvent;
+
+ async function streamTerminal(events: AdapterEvent[]): Promise {
+ async function* source(): AsyncGenerator {
+ for (const e of events) yield e;
+ }
+ const text = await new Response(bridgeToResponsesSSE(
+ source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true },
+ )).text();
+ return terminalEventNames(text)[0] ?? "";
+ }
+
+ async function streamCompactionItems(events: AdapterEvent[]): Promise {
+ async function* source(): AsyncGenerator {
+ for (const e of events) yield e;
+ }
+ const text = await new Response(bridgeToResponsesSSE(
+ source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true },
+ )).text();
+ // Count emitted ITEMS, not mentions: the compaction item also appears inside the terminal
+ // response snapshot. A duplicate emission would slip past a boolean presence check.
+ return text.split("\n\n")
+ .filter(f => f.includes("event: response.output_item.done") && f.includes('"type":"compaction"'))
+ .length;
+ }
+
+ function bufferedCompactionItems(events: AdapterEvent[]): number {
+ const json = buildResponseJSON(events, "routed/model", { compaction: true });
+ return (json.output as { type: string }[]).filter(o => o.type === "compaction").length;
+ }
+
+ const truncatedReasons = [
+ "length", // Command Code / raw OpenAI
+ "max_tokens", // canonical
+ "content_filter", // canonical
+ "refusal", // raw Anthropic
+ "MAX_TOKENS", // raw Gemini
+ "MALFORMED_FUNCTION_CALL",
+ "SAFETY",
+ ];
+
+ for (const reason of truncatedReasons) {
+ test(`stopReason "${reason}" installs no compaction history (streaming and buffered)`, async () => {
+ const events: AdapterEvent[] = [delta("half a summary"), { type: "done", stopReason: reason }];
+
+ expect(await streamCompactionItems(events)).toBe(0);
+ expect(bufferedCompactionItems(events)).toBe(0);
+ // Suppression and terminal status must agree. Withholding the item while still reporting
+ // success hands codex-rs a completed response with ZERO compaction items, which is fatal.
+ expect(await streamTerminal(events)).toBe("response.incomplete");
+ expect(buildResponseJSON(events, "routed/model", { compaction: true }).status).toBe("incomplete");
+ });
+ }
+
+ test("a clean turn still ships EXACTLY ONE compaction item on both paths", async () => {
+ // codex-rs takes the first compaction item and fatals on zero, so suppression must not
+ // widen — and a duplicate would be just as wrong.
+ const events: AdapterEvent[] = [delta("a whole summary"), { type: "done" }];
+
+ expect(await streamCompactionItems(events)).toBe(1);
+ expect(bufferedCompactionItems(events)).toBe(1);
+ });
+
+ test("an unrecognized stop reason is treated as a normal stop", async () => {
+ // Unknown values must not fail healthy turns: an unrecognized reason is far more likely a
+ // provider's ordinary stop than a silent truncation.
+ const events: AdapterEvent[] = [delta("a whole summary"), { type: "done", stopReason: "end_turn" }];
+
+ expect(await streamCompactionItems(events)).toBe(1);
+ expect(bufferedCompactionItems(events)).toBe(1);
+ });
+});
+
+describe("truncated-stop-reason classifier", () => {
+ test("matches every adapter vocabulary case-insensitively", () => {
+ for (const reason of [
+ "max_tokens", "content_filter", // canonical
+ "length", "content-filter", // Command Code / AI SDK
+ "pause_turn", // Anthropic: turn needs continuation
+ "refusal", "model_context_window_exceeded", // Anthropic
+ "MAX_TOKENS", "SAFETY", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY", "LANGUAGE", // Gemini
+ "Safety", "safety", // mixed case must not slip through
+ ]) {
+ expect(isTruncatedStopReason(reason)).toBe(true);
+ }
+ });
+
+ test("normal stops are never treated as truncation", () => {
+ // A false positive costs a compaction item, and codex-rs fatals on zero.
+ for (const reason of ["end_turn", "stop", "stop_sequence", "tool_use", "STOP", "tool-calls", undefined]) {
+ expect(isTruncatedStopReason(reason)).toBe(false);
+ }
+ });
+
+ test("truncation maps to the right incomplete_details reason", () => {
+ expect(truncationReasonFor("length")).toBe("max_output_tokens");
+ expect(truncationReasonFor("model_context_window_exceeded")).toBe("max_output_tokens");
+ expect(truncationReasonFor("refusal")).toBe("content_filter");
+ expect(truncationReasonFor("SAFETY")).toBe("content_filter");
+ expect(truncationReasonFor("end_turn")).toBeUndefined();
+ });
+});
+
+describe("Command Code finishReason error is a failure, not a stop", () => {
+ test("an error finish reason produces an adapter error terminal", () => {
+ // The AI SDK's "error" means generation FAILED upstream. As a done+stopReason it either read
+ // as a clean completion or, once classified, mislabelled an upstream error as a content
+ // filter — rejecting it from the replay cache for the wrong reason.
+ expect(isTruncatedStopReason("error")).toBe(false);
+ });
+
+ test("a turn that failed upstream reports failed, not incomplete", () => {
+ const json = buildResponseJSON([
+ { type: "text", text: "partial" },
+ { type: "error", message: 'Command Code upstream ended the turn with finishReason "error"', status: 502, errorType: "upstream_error" },
+ ], "routed/model", { compaction: true });
+
+ expect(json.status).toBe("failed");
+ // A failed turn must not install replacement history either.
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+});
diff --git a/tests/command-code-error-finish.test.ts b/tests/command-code-error-finish.test.ts
new file mode 100644
index 0000000000..fc1ff3fcbc
--- /dev/null
+++ b/tests/command-code-error-finish.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, test } from "bun:test";
+import { createCommandCodeAdapter } from "../src/adapters/command-code";
+import { buildResponseJSON } from "../src/bridge";
+import { createTestTranslatorBudget } from "./helpers/translator-budget";
+import type { AdapterEvent, OcxProviderConfig } from "../src/types";
+
+const provider: OcxProviderConfig = {
+ adapter: "command-code",
+ baseUrl: "https://api.command.example",
+ apiKey: "test-key",
+};
+
+function ndjsonResponse(lines: unknown[]): Response {
+ return new Response(lines.map(l => JSON.stringify(l) + "\n").join(""), {
+ status: 200,
+ headers: { "content-type": "application/x-ndjson" },
+ });
+}
+
+/**
+ * Drives the REAL parser. An earlier suite hand-constructed the downstream error event, so it
+ * stayed green while the adapter still emitted a clean `done` — the gap an audit named.
+ */
+describe("Command Code finishReason error", () => {
+ const errorFinish = [
+ { type: "text-delta", text: "partial" },
+ { type: "finish", finishReason: "error", totalUsage: { inputTokens: 10, outputTokens: 4 } },
+ ];
+
+ test("streaming: yields an error terminal, not a done, and keeps usage", async () => {
+ const adapter = createCommandCodeAdapter(provider);
+ const events: AdapterEvent[] = [];
+ for await (const e of adapter.parseStream(ndjsonResponse(errorFinish), createTestTranslatorBudget())) {
+ events.push(e);
+ }
+
+ const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined;
+ expect(error).toBeDefined();
+ expect(events.some(e => e.type === "done")).toBe(false);
+ // A failed turn still consumed tokens; dropping usage makes it look free in accounting.
+ expect(error?.usage?.inputTokens).toBe(10);
+ expect(error?.usage?.outputTokens).toBe(4);
+ });
+
+ test("buffered: the turn reports failed and installs no compaction history", async () => {
+ const adapter = createCommandCodeAdapter(provider);
+ const events = await adapter.parseResponse!(ndjsonResponse(errorFinish), createTestTranslatorBudget()) as AdapterEvent[];
+ const json = buildResponseJSON(events, "command-code/model", { compaction: true });
+
+ expect(json.status).toBe("failed");
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+
+ test("an ordinary finish still completes and keeps its usage", async () => {
+ const adapter = createCommandCodeAdapter(provider);
+ const events: AdapterEvent[] = [];
+ for await (const e of adapter.parseStream(ndjsonResponse([
+ { type: "text-delta", text: "a whole answer" },
+ { type: "finish", finishReason: "stop", totalUsage: { inputTokens: 10, outputTokens: 4 } },
+ ]), createTestTranslatorBudget())) events.push(e);
+
+ const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number } } | undefined;
+ expect(done).toBeDefined();
+ expect(events.some(e => e.type === "error")).toBe(false);
+ expect(done?.usage?.inputTokens).toBe(10);
+ });
+
+ test("a length finish is still a truncation, not an error", async () => {
+ const adapter = createCommandCodeAdapter(provider);
+ const events = await adapter.parseResponse!(ndjsonResponse([
+ { type: "text-delta", text: "half a summary" },
+ { type: "finish", finishReason: "length", totalUsage: { inputTokens: 10, outputTokens: 4 } },
+ ]), createTestTranslatorBudget()) as AdapterEvent[];
+ const json = buildResponseJSON(events, "command-code/model", { compaction: true });
+
+ expect(json.status).toBe("incomplete");
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+});
+
diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts
index 47da383fc1..f3aaafd656 100644
--- a/tests/cursor-request-builder.test.ts
+++ b/tests/cursor-request-builder.test.ts
@@ -199,8 +199,10 @@ describe("Cursor request builder", () => {
expect(request.messages[0]?.content).toContain("see");
// A USER-message image is still flattened here (this path builds the plain-text prompt).
- // Tool-result images do reach Cursor as real McpImageContent, so the placeholder no longer
- // claims the adapter as a whole is unable to send images.
+ // The tool-result ENCODER does build real McpImageContent, so the placeholder no longer
+ // claims the encoder as a whole is unable to send images. (Neither kind reaches Cursor in
+ // production today: every Cursor model is in noVisionModels, so the vision sidecar runs
+ // first — see devlog/_plan/260817_cursor_toolcall_decode/020_*.md.)
expect(request.messages[0]?.content).toContain("image omitted from this Cursor text prompt");
expect(request.messages[0]?.content).toContain("high");
});
diff --git a/tests/cursor-tool-result-image.test.ts b/tests/cursor-tool-result-image.test.ts
index 94c03dfba7..610c25e3c4 100644
--- a/tests/cursor-tool-result-image.test.ts
+++ b/tests/cursor-tool-result-image.test.ts
@@ -26,7 +26,13 @@ function blobData(blobId: Uint8Array): Uint8Array {
return kv.message.value.blobData;
}
-/** Every content item Cursor will see for the tool result attached to the assistant's tool call. */
+/**
+ * Every content item the ENCODER emits for the tool result attached to the assistant's tool call.
+ * This is encoder-level: it calls encodeCursorRunRequest directly, so it deliberately bypasses the
+ * server's vision preprocessing. In production every Cursor model is in noVisionModels, so images
+ * are described or stripped before the adapter runs — these assertions prove encoder support, not
+ * end-to-end delivery.
+ */
function toolResultItems(bytes: Uint8Array) {
const msg = fromBinary(AgentClientMessageSchema, bytes);
const run = msg.message.case === "runRequest" ? msg.message.value : undefined;
@@ -88,7 +94,7 @@ describe("Cursor tool-result image passthrough", () => {
expect(items!.length).toBe(2);
expect(items![0].content.case).toBe("text");
expect(items![0].content.case === "text" ? items![0].content.value.text : "").toBe("here is the screen");
- // The decisive assertion: the model receives the actual bytes, not a placeholder.
+ // The decisive assertion: the encoder emits the actual bytes, not a placeholder.
expect(items![1].content.case).toBe("image");
if (items![1].content.case !== "image") throw new Error("expected image content");
expect(items![1].content.value.mimeType).toBe("image/png");
diff --git a/tests/google-buffered-stop-reason.test.ts b/tests/google-buffered-stop-reason.test.ts
new file mode 100644
index 0000000000..5934f04a40
--- /dev/null
+++ b/tests/google-buffered-stop-reason.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test";
+import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google";
+import { buildResponseJSON } from "../src/bridge";
+import type { AdapterEvent, OcxProviderConfig } from "../src/types";
+import { withTestTranslatorBudget } from "./helpers/translator-budget";
+
+const createGoogleAdapter = (...args: Parameters) =>
+ withTestTranslatorBudget(createGoogleAdapterProduction(...args));
+
+const provider: OcxProviderConfig = {
+ adapter: "google",
+ baseUrl: "https://generativelanguage.googleapis.com",
+ apiKey: "test-key",
+};
+
+function geminiResponse(finishReason: string, text = "half a summary"): Response {
+ return new Response(JSON.stringify({
+ candidates: [{ content: { parts: [{ text }] }, finishReason }],
+ usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 },
+ }), { status: 200 });
+}
+
+describe("Google buffered parseResponse carries its stop reason", () => {
+ // The streaming path already mapped MAX_TOKENS to stopReason "max_tokens"; parseResponse
+ // emitted a clean `done`. The bridge therefore reported a truncated buffered turn as
+ // completed — and on a compaction turn installed the half-written summary as replacement
+ // history, the #422 hazard.
+ test("MAX_TOKENS becomes stopReason max_tokens", async () => {
+ const adapter = createGoogleAdapter(provider);
+ const events = await adapter.parseResponse!(geminiResponse("MAX_TOKENS")) as AdapterEvent[];
+ const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined;
+
+ expect(done).toBeDefined();
+ expect(done?.stopReason).toBe("max_tokens");
+ });
+
+ test("a safety finish reason becomes stopReason content_filter", async () => {
+ const adapter = createGoogleAdapter(provider);
+ const events = await adapter.parseResponse!(geminiResponse("SAFETY")) as AdapterEvent[];
+ const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined;
+
+ expect(done?.stopReason).toBe("content_filter");
+ });
+
+ test("a normal STOP carries no stop reason", async () => {
+ const adapter = createGoogleAdapter(provider);
+ const events = await adapter.parseResponse!(geminiResponse("STOP", "a whole answer")) as AdapterEvent[];
+ const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined;
+
+ expect(done).toBeDefined();
+ expect(done?.stopReason).toBeUndefined();
+ });
+
+ test("a truncated buffered compaction turn installs no replacement history", async () => {
+ const adapter = createGoogleAdapter(provider);
+ const events = await adapter.parseResponse!(geminiResponse("MAX_TOKENS")) as AdapterEvent[];
+ const json = buildResponseJSON(events, "google/gemini-3-pro", { compaction: true });
+
+ expect(json.status).toBe("incomplete");
+ expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false);
+ });
+});
]