From d637eea92d576304013f714a3ef4ecfe44f1283f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Mon, 17 Aug 2026 22:36:56 +0900 Subject: [PATCH 01/40] fix(bridge): a buffered turn with no adapter terminal is not completed buildResponseJSON defaulted status to "completed" whenever no error/incomplete event was present, so a turn whose adapter simply stopped emitting came back as a success. The worst shape was a tool call left open: the response carried a function_call with arguments '{"code":"tru' and status completed, inside a turn also marked completed. A caller trusting either field would try to execute half-written JSON. The same events WITH tool_call_end already produced status failed and item status incomplete, and the streaming path already reported response.incomplete/adapter_eof, so the bridge knew how to reject this - it just never ran that path when the stream stopped on its own. A buffered turn with no adapter terminal now resolves to incomplete with incomplete_details.reason adapter_eof (the same string streaming uses), and an open tool call is flushed as incomplete rather than completed. Explicit done/error/incomplete outcomes are untouched, including each event's own reason. This closes the default itself. 010 and 040 removed two Cursor-side routes to it; any adapter that ends a stream without a terminal is now covered, including future ones. bridge.ts is shared by every provider, so this was verified against a full-suite baseline captured before the change (12761 pass / 0 fail across 826 files). --- .../050_phase5-nonstreaming-terminal.md | 89 +++++++++++++++++ src/bridge.ts | 26 ++++- tests/bridge-nonstreaming-terminal.test.ts | 96 +++++++++++++++++++ 3 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md create mode 100644 tests/bridge-nonstreaming-terminal.test.ts 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..ea0c4ef4a6 --- /dev/null +++ b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md @@ -0,0 +1,89 @@ +# 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. + diff --git a/src/bridge.ts b/src/bridge.ts index c34e91734a..8bd3d52ddc 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1468,6 +1468,10 @@ function buildResponseJSONWithBudget( let endTurn: boolean | undefined; let stopReason: 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,15 +1786,18 @@ 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; if (e.providerState) options?.onProviderState?.(e.providerState); @@ -1803,8 +1810,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({ @@ -1831,7 +1841,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 +1867,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/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts new file mode 100644 index 0000000000..8236bd4380 --- /dev/null +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; +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"); + }); +}); + From d23661d1d6a257535f5d6b2220eecbaf40ed1e7d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 08:58:12 +0900 Subject: [PATCH 02/40] fix(bridge): never install compaction history from a terminal-less turn Audit follow-up. The #422 guard suppresses the compaction item for a truncated turn, but it could only see explicit error/incomplete events. A stream that stopped without ANY terminal slipped past it: a probe produced status incomplete / adapter_eof while still emitting a compaction item, which becomes the conversation's replacement history. That is exactly the hazard #422 exists to prevent, reached by a route that did not exist when the guard was written. The guard now also requires sawTerminal. Verified red-before-green, and the completed and explicit-failure paths are pinned by their own tests so the suppression cannot widen silently. --- src/bridge.ts | 5 +++ tests/bridge-nonstreaming-terminal.test.ts | 40 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/bridge.ts b/src/bridge.ts index 8bd3d52ddc..f7f0c2a18e 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1830,6 +1830,11 @@ function buildResponseJSONWithBudget( options?.compaction && !errorEvent && !incompleteEvent + // 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 && stopReason !== "max_tokens" && stopReason !== "content_filter" ) { diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts index 8236bd4380..028d95d5b2 100644 --- a/tests/bridge-nonstreaming-terminal.test.ts +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -94,3 +94,43 @@ describe("buffered turns without an adapter terminal", () => { }); }); +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); + } + }); +}); From 1b475b9539cbbadd8a2b2877929fe6f9b6b012d3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 09:13:07 +0900 Subject: [PATCH 03/40] fix(bridge,google): close two more routes that install truncated compaction history Audit round 2 found the #422 guard still had two open routes, both verified before fixing: 1. Streaming emitted the compaction item BEFORE reading done.stopReason, so a max_tokens or content_filter turn shipped a half-written summary and then declared itself incomplete. The buffered path had always checked this; streaming had the same hazard one branch over. 2. Google's parseResponse ignored finishReason entirely and emitted a clean done, so a buffered MAX_TOKENS turn was reported as completed - and on a compaction turn its partial summary became replacement history. The streaming path already mapped MAX_TOKENS and the safety reasons to a stopReason; the buffered path did not. Both now match. Verified red-before-green: 5 of the new tests fail with either fix reverted. Suppression is pinned in both directions so it cannot widen - a clean compaction turn must still ship exactly one item, since codex-rs fatals on zero. --- src/adapters/google.ts | 11 ++++ src/bridge.ts | 6 ++- tests/bridge-nonstreaming-terminal.test.ts | 47 ++++++++++++++++ tests/google-buffered-stop-reason.test.ts | 62 ++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/google-buffered-stop-reason.test.ts 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 f7f0c2a18e..407818ed7a 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1154,7 +1154,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 && event.stopReason !== "max_tokens" && event.stopReason !== "content_filter") { // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0. const item = { type: "compaction", id: `cmp_${uuid()}`, diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts index 028d95d5b2..47cfd9b667 100644 --- a/tests/bridge-nonstreaming-terminal.test.ts +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -134,3 +134,50 @@ describe("compaction is never installed from a truncated turn", () => { } }); }); + +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"]); + }); +}); 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); + }); +}); From 64321f79aaff1d5810127df15b6cb601386a1f18 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 09:25:07 +0900 Subject: [PATCH 04/40] fix(bridge): recognize truncation regardless of adapter stop-reason vocabulary Audit round 3 found the #422 guard was still bypassable, and reproduced it: Command Code emits done(stopReason: "length"), which neither bridge recognized, so a truncated compaction turn shipped its partial summary and reported completed. Anthropic forwards raw stop_reason values like "refusal" the same way. Only openai-chat and google normalized to the canonical pair the guard matched. stopReason is an open-ended string, so the guard cannot depend on which adapter produced the event. isTruncatedStopReason now recognizes the canonical names plus the raw OpenAI/Anthropic/Gemini vocabularies, and both the streaming and buffered guards use it. The buffered path keeps the raw reason alongside the narrowed one, since the narrowed value exists only to map onto incomplete_details. Unknown reasons stay non-truncated on purpose: this must never turn a healthy turn into a failure, and an unrecognized value is far more likely an ordinary stop. That is pinned by its own test, as is the requirement that a clean turn still ships EXACTLY ONE compaction item on both paths - codex-rs fatals on zero, and the previous test only checked presence, so a duplicate would have passed. Verified red-before-green: 5 of the new tests fail with canonical-only matching restored. --- src/bridge.ts | 11 +++- src/responses/truncated-stop-reason.ts | 37 +++++++++++++ tests/bridge-nonstreaming-terminal.test.ts | 64 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/responses/truncated-stop-reason.ts diff --git a/src/bridge.ts b/src/bridge.ts index 407818ed7a..5135096a50 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 } from "./responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; import { @@ -1158,7 +1159,7 @@ export function bridgeToResponsesSSE( // 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 && event.stopReason !== "max_tokens" && event.stopReason !== "content_filter") { + 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()}`, @@ -1471,6 +1472,10 @@ 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 @@ -1804,6 +1809,7 @@ function buildResponseJSONWithBudget( 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; @@ -1839,8 +1845,7 @@ function buildResponseJSONWithBudget( // truncated summary as replacement history — the exact #422 hazard, reached by a route that // did not exist when the guard was written. && sawTerminal - && stopReason !== "max_tokens" - && stopReason !== "content_filter" + && !isTruncatedStopReason(rawStopReason) ) { pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes); } diff --git a/src/responses/truncated-stop-reason.ts b/src/responses/truncated-stop-reason.ts new file mode 100644 index 0000000000..03cee76056 --- /dev/null +++ b/src/responses/truncated-stop-reason.ts @@ -0,0 +1,37 @@ +/** + * Whether a `done` event's `stopReason` means the turn was cut short rather than finishing. + * + * `stopReason` is an open-ended string and adapters do not agree on a vocabulary: openai-chat + * normalizes to `max_tokens`/`content_filter`, Command Code forwards the raw provider value + * (`length`), and Anthropic forwards `stop_reason` verbatim (`max_tokens`, `refusal`, ...). + * Matching only the two canonical strings let a truncated turn read as completed — and, on a + * compaction turn, install its half-written summary as replacement history (#422). + * + * Recognizing the vocabularies here keeps that guard from depending on which adapter produced + * the event. Unknown reasons stay non-truncated: this must never turn a healthy turn into a + * failure, and an unrecognized value is more likely a normal stop than a silent truncation. + */ +const TRUNCATED_STOP_REASONS = new Set([ + // canonical (openai-chat, google) + "max_tokens", + "content_filter", + // raw OpenAI/Command Code finish reasons + "length", + // raw Anthropic stop reasons + "max_output_tokens", + "refusal", + // raw Gemini/Vertex finish reasons + "MAX_TOKENS", + "MALFORMED_FUNCTION_CALL", + "SAFETY", + "RECITATION", + "BLOCKLIST", + "PROHIBITED_CONTENT", + "SPII", +]); + +export function isTruncatedStopReason(stopReason: string | undefined): boolean { + if (stopReason === undefined) return false; + const trimmed = stopReason.trim(); + return TRUNCATED_STOP_REASONS.has(trimmed) || TRUNCATED_STOP_REASONS.has(trimmed.toLowerCase()); +} diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts index 47cfd9b667..750e4890f7 100644 --- a/tests/bridge-nonstreaming-terminal.test.ts +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -181,3 +181,67 @@ describe("streaming compaction respects the same #422 guard", () => { 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 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); + }); + } + + 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); + }); +}); From 7e86ac28b733ea4e838fa9ec23dfb2019bb87ddf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 09:36:53 +0900 Subject: [PATCH 05/40] fix(bridge): keep compaction suppression and terminal status in agreement Round 4 caught a regression I introduced one round earlier. Recognizing raw stop reasons suppressed the compaction item for values like "length" and "refusal", but the terminal mapping stayed canonical-only - so those turns reported response.completed with ZERO compaction items, which codex-rs treats as fatal. Suppressing the item without also downgrading the turn was worse than the bug it replaced. The classifier now returns WHICH incomplete_details reason a truncation maps to, and both bridges use that single decision for suppression and for status. A truncated turn is incomplete on both paths, for every vocabulary. Also fixed: matching claimed to be case-insensitive but lowercased the input against uppercase-only entries, so "SAFETY" matched while "Safety" and "safety" did not. All entries are stored lowercase and compared lowercase. Vocabulary extended with the values round 4 documented from vendor references: Anthropic model_context_window_exceeded, AI SDK content-filter and error, and the Gemini enums MALFORMED_RESPONSE, UNEXPECTED_TOOL_CALL, IMAGE_SAFETY and LANGUAGE. Unknown reasons remain non-truncated, pinned by a test over end_turn, stop, stop_sequence, tool_use, STOP and tool-calls: a false positive costs a compaction item, and zero is fatal. --- src/bridge.ts | 18 ++++-- src/responses/truncated-stop-reason.ts | 75 ++++++++++++++-------- tests/bridge-nonstreaming-terminal.test.ts | 44 +++++++++++++ 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index 5135096a50..ebbf2c7cb9 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -9,7 +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 } from "./responses/truncated-stop-reason"; +import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; import { @@ -1169,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; @@ -1812,7 +1816,13 @@ function buildResponseJSONWithBudget( 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); diff --git a/src/responses/truncated-stop-reason.ts b/src/responses/truncated-stop-reason.ts index 03cee76056..80a2a0487e 100644 --- a/src/responses/truncated-stop-reason.ts +++ b/src/responses/truncated-stop-reason.ts @@ -1,37 +1,58 @@ /** - * Whether a `done` event's `stopReason` means the turn was cut short rather than finishing. + * 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 - * normalizes to `max_tokens`/`content_filter`, Command Code forwards the raw provider value - * (`length`), and Anthropic forwards `stop_reason` verbatim (`max_tokens`, `refusal`, ...). - * Matching only the two canonical strings let a truncated turn read as completed — and, on a - * compaction turn, install its half-written summary as replacement history (#422). + * `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). * - * Recognizing the vocabularies here keeps that guard from depending on which adapter produced - * the event. Unknown reasons stay non-truncated: this must never turn a healthy turn into a - * failure, and an unrecognized value is more likely a normal stop than a silent truncation. + * 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. */ -const TRUNCATED_STOP_REASONS = new Set([ +type TruncationKind = "max_output_tokens" | "content_filter"; + +const TRUNCATED_STOP_REASONS = new Map([ // canonical (openai-chat, google) - "max_tokens", - "content_filter", - // raw OpenAI/Command Code finish reasons - "length", + ["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"], + ["error", "content_filter"], // raw Anthropic stop reasons - "max_output_tokens", - "refusal", - // raw Gemini/Vertex finish reasons - "MAX_TOKENS", - "MALFORMED_FUNCTION_CALL", - "SAFETY", - "RECITATION", - "BLOCKLIST", - "PROHIBITED_CONTENT", - "SPII", + ["max_output_tokens", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], + ["refusal", "content_filter"], + // 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 { - if (stopReason === undefined) return false; - const trimmed = stopReason.trim(); - return TRUNCATED_STOP_REASONS.has(trimmed) || TRUNCATED_STOP_REASONS.has(trimmed.toLowerCase()); + return truncationReasonFor(stopReason) !== undefined; } + diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts index 750e4890f7..9852598a90 100644 --- a/tests/bridge-nonstreaming-terminal.test.ts +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -1,5 +1,6 @@ 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 { @@ -189,6 +190,16 @@ describe("truncation is recognized regardless of adapter vocabulary", () => { // 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; @@ -224,6 +235,10 @@ describe("truncation is recognized regardless of adapter vocabulary", () => { 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"); }); } @@ -245,3 +260,32 @@ describe("truncation is recognized regardless of adapter vocabulary", () => { 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", "error", // Command Code / AI SDK + "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(); + }); +}); From 932e2043cbe99190c045bb684bdb45610f523ddb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 09:48:04 +0900 Subject: [PATCH 06/40] fix(bridge,command-code): treat pause_turn as unfinished and error as a failure Two remaining bridge-side gaps from round 5. Anthropic forwards pause_turn verbatim, and the classifier read it as a normal completion. Anthropic documents it as a long-running turn the client is expected to CONTINUE, so whatever was produced is by definition unfinished and must not become replacement history. The AI SDK's error finish reason means generation FAILED upstream, not that it stopped. Carrying it as done+stopReason forced a bad choice: read as a clean completion, or (once classified) mislabel an upstream error as a content filter and reject it from the replay cache for the wrong reason. Command Code now emits a proper error terminal, so the turn reports failed with an accurate cause and error is no longer in the truncation table. The two remaining findings are adapter-side erasure - Kiro's disabled completion mode and ordinary Google mode both drop finish reasons before the bridge can see them. Those are separate subsystems with their own truncation handling and are recorded as follow-ups rather than folded in here. --- src/adapters/command-code.ts | 9 +++++++++ src/responses/truncated-stop-reason.ts | 6 ++++-- tests/bridge-nonstreaming-terminal.test.ts | 23 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 5299b455c2..aceab45511 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -553,6 +553,15 @@ 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") { + yield { type: "error", message: "Command Code upstream ended the turn with finishReason \"error\"", status: 502, errorType: "upstream_error" }; + break; + } yield { type: "done", usage: usage(usageValue), stopReason }; break; } diff --git a/src/responses/truncated-stop-reason.ts b/src/responses/truncated-stop-reason.ts index 80a2a0487e..85042a763f 100644 --- a/src/responses/truncated-stop-reason.ts +++ b/src/responses/truncated-stop-reason.ts @@ -26,11 +26,14 @@ const TRUNCATED_STOP_REASONS = new Map([ // raw OpenAI / Command Code (AI SDK) finish reasons ["length", "max_output_tokens"], ["content-filter", "content_filter"], - ["error", "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"], @@ -55,4 +58,3 @@ export function truncationReasonFor(stopReason: string | undefined): TruncationK export function isTruncatedStopReason(stopReason: string | undefined): boolean { return truncationReasonFor(stopReason) !== undefined; } - diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts index 9852598a90..a137b676a2 100644 --- a/tests/bridge-nonstreaming-terminal.test.ts +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -265,7 +265,8 @@ describe("truncated-stop-reason classifier", () => { test("matches every adapter vocabulary case-insensitively", () => { for (const reason of [ "max_tokens", "content_filter", // canonical - "length", "content-filter", "error", // Command Code / AI SDK + "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 @@ -289,3 +290,23 @@ describe("truncated-stop-reason classifier", () => { 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); + }); +}); From 26a82705c191c19e8d9a2ec3467030bfd6ddda16 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 09:48:57 +0900 Subject: [PATCH 07/40] docs(devlog): record what shipped for 050 and the adapter-side follow-ups --- .../050_phase5-nonstreaming-terminal.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index ea0c4ef4a6..7df9f5ca54 100644 --- a/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md +++ b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md @@ -87,3 +87,39 @@ signal. 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. From 214b4adb44e1488518c780a7181b4c968b86595b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 10:05:33 +0900 Subject: [PATCH 08/40] fix(anthropic,command-code): an upstream error stop reason fails the turn and keeps usage Round 6 found both of these, and both were mine. Removing 'error' from the shared truncation table was only safe for Command Code, which converts it upstream. Anthropic-compatible providers forward stop_reason verbatim, so a probe with stop_reason 'error' produced a clean done and both bridge paths reported completed with a compaction item installed. Anthropic now emits an error terminal on both the buffered and streaming paths. The Command Code error terminal I added last round dropped usage, so a failed turn looked free in accounting and reported zeros to the client. Both error terminals now carry usage: a turn that failed still consumed tokens. The new tests drive the REAL adapter parsers. The previous suite constructed the downstream error event by hand, which is why it stayed green while the adapter itself still emitted a clean done - the gap the audit named. --- src/adapters/anthropic.ts | 27 ++++++++ src/adapters/command-code.ts | 10 ++- tests/anthropic-error-stop-reason.test.ts | 83 +++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/anthropic-error-stop-reason.test.ts diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 6eb8085be0..1787746018 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), @@ -1210,6 +1222,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 aceab45511..156ba5130a 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -559,7 +559,15 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA // upstream error as a content filter and rejected it from the replay cache for the // wrong reason. if (stopReason === "error") { - yield { type: "error", message: "Command Code upstream ended the turn with finishReason \"error\"", status: 502, errorType: "upstream_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 }; diff --git a/tests/anthropic-error-stop-reason.test.ts b/tests/anthropic-error-stop-reason.test.ts new file mode 100644 index 0000000000..5921d03f87 --- /dev/null +++ b/tests/anthropic-error-stop-reason.test.ts @@ -0,0 +1,83 @@ +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); + + expect(events.some(e => e.type === "error")).toBe(true); + expect(events.some(e => e.type === "done")).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); + }); +}); + From 1bc055cc2a3ba8efde56bb942eebe3bb488330bc Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 10:23:18 +0900 Subject: [PATCH 09/40] fix(anthropic): the EOF terminal path must fail an error stop reason too Anthropic has THREE terminal paths and I had fixed two. The third exists for compatible providers that close after message_delta without message_stop, and it bypasses emitDone entirely - so it still emitted done(stopReason: error) and the bridge reported completed with a compaction item installed. Same rule now applies there. Test hardening from the same review, both cases where a green test was protecting nothing: - The Anthropic streaming test was titled "carrying usage" but never asserted usage, so removing it would have kept the test green. It now asserts input and output tokens, and a new case covers the EOF route end to end. - The Command Code coverage hand-constructed the downstream error event instead of driving the real parser. tests/command-code-error-finish.test.ts now exercises parseStream and parseResponse directly, and pins that an ordinary finish still completes and that a length finish is still a truncation rather than an error. Verified non-vacuous: dropping usage from either error terminal turns the suite red. --- src/adapters/anthropic.ts | 15 +++++ tests/anthropic-error-stop-reason.test.ts | 35 +++++++++- tests/command-code-error-finish.test.ts | 80 +++++++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/command-code-error-finish.test.ts diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 1787746018..fd141ccfb8 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1155,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" diff --git a/tests/anthropic-error-stop-reason.test.ts b/tests/anthropic-error-stop-reason.test.ts index 5921d03f87..d4b2a750d3 100644 --- a/tests/anthropic-error-stop-reason.test.ts +++ b/tests/anthropic-error-stop-reason.test.ts @@ -49,8 +49,40 @@ describe("an upstream error stop_reason is a failure, not a stop", () => { status: 200, headers: { "content-type": "text/event-stream" }, }))) events.push(e); - expect(events.some(e => e.type === "error")).toBe(true); + 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 () => { @@ -80,4 +112,3 @@ describe("an upstream error stop_reason is a failure, not a stop", () => { expect(events.some(e => e.type === "error")).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); + }); +}); + From 1dab7717c880225ff95105bca1cb29aca44a081c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 10:32:36 +0900 Subject: [PATCH 10/40] docs(devlog): record the final state of 050 after eight review rounds --- .../050_phase5-nonstreaming-terminal.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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 index 7df9f5ca54..08397649fe 100644 --- a/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md +++ b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md @@ -123,3 +123,49 @@ cannot be fixed here: 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. From 4a9a96e0365f4236a10ffc18228b728335f37c31 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 10:33:35 +0900 Subject: [PATCH 11/40] docs(devlog): final campaign status across all five phases --- .../000_index.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md index 742367cec8..3a63a53a53 100644 --- a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md +++ b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md @@ -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. From 5a3cd644653644eec4634c063f17214f90affeff Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:00:27 +0900 Subject: [PATCH 12/40] docs(devlog): plan the cursor-call integration onto the current dev head The campaign shipped against f64c0639. dev has moved 104 commits since, and two of our 18 files were touched there too. A per-file sweep found exactly one SEMANTIC collision and one textual one; the other 16 paths have zero dev commits. live-transport.ts is the semantic one: dev fixed the SAME clean-EOF defect in the opposite shape (a fail-closed error EVENT via finalizeTurnEvents, with CodeRabbit explicitly rejecting a throw) while our 54f68daf5 raises a typed transport error. An independent read-only investigation traced both shapes through the streaming and buffered Responses paths, including our own phase-050 bridge changes, and dev's shape survives: the error sets both errorEvent and sawTerminal, so buildResponseJSON already returns failed with no adapter_eof and no compaction history. What our branch still contributes there is emittedTerminal, which f145fd513 depends on, plus one extra guard so EOF finalization cannot append a second terminal after a mapper error. google.ts is textual: dev's six commits are request-side identity work, so the buffered finishReason hunk applies unchanged at :946 instead of :939. Docs only. No code moved in this commit. --- .../000_plan.md | 104 ++++++++++ .../010_phase1.md | 187 ++++++++++++++++++ .../020_phase2.md | 56 ++++++ .../030_phase3.md | 47 +++++ .../040_phase4.md | 36 ++++ .../050_phase5.md | 53 +++++ 6 files changed, 483 insertions(+) create mode 100644 devlog/_plan/260818_cursor_call_integration/000_plan.md create mode 100644 devlog/_plan/260818_cursor_call_integration/010_phase1.md create mode 100644 devlog/_plan/260818_cursor_call_integration/020_phase2.md create mode 100644 devlog/_plan/260818_cursor_call_integration/030_phase3.md create mode 100644 devlog/_plan/260818_cursor_call_integration/040_phase4.md create mode 100644 devlog/_plan/260818_cursor_call_integration/050_phase5.md 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..801a00d9f2 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/000_plan.md @@ -0,0 +1,104 @@ +# 000 — Integrate cursor-call onto dev and reach release-ready state + +## Objective + +Land the 31-commit `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 104 commits since, and two of the files +we changed were changed there too — one of them for the SAME defect, in the +opposite shape. + +This unit is the integration record, not a re-decode. The decode unit is +`devlog/_plan/260817_cursor_toolcall_decode/`. + +## Evidence base + +| Fact | Command | +|------|---------| +| merge-base = `f64c06391` | `git merge-base origin/dev cursor-call-prerebase-260818` | +| ours = 31 commits, snapshot `cursor-call-prerebase-260818` = `fe2237038` | `git rev-list --count origin/dev..cursor-call` | +| dev head = `87f7f970b`, 104 commits ahead of merge-base | `git rev-list --count cursor-call..origin/dev` | +| our 31 commits touch 18 files | `git diff --name-only cursor-call-prerebase-260818` | +| only 2 of those 18 were touched on dev | per-file `git log --oneline ..origin/dev -- ` | + +## Collision inventory (all 18 files) + +`COUNT` is dev commits touching that path since the merge-base. + +| 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/rename work, our hunk drifted 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 see 010: its EXPECTATION changes) | +| `tests/cursor-request-builder.test.ts` | 0 | none | +| `tests/cursor-tool-result-image.test.ts` | 0 | none | +| `tests/google-buffered-stop-reason.test.ts` | 0 | none | +| `devlog/_plan/260817_cursor_toolcall_decode/*` | 0 | none | + +An INDIRECT-breakage sweep found nothing: `AdapterEvent.done.stopReason?: string` +still exists (`src/types.ts:367-371`), no symbol our bridge patch references was +renamed, and `src/bridge.ts` / `src/responses/truncated-stop-reason.ts` have zero +dev commits. + +## Loop-spec + +- Loop archetype: verifier-defined (typecheck + full suite on lidge decide done). +- Write scope: the 18 files above plus this unit. No version bump, no npm publish, + no `main` promotion, no gui/ source changes. +- Tool/credential scope: local git, `ssh lidge` for verification, `gh`/GitHub app + for PRs and the admin merge. Push to `origin/cursor-call` is pre-approved + (`--no-verify`); force-push is inherent to the requested rebase and the + snapshot branch `cursor-call-prerebase-260818` is the recovery path. +- Bounds: no stated token budget. Wall-clock is dominated by the lidge full suite + (~470s at 12800 tests). CI is explicitly NOT checked (user waived). + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1-integration-roadmap | this unit | conflict inventory + roadmap (docs-only) | — | +| wp2-rebase | `010` | rebase with evidence-based conflict resolution | wp1 | +| wp3-remote-verify | `020` | typecheck + full suite on `ssh lidge` at the pushed SHA | wp2 | +| wp4-stacked-prs | `030` | stacked PRs targeting `dev` with the repo template | wp3 | +| wp5-merge | `040` | admin merge onto `dev` + ancestry proof | wp4 | +| wp6-release-gates | `050` | release gates on `dev` + go/no-go note | wp5 | + +## Accept criteria (mirrored into the goalplan) + +- `c1-roadmap-unit` — this unit exists with research + diff-level decade docs. +- `c2-conflict-inventory` — the table above, 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 clean + full suite green on lidge at the SHA. +- `c6-prs-open` — stacked PRs against `dev`, template filled. +- `c7-merged-on-dev` — `git merge-base --is-ancestor` proves it, not an API reply. +- `c8-release-gates` — privacy:scan, typecheck, full suite green on merged `dev`. +- `c9-go-no-go` — a written note on whether to cut a version. + +## Out of scope (carried follow-ups, NOT this unit) + +These were recorded in the decode unit and stay open: + +1. Kiro `completionMode: "disabled"` drops `stopReason` (`kiro.ts:1315`, `:1485`). +2. Google ordinary mode still forwards only `MAX_TOKENS` + five safety values; + `MALFORMED_RESPONSE`, `UNEXPECTED_TOOL_CALL`, `IMAGE_SAFETY`, `LANGUAGE` + become reasonless `done` (dev `google.ts:786-795`). +3. User-message images still placeholdered in `request-builder.ts`. +4. Phase 030 (xai apply_patch) remains a measurement cycle — NOT REPRODUCED. + New information: dev landed `bc229433a` + `8a4040384`, which stop the code-mode + guidance from forbidding a separately-advertised top-level `apply_patch`. That + is the same affordance surface 030 suspected, fixed independently. Re-probing + belongs to a later work-phase only if the user supplies a failing case. + 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..7866a309e2 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -0,0 +1,187 @@ +# 010 — WP2: rebase cursor-call onto dev with evidence-based conflict resolution + +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. `git rebase origin/dev` on `cursor-call` (snapshot `cursor-call-prerebase-260818` + already exists at `fe2237038`). +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. At any `google.ts` conflict: apply the hunk at dev's current location. +4. Every later commit should apply cleanly (zero dev commits on those paths). If + one does not, STOP and investigate rather than resolving mechanically. +5. Adversarial audit round on the resolved diff before pushing. + +## 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md new file mode 100644 index 0000000000..428fea31d3 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -0,0 +1,56 @@ +# 020 — WP3: full remote verification on ssh lidge + +## 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. Repository policy (`AGENTS.md` §Commands) requires +`bun run typecheck` and `bun run test` before a non-trivial PR is review-ready. + +The user's standing contract: the authoritative suite runs on `ssh lidge`, never +the local workstation. + +Remote checkout: `/home/lidgeai/Developer/opencodex` (bun 1.3.14), currently +parked at the pre-rebase campaign SHA `1651002c59`. + +`--isolate` is required: the flat suite bleeds environment between files without it +(known-good practice for this checkout). + +## Procedure (MODIFY: none — verification only) + +``` +ssh lidge 'cd ~/Developer/opencodex && git fetch origin cursor-call && git checkout -f && git log --oneline -1' +ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' +ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' +ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' +``` + +Run the suite as a managed background session (it takes ~8 minutes) and poll, +rather than blocking a turn. + +## Expected evidence + +- `bun x tsc --noEmit` → exit 0, no output. +- `bun test --isolate tests` → 0 fail. Pre-campaign baseline on the old base was + 12761 pass / 826 files; the post-050 campaign SHA was 12800 pass / 830 files. + Post-rebase the count rises again because dev added 104 commits of tests; the + bar is **0 fail**, not a specific pass count. + +## Known flake (do NOT treat as 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 before calling it a regression. + +## 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, not another +patch. Three → back to P with a changed plan. + +## Verification (C) + +The C gate for this work-phase is the remote output itself: typecheck exit 0 and a +`0 fail` line from `bun test --isolate tests`, both quoted with the SHA they ran +against. + 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..d1c05b3312 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -0,0 +1,47 @@ +# 030 — WP4: stacked PRs targeting dev + +## Policy constraints (`AGENTS.md`) + +- `dev` is the only integration target. Never open a feature PR against `main`. +- `.github/PULL_REQUEST_TEMPLATE.md` has three required sections: **Summary**, + **Verification**, **Checklist**. `enforce-target` rejects empty, thin, or + malformed descriptions. +- Stacked child PRs that target another OPEN PR's head branch are an intentional + workflow; `enforce-target` skips the wrong-base gate for them. Retarget children + to `dev` after the parent lands. +- CI status is NOT checked (user waived). + +## Stack shape + +The campaign is one dependency chain, and the branch is one linear history. The +honest stack boundary is by SUBSYSTEM, because that is what a reviewer can review +independently: + +| PR | Head branch | Base | Content | +|----|-------------|------|---------| +| 1 | `cursor-call-adapter` | `dev` | Cursor adapter: image tool results, cancel provenance, `emittedTerminal` (`878b067e8`..`c9681d043` + the resolved `54f68daf5`) | +| 2 | `cursor-call-bridge` | `cursor-call-adapter` | Bridge terminal/compaction work (`aa800ae65`..`1651002c5`) + `src/responses/truncated-stop-reason.ts` | +| 3 | `cursor-call` | `cursor-call-bridge` | devlog units (both decode and integration) | + +Splitting is only worth doing if the split points are clean commit boundaries in +the rebased history. If the rebase produced interleaved docs/code commits, prefer +ONE PR from `cursor-call` → `dev` over a fake stack: an unreviewable split is +worse than a single honest PR. Decide from the actual topology at WP4's P. + +## Description content per PR + +- **Summary** — the defect, the wire behavior before/after, and for PR 1 an + explicit note that dev independently fixed the clean-EOF defect and our + contribution on that file narrowed to `emittedTerminal` + the extra guard. +- **Verification** — the exact lidge commands from `020` with their output and the + SHA. No remembered passes. +- **Checklist** — all three boxes, honestly. +- No `Closes #`: no issue is being closed by this branch. + +## Verification (C) + +``` +gh pr list --state open --json number,baseRefName,headRefName,title +gh pr view --json body # confirm all three template sections present +``` + 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..d8cde042c8 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -0,0 +1,36 @@ +# 040 — WP5: admin merge onto dev + ancestry proof + +## Authorization + +The user explicitly granted admin merge authority for this branch ("admin 권한으로"). +CI checks are waived by the same user. That waiver covers THIS branch only. + +## Procedure + +Merge in dependency order (parent before child). For each PR: + +``` +gh pr merge --merge --admin +``` + +Do NOT squash across the campaign: the commit-by-commit history is the audit trail +for five phases of adversarial review, and the devlog references specific SHAs. +A squash would break every one of those references. + +If a child PR was stacked on a parent head branch, retarget it to `dev` after the +parent merges (`gh pr edit --base dev`) before merging it. + +## Ancestry proof (the actual criterion) + +A merge API response is not proof. The criterion is: + +``` +git fetch origin dev +git merge-base --is-ancestor origin/dev # exit 0 +git log --oneline -5 origin/dev +``` + +## Verification (C) + +Exit 0 from `--is-ancestor` plus the `origin/dev` log showing the merge commits. + 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..7fe98ef006 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -0,0 +1,53 @@ +# 050 — WP6: release gates on dev + go/no-go note + +## Scope boundary (explicit) + +IN: running the release 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 + +``` +ssh lidge 'cd ~/Developer/opencodex && git fetch origin dev && git checkout -f origin/dev && git log --oneline -1' +ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' +ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' +ssh lidge 'cd ~/Developer/opencodex && bun run privacy:scan' +ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' +``` + +GUI gates (`bun run lint:gui`, `bun run build:gui`) are only required if the merge +touched `gui/`. This campaign does not, so record that as N/A with the evidence +(`git diff --name-only` showing no `gui/` paths) rather than skipping silently. + +## Docs-site check + +Repository policy: user-facing behavior changes should update `docs-site/`. Decide +per change and record the reasoning: + +- Cursor tool-result images now reach the provider as real image content — a + capability change a user can observe. Check whether `docs-site/` claims the + adapter cannot send images anywhere (`cc906b0fc` already removed one such claim + from source comments). +- Truncated-turn reporting (`failed` instead of `completed`) is a correctness fix + in the failure path, not a documented feature. + +## Go/no-go note + +Write `devlog/_plan/260818_cursor_call_integration/060_release_readiness.md` with: + +- every gate, its command, its output, and the SHA it ran against; +- whether `dev` is releasable as-is; +- an explicit recommendation on cutting a version, with the reason. Current + released version is `2.24.2` (`origin/main` = `474584bcd`, tag `v2.24.2`). + 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, and the note exists and is committed. + From 7d8c2eafa6e02d46cb39d8cfd232c22fa82ff46f Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:16:45 +0900 Subject: [PATCH 13/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r1=20FAI?= =?UTF-8?q?L=20=E2=80=94=20six=20findings,=20all=20verified,=20all=20accep?= =?UTF-8?q?ted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round r1-20260818030046 attacked the integration plan before any rebase step ran and returned FAIL. Every finding was re-checked against the tree; none was waved through. Three changed the plan structurally. F3 needed a new work-phase. Choosing dev's error-EVENT shape for clean-EOF (010) is correct on its own merits, but finalizeTurnEvents' open-tool branch emits no usage while the thrown path attaches partialUsage. So the shape choice would have silently traded away token reporting on truncated turns. resolvedTurnUsage is already in that module and already used by the done branch, so wp2b/015 fixes the omission with a test that asserts usage separately from shape — the campaign already shipped one test titled "carrying usage" that never checked usage. F1 is real and is NOT fixed here. Every Cursor model sits in noVisionModels (registry.ts:978-982), so the vision sidecar replaces tool-result images with text before the adapter runs (core.ts:2225-2243, vision/index.ts:252-259,565-581). The 020 encoder work is correct and currently unreachable in production. Fixing it means role-aware vision policy plus an end-to-end test, and dropping Cursor from noVisionModels alone is unsafe because user images are still flattened. Recorded as follow-up 1 with the overstated capability claim corrected. F2 reframes the merge. MAINTAINERS.md:48-49 requires maintainer approval and successful required CI; the user waived CI and granted admin merge. That is owner authority, not compliance, and lidge is Linux-only. 040 now records it as an owner-authorized exception and forbids the readiness note from claiming "policy-compliant". F4 killed the three-PR stack: ten devlog commits precede the first code commit and docs interleave after each phase, so the advertised split needs cherry-picking and would not be this history. One honest PR instead, with the reasoning written down. F5 moved privacy:scan and audit:high before the PR (they are in release.ts:374,380) and requires the docs determination before the template box is ticked. F6 corrected the counts: 28 paths, not 18; 32 commits, not 31. --- .../000_plan.md | 128 ++++++++++------- .../005_audit_r1.md | 135 ++++++++++++++++++ .../015_phase2b_eof_usage.md | 83 +++++++++++ .../020_phase2.md | 58 +++++--- .../030_phase3.md | 84 ++++++----- .../040_phase4.md | 41 ++++-- .../050_phase5.md | 49 ++++--- 7 files changed, 436 insertions(+), 142 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/005_audit_r1.md create mode 100644 devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md diff --git a/devlog/_plan/260818_cursor_call_integration/000_plan.md b/devlog/_plan/260818_cursor_call_integration/000_plan.md index 801a00d9f2..a26207e41a 100644 --- a/devlog/_plan/260818_cursor_call_integration/000_plan.md +++ b/devlog/_plan/260818_cursor_call_integration/000_plan.md @@ -2,33 +2,44 @@ ## Objective -Land the 31-commit `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 104 commits since, and two of the files -we changed were changed there too — one of them for the SAME defect, in the -opposite shape. +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 104 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. 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` | `git merge-base origin/dev cursor-call-prerebase-260818` | -| ours = 31 commits, snapshot `cursor-call-prerebase-260818` = `fe2237038` | `git rev-list --count origin/dev..cursor-call` | -| dev head = `87f7f970b`, 104 commits ahead of merge-base | `git rev-list --count cursor-call..origin/dev` | -| our 31 commits touch 18 files | `git diff --name-only cursor-call-prerebase-260818` | -| only 2 of those 18 were touched on dev | per-file `git log --oneline ..origin/dev -- ` | +| snapshot `cursor-call-prerebase-260818` = `fe2237038`, 31 commits | `git rev-list --count ..cursor-call-prerebase-260818` | +| `cursor-call` now 32 commits (this unit's plan commit `66b9df9ef` is the 32nd) | `git rev-list --count ..cursor-call` | +| dev head = `87f7f970b`, 104 commits ahead of merge-base | `git rev-list --count cursor-call-prerebase-260818..origin/dev` | +| snapshot touches **28** paths (18 source/test + 10 devlog) | `git diff --name-only cursor-call-prerebase-260818` | +| only 2 of the 28 were touched on dev | per-path `git log --oneline ..origin/dev -- ` | -## Collision inventory (all 18 files) +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. -`COUNT` is dev commits touching that path since the merge-base. +## 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/rename work, our hunk drifted 939 → 946 | +| `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 | @@ -41,64 +52,85 @@ This unit is the integration record, not a re-decode. The decode unit is | `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 see 010: its EXPECTATION changes) | +| `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 | +| `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 | -| `devlog/_plan/260817_cursor_toolcall_decode/*` | 0 | none | -An INDIRECT-breakage sweep found nothing: `AdapterEvent.done.stopReason?: string` -still exists (`src/types.ts:367-371`), no symbol our bridge patch references was -renamed, and `src/bridge.ts` / `src/responses/truncated-stop-reason.ts` have zero -dev commits. +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 files above plus this unit. No version bump, no npm publish, - no `main` promotion, no gui/ source changes. +- Write scope: the 18 source/test paths above, plus `src/vision/index.ts` and + `src/providers/registry.ts` if WP2b's investigation concludes a fix belongs + there, plus this unit. 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 admin merge. Push to `origin/cursor-call` is pre-approved - (`--no-verify`); force-push is inherent to the requested rebase and the - snapshot branch `cursor-call-prerebase-260818` is the recovery path. -- Bounds: no stated token budget. Wall-clock is dominated by the lidge full suite - (~470s at 12800 tests). CI is explicitly NOT checked (user waived). + 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 | conflict inventory + roadmap (docs-only) | — | +| wp1-integration-roadmap | this unit + `005` | conflict inventory, audit absorption, roadmap (docs-only) | — | | wp2-rebase | `010` | rebase with evidence-based conflict resolution | wp1 | -| wp3-remote-verify | `020` | typecheck + full suite on `ssh lidge` at the pushed SHA | wp2 | -| wp4-stacked-prs | `030` | stacked PRs targeting `dev` with the repo template | wp3 | -| wp5-merge | `040` | admin merge onto `dev` + ancestry proof | wp4 | -| wp6-release-gates | `050` | release gates on `dev` + go/no-go note | wp5 | +| 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 exists with research + diff-level decade docs. -- `c2-conflict-inventory` — the table above, produced by the named commands. +- `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 clean + full suite green on lidge at the SHA. -- `c6-prs-open` — stacked PRs against `dev`, template filled. +- `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, typecheck, full suite green on merged `dev`. +- `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) -These were recorded in the decode unit and stay open: - -1. Kiro `completionMode: "disabled"` drops `stopReason` (`kiro.ts:1315`, `:1485`). -2. Google ordinary mode still forwards only `MAX_TOKENS` + five safety values; - `MALFORMED_RESPONSE`, `UNEXPECTED_TOOL_CALL`, `IMAGE_SAFETY`, `LANGUAGE` - become reasonless `done` (dev `google.ts:786-795`). -3. User-message images still placeholdered in `request-builder.ts`. -4. Phase 030 (xai apply_patch) remains a measurement cycle — NOT REPRODUCED. - New information: dev landed `bc229433a` + `8a4040384`, which stop the code-mode - guidance from forbidding a separately-advertised top-level `apply_patch`. That - is the same affordance surface 030 suspected, fixed independently. Re-probing - belongs to a later work-phase only if the user supplies a failing case. +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/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md new file mode 100644 index 0000000000..9adf07c4a2 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md @@ -0,0 +1,83 @@ +# 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. + +## 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:1195-1199`) → `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`), and `resolvedTurnUsage(state)` is defined +in the same file at `:1340` and already used by the `done` branch at `:1376`. 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. + +## MODIFY — `src/adapters/cursor/protobuf-events.ts` + +In `finalizeTurnEvents`, the open-tool branch: + +```diff + for (const callId of openCallIds) state.translatorBudget?.closeCall(callId); + state.openToolCalls.clear(); +- return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }]; ++ // Same usage resolution as the clean `done` branch below. A truncated turn still consumed ++ // tokens, and the error variant carries usage (types.ts CursorServerMessage). Without this the ++ // event-shaped truncation reports 0 tokens / unreported, while the thrown path reports real ++ // consumption via attachPartialUsage — so the choice of shape would change the bill. ++ return [{ ++ type: "error", ++ message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.`, ++ usage: resolvedTurnUsage(state), ++ }]; +``` + +`resolvedTurnUsage` is already in scope (same module). + +### Check before writing: does the adapter overwrite it? + +`src/adapters/cursor.ts:181-192` builds its error event from `err.partialUsage`. +That is the THROWN path and is unrelated to an event that already flowed through +the mapper. Confirm the mapper (`message-mapper.ts`) forwards `usage` on an error +message rather than dropping it; if it drops it, the mapper is the real fix site +and this doc gets amended at WP2b's P rather than patched blindly. + +## TESTS — `tests/cursor-eof-terminal.test.ts` + +Add a case, and drive it red first: + +```ts +test("an EOF truncation error reports the tokens the turn already consumed", async () => { + // A checkpoint/usage frame BEFORE the open tool call, then clean EOF with no terminal. + // Red before the fix: usage is undefined on the error event. + // ...arrange frames: assistant text with a token signal, toolCallStarted, then stream end + expect(errorEvent.usage).toBeDefined(); + expect(errorEvent.usage?.outputTokens ?? 0).toBeGreaterThan(0); +}); +``` + +The existing rewritten case from `010` asserts the SHAPE (error event, no `done`, +no `tool_call_end`); this new one asserts the USAGE. Keeping them separate means a +future change cannot quietly satisfy one by breaking the other — which is the +mistake the decode campaign already made once, when a test titled "carrying usage" +never asserted usage. + +## 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. + diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 428fea31d3..ecfdc7f0f3 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -1,56 +1,70 @@ # 020 — WP3: full remote verification on ssh lidge +Revised by audit `r1` finding F5: `privacy:scan` and `audit:high` run HERE, before +the PR, not after the merge. + ## 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. Repository policy (`AGENTS.md` §Commands) requires -`bun run typecheck` and `bun run test` before a non-trivial PR is review-ready. +scoped adapter change. `AGENTS.md` §Commands requires `bun run typecheck` and +`bun run test` before a non-trivial PR is review-ready. -The user's standing contract: the authoritative suite runs on `ssh lidge`, never -the local workstation. +Standing user contract: the authoritative suite runs on `ssh lidge`, never locally. -Remote checkout: `/home/lidgeai/Developer/opencodex` (bun 1.3.14), currently -parked at the pre-rebase campaign SHA `1651002c59`. +Remote checkout: `/home/lidgeai/Developer/opencodex`, bun 1.3.14, 16 cores. -`--isolate` is required: the flat suite bleeds environment between files without it -(known-good practice for this checkout). +`--isolate` is required: the flat suite bleeds environment between files without it. -## Procedure (MODIFY: none — verification only) +## Procedure (verification only, no MODIFY) ``` ssh lidge 'cd ~/Developer/opencodex && git fetch origin cursor-call && git checkout -f && git log --oneline -1' ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' +ssh lidge 'cd ~/Developer/opencodex && bun run privacy:scan' +ssh lidge 'cd ~/Developer/opencodex && bun run audit:high' ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' ``` -Run the suite as a managed background session (it takes ~8 minutes) and poll, -rather than blocking a turn. +`audit:high` and `privacy:scan` are in `scripts/release.ts:374,380` — the release +authority runs both, so they belong before a merge that claims release readiness. + +Run the suite as a managed background session (~8 min) and poll; do not block a +turn on it. ## Expected evidence - `bun x tsc --noEmit` → exit 0, no output. -- `bun test --isolate tests` → 0 fail. Pre-campaign baseline on the old base was - 12761 pass / 826 files; the post-050 campaign SHA was 12800 pass / 830 files. - Post-rebase the count rises again because dev added 104 commits of tests; the - bar is **0 fail**, not a specific pass count. +- `bun run privacy:scan` → exit 0. +- `bun run audit:high` → exit 0. If it reports a pre-existing advisory that also + fails on `origin/dev`, record that comparison rather than attributing it to this + branch. +- `bun test --isolate tests` → **0 fail**. Pass counts move because dev added 104 + commits of tests; the bar is 0 fail, not a count. (Prior data points: 12761 pass + at the old base, 12800 at the campaign tip.) + +## Platform gap (state it, do not paper over it) + +lidge is Linux. Repository CI covers Linux, Windows, and macOS. Windows-specific +surfaces (shims, installer, PowerShell) are historically where this repository +breaks. This campaign touches none of them — record that as the reason the Linux +evidence is *adequate for this diff*, rather than implying Linux equals CI. -## Known flake (do NOT treat as a regression without isolation) +## 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 before calling it a regression. +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, not another -patch. Three → back to P with a changed plan. +consecutive failed repairs of the same failure → root-cause mode. Three → back to P +with a changed plan. ## Verification (C) -The C gate for this work-phase is the remote output itself: typecheck exit 0 and a -`0 fail` line from `bun test --isolate tests`, both quoted with the SHA they ran -against. +Typecheck exit 0, privacy:scan exit 0, audit:high exit 0, and a `0 fail` line from +`bun test --isolate tests` — each quoted with the SHA it ran against. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index d1c05b3312..56db8213e6 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,47 +1,57 @@ -# 030 — WP4: stacked PRs targeting dev +# 030 — WP4: the pull request against dev + +Rewritten after audit `r1` finding F4. The earlier draft promised three stacked +PRs; the history does not support that split. + +## Why ONE PR, not a stack + +The user asked for a stacked PR. The honest answer is that this branch is a single +linear chain whose docs and code interleave: + +``` +git log --oneline --reverse ..cursor-call +``` + +Ten devlog commits come before the first code commit, then documentation lands +after each implementation phase (`dfb6fb884`, `6d9744283`, `3f5bf955d`, +`f10108315`, `fe2237038`, `66b9df9ef`). A "PR1 = adapter, PR2 = bridge, PR3 = +docs" split needs reordering or cherry-picking, so the resulting branches would not +be this history — and `AGENTS.md:178-180` would then require verifying each layer +at its own SHA, tripling the ~8-minute suite for a split that reviews worse. + +So: one PR, `cursor-call` → `dev`, with the phase structure explained in the body +(each phase is a contiguous commit run, which is what a reviewer actually needs to +read it phase by phase). If the user still wants separate PRs after seeing this, +that is a rebuild-the-history decision to make deliberately, not a thing to fake. ## Policy constraints (`AGENTS.md`) -- `dev` is the only integration target. Never open a feature PR against `main`. -- `.github/PULL_REQUEST_TEMPLATE.md` has three required sections: **Summary**, - **Verification**, **Checklist**. `enforce-target` rejects empty, thin, or - malformed descriptions. -- Stacked child PRs that target another OPEN PR's head branch are an intentional - workflow; `enforce-target` skips the wrong-base gate for them. Retarget children - to `dev` after the parent lands. -- CI status is NOT checked (user waived). - -## Stack shape - -The campaign is one dependency chain, and the branch is one linear history. The -honest stack boundary is by SUBSYSTEM, because that is what a reviewer can review -independently: - -| PR | Head branch | Base | Content | -|----|-------------|------|---------| -| 1 | `cursor-call-adapter` | `dev` | Cursor adapter: image tool results, cancel provenance, `emittedTerminal` (`878b067e8`..`c9681d043` + the resolved `54f68daf5`) | -| 2 | `cursor-call-bridge` | `cursor-call-adapter` | Bridge terminal/compaction work (`aa800ae65`..`1651002c5`) + `src/responses/truncated-stop-reason.ts` | -| 3 | `cursor-call` | `cursor-call-bridge` | devlog units (both decode and integration) | - -Splitting is only worth doing if the split points are clean commit boundaries in -the rebased history. If the rebase produced interleaved docs/code commits, prefer -ONE PR from `cursor-call` → `dev` over a fake stack: an unreviewable split is -worse than a single honest PR. Decide from the actual topology at WP4's P. - -## Description content per PR - -- **Summary** — the defect, the wire behavior before/after, and for PR 1 an - explicit note that dev independently fixed the clean-EOF defect and our - contribution on that file narrowed to `emittedTerminal` + the extra guard. -- **Verification** — the exact lidge commands from `020` with their output and the - SHA. No remembered passes. -- **Checklist** — all three boxes, honestly. -- No `Closes #`: no issue is being closed by this branch. +- `dev` is the only integration target. Never `main`. +- `.github/PULL_REQUEST_TEMPLATE.md` requires **Summary**, **Verification**, + **Checklist**. `enforce-target` rejects empty, thin, or malformed descriptions. +- CI status is not checked (user waived) — see `040` for how that is recorded. + +## Description content + +- **Summary** — per phase: the defect, the wire behavior before and after. Must + include two honest notes: (a) dev independently fixed the clean-EOF defect, so + our contribution on `live-transport.ts` narrowed to `emittedTerminal` plus one + guard; (b) the tool-result image encoder is correct but currently unreachable in + production because all Cursor models are in `noVisionModels` — named as a + follow-up, not claimed as a shipped capability. +- **Verification** — the exact lidge commands from `020` with output and SHA. No + remembered passes. +- **Checklist** — three boxes, each honestly. "Docs or release notes were updated + when needed" requires the `docs-site/` determination to be MADE first (F5), not + deferred to `050`. +- No `Closes #`: no issue is being closed. ## Verification (C) ``` gh pr list --state open --json number,baseRefName,headRefName,title -gh pr view --json body # confirm all three template sections present +gh pr view --json body ``` +Base must be `dev`; all three template sections present and non-thin. + diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index d8cde042c8..aecca51412 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -1,28 +1,41 @@ -# 040 — WP5: admin merge onto dev + ancestry proof +# 040 — WP5: merge onto dev + ancestry proof -## Authorization +Revised after audit `r1` finding F2. -The user explicitly granted admin merge authority for this branch ("admin 권한으로"). -CI checks are waived by the same user. That waiver covers THIS branch only. +## Authority, stated precisely -## Procedure +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 this merge is an **owner-authorized exception**, and every downstream claim must +say so. Concretely: + +- The platform gap is real: lidge is Linux, CI covers Linux + Windows + macOS. +- This diff touches no Windows-sensitive surface (no shims, installer, PowerShell, + or path handling), which is why Linux evidence is adequate *for this diff*. +- `050`'s readiness note may say "gates green on Linux; CI waived by the owner". + It may **not** say "policy-compliant" or "all required checks passed". -Merge in dependency order (parent before child). For each PR: +If the user wants full compliance instead, the path is to let required CI run on the +PR head before merging. That is a one-line change to this plan, not a rewrite. + +## Procedure ``` gh pr merge --merge --admin ``` -Do NOT squash across the campaign: the commit-by-commit history is the audit trail -for five phases of adversarial review, and the devlog references specific SHAs. -A squash would break every one of those references. - -If a child PR was stacked on a parent head branch, retarget it to `dev` after the -parent merges (`gh pr edit --base dev`) before merging it. +Do NOT squash. The commit-by-commit history is the audit trail for five phases of +adversarial review, and the devlog references specific SHAs — a squash breaks every +one of those references. ## Ancestry proof (the actual criterion) -A merge API response is not proof. The criterion is: +A merge API response is not proof: ``` git fetch origin dev @@ -32,5 +45,5 @@ git log --oneline -5 origin/dev ## Verification (C) -Exit 0 from `--is-ancestor` plus the `origin/dev` log showing the merge commits. +Exit 0 from `--is-ancestor`, plus the `origin/dev` log showing the merge. diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md index 7fe98ef006..88a2441257 100644 --- a/devlog/_plan/260818_cursor_call_integration/050_phase5.md +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -1,13 +1,16 @@ # 050 — WP6: release gates on dev + go/no-go note +Revised after audit `r1` finding F5: these gates are a RE-RUN on merged `dev`, not +first contact. First contact is `020`, before the PR. + ## Scope boundary (explicit) -IN: running the release gates on merged `dev` and writing an evidence-backed -readiness note. +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 +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 @@ -17,34 +20,38 @@ ssh lidge 'cd ~/Developer/opencodex && git fetch origin dev && git checkout -f o ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' ssh lidge 'cd ~/Developer/opencodex && bun run privacy:scan' +ssh lidge 'cd ~/Developer/opencodex && bun run audit:high' ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' ``` -GUI gates (`bun run lint:gui`, `bun run build:gui`) are only required if the merge -touched `gui/`. This campaign does not, so record that as N/A with the evidence -(`git diff --name-only` showing no `gui/` paths) rather than skipping silently. - -## Docs-site check +GUI gates (`lint:gui`, `build:gui`) are N/A for a source-only diff — record the +evidence (`git diff --name-only` showing no `gui/` paths) rather than skipping +silently. -Repository policy: user-facing behavior changes should update `docs-site/`. Decide -per change and record the reasoning: +## Docs-site determination (must already be made at `030`) -- Cursor tool-result images now reach the provider as real image content — a - capability change a user can observe. Check whether `docs-site/` claims the - adapter cannot send images anywhere (`cc906b0fc` already removed one such claim - from source comments). +- Cursor tool-result images: the encoder supports them, but 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 currently still accurate end-to-end and stays. - Truncated-turn reporting (`failed` instead of `completed`) is a correctness fix - in the failure path, not a documented feature. + 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. ## Go/no-go note -Write `devlog/_plan/260818_cursor_call_integration/060_release_readiness.md` with: +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, merge was an owner-authorized exception; +- the open follow-ups from `000` that a reader would otherwise assume were fixed — + especially F1, because the campaign's own docs previously overstated it; - whether `dev` is releasable as-is; -- an explicit recommendation on cutting a version, with the reason. Current - released version is `2.24.2` (`origin/main` = `474584bcd`, tag `v2.24.2`). - A provider-correctness batch of this size is a minor bump candidate, but the +- an explicit recommendation on cutting a version, with the reason. Released + version is `2.24.2` (`origin/main` = `474584bcd`, tag `v2.24.2`). 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) From fe015d4df1fb640aab4c4dd10b2ff6a7e286d2f3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:18:05 +0900 Subject: [PATCH 14/40] =?UTF-8?q?docs(devlog):=20correct=20the=20020=20cap?= =?UTF-8?q?ability=20claim=20=E2=80=94=20the=20encoder=20shipped,=20not=20?= =?UTF-8?q?the=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit r1 finding F1 proved the decode unit overstated phase 020. The encoder does emit real McpImageContent, and that part is right. But every Cursor model is in noVisionModels (registry.ts:978-982), so the vision sidecar replaces tool-result images with text descriptions before the adapter runs, or strips them fail-closed when no sidecar plan exists (core.ts:2225-2243, vision/index.ts:252-259,565-581). The phase test calls encodeCursorRunRequest directly, so it never crosses that preprocessing and proves encoder support only. Correcting the wording rather than the code is deliberate: dropping Cursor from noVisionModels would leave user images with neither an image nor a description, because request-builder.ts:206-214 still flattens them. The fix is role-aware vision policy plus an end-to-end regression, which is its own unit. --- .../000_index.md | 6 ++--- ...020_phase2-toolresult-image-passthrough.md | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md index 3a63a53a53..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 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`. From 39e04a8b6cd32211081ef21e6aaa654a017f712b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:27:15 +0900 Subject: [PATCH 15/40] =?UTF-8?q?docs(devlog),refactor:=20absorb=20audit?= =?UTF-8?q?=20r2=20NEAR-PASS=20=E2=80=94=20three=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r2 re-verified all six r1 closures and found three that were real but incomplete. The important one is the usage resolver in WP2b. My first draft said resolvedTurnUsage(state), which is the CLEAN-turn resolver: it falls back to the session carry-forward and then the request estimate, so it returns a number even when this turn produced no token signal. partialUsageFromEventState exists exactly because that is wrong for a failure — its own comment says a carry-forward belongs to an earlier successful turn and cannot prove a first-frame failure consumed anything. So the draft would have traded reporting 0 tokens for reporting someone else's tokens. 015 now moves that helper down into protobuf-events.ts (the dependency already runs that way; the reverse would cycle) and adds the negative regression case r2 asked for. Also confirmed the patch site: message-mapper.ts:29 forwards usage on an error event, so no mapper change is needed. Second: every moving-ref number in the plan was already stale. dev went from 87f7f970b (+104) to e1bdbc1e5 (+124) while I was writing. The counts are now pinned to immutable SHAs, the rebase re-reads origin/dev instead of trusting the doc, and the collision set was re-checked at the later head — still the same two paths. Third: the plan deferred F1 while its write scope conditionally allowed WP2b to edit vision policy. That contradiction is removed; vision is out of scope. And the honesty cleanup was incomplete — request-builder.ts and two test files still said tool-result images "reach Cursor". They now say the encoder emits them, and name the noVisionModels preprocessing that stops anything from reaching Cursor today. Comment-only in src/ and tests/: bun test tests/cursor-request-builder.test.ts tests/cursor-tool-result-image.test.ts -> 48 pass / 0 fail. --- .../000_plan.md | 30 ++-- .../015_phase2b_eof_usage.md | 147 ++++++++++++------ src/adapters/cursor/request-builder.ts | 8 +- tests/cursor-request-builder.test.ts | 6 +- tests/cursor-tool-result-image.test.ts | 10 +- 5 files changed, 133 insertions(+), 68 deletions(-) diff --git a/devlog/_plan/260818_cursor_call_integration/000_plan.md b/devlog/_plan/260818_cursor_call_integration/000_plan.md index a26207e41a..aaf292b057 100644 --- a/devlog/_plan/260818_cursor_call_integration/000_plan.md +++ b/devlog/_plan/260818_cursor_call_integration/000_plan.md @@ -4,10 +4,17 @@ 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 104 commits since, and two of the source files we +(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/`. @@ -22,12 +29,12 @@ the merge, the stack claim replaced) — they were not absorbed as wording tweak | Fact | Command | |------|---------| -| merge-base = `f64c06391` | `git merge-base origin/dev cursor-call-prerebase-260818` | -| snapshot `cursor-call-prerebase-260818` = `fe2237038`, 31 commits | `git rev-list --count ..cursor-call-prerebase-260818` | -| `cursor-call` now 32 commits (this unit's plan commit `66b9df9ef` is the 32nd) | `git rev-list --count ..cursor-call` | -| dev head = `87f7f970b`, 104 commits ahead of merge-base | `git rev-list --count cursor-call-prerebase-260818..origin/dev` | -| snapshot touches **28** paths (18 source/test + 10 devlog) | `git diff --name-only cursor-call-prerebase-260818` | -| only 2 of the 28 were touched on dev | per-path `git log --oneline ..origin/dev -- ` | +| 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. @@ -69,9 +76,11 @@ upstream hazard `r1` found is not a renamed import — it is request PREPROCESSI ## Loop-spec - Loop archetype: verifier-defined (typecheck + full suite on lidge decide done). -- Write scope: the 18 source/test paths above, plus `src/vision/index.ts` and - `src/providers/registry.ts` if WP2b's investigation concludes a fix belongs - there, plus this unit. No version bump, no npm publish, no `main` promotion. +- 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 @@ -133,4 +142,3 @@ decode unit's docs. Landing a rebase does not make it worse. 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/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md index 9adf07c4a2..9ea1f9b49c 100644 --- a/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md +++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md @@ -3,80 +3,127 @@ 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:1195-1199`) → `cursor.ts:181-192` copies `partialUsage` into the error event | +| 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`), and `resolvedTurnUsage(state)` is defined -in the same file at `:1340` and already used by the `done` branch at `:1376`. So the -omission is an oversight in the open-tool branch, not a design constraint. +(`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` -In `finalizeTurnEvents`, the open-tool branch: - -```diff - for (const callId of openCallIds) state.translatorBudget?.closeCall(callId); - state.openToolCalls.clear(); -- return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }]; -+ // Same usage resolution as the clean `done` branch below. A truncated turn still consumed -+ // tokens, and the error variant carries usage (types.ts CursorServerMessage). Without this the -+ // event-shaped truncation reports 0 tokens / unreported, while the thrown path reports real -+ // consumption via attachPartialUsage — so the choice of shape would change the bill. -+ return [{ -+ type: "error", -+ message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.`, -+ usage: resolvedTurnUsage(state), -+ }]; -``` - -`resolvedTurnUsage` is already in scope (same module). - -### Check before writing: does the adapter overwrite it? - -`src/adapters/cursor.ts:181-192` builds its error event from `err.partialUsage`. -That is the THROWN path and is unrelated to an event that already flowed through -the mapper. Confirm the mapper (`message-mapper.ts`) forwards `usage` on an error -message rather than dropping it; if it drops it, the mapper is the real fix site -and this doc gets amended at WP2b's P rather than patched blindly. +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. Any test +importing it from `live-transport.ts` must be repointed; check +`rg -n 'partialUsageFromEventState' tests` first. + +## 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` -Add a case, and drive it red first: +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(); + }); -```ts -test("an EOF truncation error reports the tokens the turn already consumed", async () => { - // A checkpoint/usage frame BEFORE the open tool call, then clean EOF with no terminal. - // Red before the fix: usage is undefined on the error event. - // ...arrange frames: assistant text with a token signal, toolCallStarted, then stream end - expect(errorEvent.usage).toBeDefined(); - expect(errorEvent.usage?.outputTokens ?? 0).toBeGreaterThan(0); -}); -``` +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. -The existing rewritten case from `010` asserts the SHAPE (error event, no `done`, -no `tool_call_end`); this new one asserts the USAGE. Keeping them separate means a -future change cannot quietly satisfy one by breaking the other — which is the -mistake the decode 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 -``` + 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. 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/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"); From 2d68a3e081faa6a7ddff48859dd088cc7700b1ed Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:39:32 +0900 Subject: [PATCH 16/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r3=20FAI?= =?UTF-8?q?L=20=E2=80=94=20five=20workflow=20findings,=20and=20restore=20t?= =?UTF-8?q?he=20stacked=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3 was a fresh reviewer (r1/r2's was retired; a final gate must not reuse a contaminated one). Every CODE resolution passed: the merged EOF block typechecks and preserves dev's guard precedence, the WP2b helper move creates no cycle, the mapper and bridge really do carry usage on a failed turn, and the collision set is still just two files at dev e1bdbc1e5. The five findings were all in the workflow. F3 is the one worth reading. r1 correctly killed a FABRICATED adapter/bridge/docs split, and I drew the wrong conclusion from it — "no honest split exists" instead of "that split was the wrong one". The campaign's own phase boundaries are already clean commits: dfb6fb884 ends the Cursor wire work (17 commits), 6d9744283 ends the CANCEL provenance work (3), and the rest is bridge/adapter terminals (15). The file sets separate cleanly and the dependency is real — PR2's guard reads PR1's emittedTerminal, and PR3's bridge logic is what makes PR1/PR2's error events reportable at all. So the user's stacked PR request is back, with a per-layer verification table because AGENTS.md:178-180 wants each layer's own evidence. F1 and F2 are both about not trusting a cached ref or a shared checkout. dev moved twice while I was planning, so the base is now pinned with git ls-remote (the live head, the way scripts/release.ts:327-335 does it) and re-read before every merge in the stack. And the remote gates no longer checkout -f the shared lidge checkout, which would have discarded any tracked uncommitted work there; they use a dedicated /tmp/ocx-* worktree, which is already that host's pattern. F4: build:gui is not N/A just because no gui/ path changed — prepublishOnly runs it unconditionally and it also runs prepare:package. It moves into both gate phases, and the readiness note must say publication still needs Cross-platform CI plus Service lifecycle at the exact SHA. F5: the plan cached origin/main = 474584bcd, which is now 0013b2347 while the v2.24.2 tag still points at the old SHA. Same root cause as F1. --- .../006_audit_r3.md | 108 ++++++++++++++++++ .../015_phase2b_eof_usage.md | 20 +++- .../020_phase2.md | 92 +++++++++++---- .../030_phase3.md | 70 ++++++------ .../040_phase4.md | 52 ++++++--- .../050_phase5.md | 77 +++++++++---- 6 files changed, 317 insertions(+), 102 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/006_audit_r3.md 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/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md index 9ea1f9b49c..2495da6b03 100644 --- a/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md +++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md @@ -77,9 +77,22 @@ 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. Any test -importing it from `live-transport.ts` must be repointed; check -`rg -n 'partialUsageFromEventState' tests` first. +`./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 @@ -127,4 +140,3 @@ so the two docs do not conflict (confirmed in audit `r2`). `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. - diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index ecfdc7f0f3..93c8850a03 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -1,7 +1,7 @@ # 020 — WP3: full remote verification on ssh lidge -Revised by audit `r1` finding F5: `privacy:scan` and `audit:high` run HERE, before -the PR, not after the merge. +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 @@ -12,44 +12,73 @@ scoped adapter change. `AGENTS.md` §Commands requires `bun run typecheck` and Standing user contract: the authoritative suite runs on `ssh lidge`, never locally. -Remote checkout: `/home/lidgeai/Developer/opencodex`, bun 1.3.14, 16 cores. +lidge: `/home/lidgeai/Developer/opencodex`, bun 1.3.14, 16 cores. `--isolate` is required: the flat suite bleeds environment between files without it. -## Procedure (verification only, no MODIFY) +## 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: + +``` +ssh lidge 'cd ~/Developer/opencodex && git fetch origin cursor-call dev' +ssh lidge 'cd ~/Developer/opencodex && git worktree add /tmp/ocx-cc- ' +ssh lidge 'cd /tmp/ocx-cc- && git log --oneline -1' +ssh lidge 'cd /tmp/ocx-cc- && bun install --frozen-lockfile' +``` + +Remove the worktree when the phase closes (`git worktree remove`), and never touch +the shared checkout's HEAD. + +## Pin the base (r3 F1) + +`dev` moves. Record, at the moment the rebase runs: ``` -ssh lidge 'cd ~/Developer/opencodex && git fetch origin cursor-call && git checkout -f && git log --oneline -1' -ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' -ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' -ssh lidge 'cd ~/Developer/opencodex && bun run privacy:scan' -ssh lidge 'cd ~/Developer/opencodex && bun run audit:high' -ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' +git ls-remote origin refs/heads/dev # LIVE head, not the tracking ref ``` -`audit:high` and `privacy:scan` are in `scripts/release.ts:374,380` — the release -authority runs both, so they belong before a merge that claims release readiness. +That SHA is `VERIFIED_BASE`. Every later phase compares against it, and WP5 refuses +to merge if the live `dev` head has moved off it. Using `git ls-remote` rather than +`origin/dev` follows `scripts/release.ts:327-335`, which exists because the local +tracking ref can be minutes stale. -Run the suite as a managed background session (~8 min) and poll; do not block a -turn on it. +## Gates + +``` +bun x tsc --noEmit +bun run privacy:scan +bun run audit:high +bun test --isolate tests +bun run build:gui # see r3 F4 — publish runs this unconditionally +``` + +`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 on `origin/dev`, record that comparison rather than attributing it to this - branch. -- `bun test --isolate tests` → **0 fail**. Pass counts move because dev added 104 - commits of tests; the bar is 0 fail, not a count. (Prior data points: 12761 pass - at the old base, 12800 at the campaign tip.) + 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. Windows-specific -surfaces (shims, installer, PowerShell) are historically where this repository -breaks. This campaign touches none of them — record that as the reason the Linux -evidence is *adequate for this diff*, rather than implying Linux equals CI. +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) @@ -65,6 +94,19 @@ with a changed plan. ## Verification (C) -Typecheck exit 0, privacy:scan exit 0, audit:high exit 0, and a `0 fail` line from -`bun test --isolate tests` — each quoted with the SHA it ran against. +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`. + +## 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-interaction-query.test.ts` + FULL suite | diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index 56db8213e6..cb38a2f751 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,50 +1,53 @@ -# 030 — WP4: the pull request against dev +# 030 — WP4: the stacked pull requests against dev -Rewritten after audit `r1` finding F4. The earlier draft promised three stacked -PRs; the history does not support that split. +Rewritten twice. Audit `r1` F4 killed the first version (a fabricated +adapter/bridge/docs split needing cherry-picks). Audit `r3` F3 then showed the +second version was over-corrected: an **honest** stack does exist at the campaign's +own phase boundaries, with no reordering at all. The user asked for a stacked PR, and +it turns out the history supports one. -## Why ONE PR, not a stack +## The stack (verified against the real topology) -The user asked for a stacked PR. The honest answer is that this branch is a single -linear chain whose docs and code interleave: +`git log --oneline --reverse ..cursor-call` splits at existing commits: -``` -git log --oneline --reverse ..cursor-call -``` +| PR | Head | Base | Range | Commits | Files | +|----|------|------|-------|---------|-------| +| 1 | `cursor-call-wire` | `dev` | `..dfb6fb884` | 17 | `cursor-errors.ts`, `live-transport.ts`, `native-exec.ts`, `protobuf-request.ts`, `request-builder.ts`, 3 cursor tests, 8 decode docs | +| 2 | `cursor-call-cancel` | PR1 head | `dfb6fb884..6d9744283` | 3 | `cursor-errors.ts`, `live-transport.ts`, `cursor-cancel-provenance.test.ts`, `040_*.md` | +| 3 | `cursor-call` | PR2 head | `6d9744283..HEAD` | 15 + WP2b | `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, 4 tests, integration docs | -Ten devlog commits come before the first code commit, then documentation lands -after each implementation phase (`dfb6fb884`, `6d9744283`, `3f5bf955d`, -`f10108315`, `fe2237038`, `66b9df9ef`). A "PR1 = adapter, PR2 = bridge, PR3 = -docs" split needs reordering or cherry-picking, so the resulting branches would not -be this history — and `AGENTS.md:178-180` would then require verifying each layer -at its own SHA, tripling the ~8-minute suite for a split that reviews worse. +The layering is not cosmetic: PR2's `CursorUnexpectedCancelError` guard reads the +`emittedTerminal` flag PR1 introduces, and PR3's bridge terminal logic is what makes +PR1's and PR2's adapter-level error events reportable instead of silently dropped. -So: one PR, `cursor-call` → `dev`, with the phase structure explained in the body -(each phase is a contiguous commit run, which is what a reviewer actually needs to -read it phase by phase). If the user still wants separate PRs after seeing this, -that is a rebuild-the-history decision to make deliberately, not a thing to fake. +WP2b (EOF usage) belongs in **PR1**, because it modifies `finalizeTurnEvents` — the +function PR1's EOF resolution selects. Land it during the rebase as part of that +layer rather than appending it to PR3. ## Policy constraints (`AGENTS.md`) - `dev` is the only integration target. Never `main`. +- Stacked children targeting an OPEN parent's head branch are an intentional + workflow; `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 empty, thin, or malformed descriptions. -- CI status is not checked (user waived) — see `040` for how that is recorded. +- Each layer carries its OWN verification evidence (`AGENTS.md:178-180`), per the + table in `020`. Reusing the tip's evidence for all three is what `r3` flagged. ## Description content -- **Summary** — per phase: the defect, the wire behavior before and after. Must - include two honest notes: (a) dev independently fixed the clean-EOF defect, so - our contribution on `live-transport.ts` narrowed to `emittedTerminal` plus one - guard; (b) the tool-result image encoder is correct but currently unreachable in - production because all Cursor models are in `noVisionModels` — named as a - follow-up, not claimed as a shipped capability. -- **Verification** — the exact lidge commands from `020` with output and SHA. No - remembered passes. -- **Checklist** — three boxes, each honestly. "Docs or release notes were updated - when needed" requires the `docs-site/` determination to be MADE first (F5), not - deferred to `050`. -- No `Closes #`: no issue is being closed. +- **Summary** — the defect and the wire behavior before/after, per commit run. Two + honest notes are mandatory: (a) in PR1, that dev independently fixed the clean-EOF + defect and our surviving contribution there is `emittedTerminal` plus one guard; + (b) wherever tool-result images are mentioned, that the ENCODER supports them and + production does not reach it because all Cursor models are in `noVisionModels` — + a follow-up, not a shipped capability. +- **Verification** — that layer's commands and output with its SHA. No remembered + passes, no borrowing the tip's run. +- **Checklist** — three boxes, honestly. "Docs or release notes were updated when + needed" requires the `docs-site/` determination to be MADE here, not deferred. +- No `Closes #`. ## Verification (C) @@ -53,5 +56,6 @@ gh pr list --state open --json number,baseRefName,headRefName,title gh pr view --json body ``` -Base must be `dev`; all three template sections present and non-thin. +PR1 base `dev`; PR2 base PR1 head; PR3 base PR2 head; all three template sections +present and non-thin in each. diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index aecca51412..fe3b693c60 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -1,6 +1,6 @@ -# 040 — WP5: merge onto dev + ancestry proof +# 040 — WP5: merge the stack onto dev + ancestry proof -Revised after audit `r1` finding F2. +Revised by audit `r1` F2 (governance honesty) and audit `r3` F1 (base pinning). ## Authority, stated precisely @@ -12,26 +12,44 @@ approval **and** successful required CI checks before merge. `AGENTS.md:251-253` makes `MAINTAINERS.md` authoritative. So this merge is an **owner-authorized exception**, and every downstream claim must -say so. Concretely: +say so: -- The platform gap is real: lidge is Linux, CI covers Linux + Windows + macOS. -- This diff touches no Windows-sensitive surface (no shims, installer, PowerShell, - or path handling), which is why Linux evidence is adequate *for this diff*. -- `050`'s readiness note may say "gates green on Linux; CI waived by the owner". - It may **not** say "policy-compliant" or "all required checks passed". +- 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 audit `r3`). That is why + Linux evidence is adequate for this diff. +- `050`'s readiness 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, the path is to let required CI run on the -PR head before merging. That is a one-line change to this plan, not a rewrite. +If the user wants full compliance instead, let required CI run on each PR head +before merging. That is a one-line change to this plan. + +## Pre-merge base check (r3 F1) — do this BEFORE every merge + +``` +git ls-remote origin refs/heads/dev +``` + +Compare to `VERIFIED_BASE` from `020`. If they differ, **stop**: rebase onto the new +head and re-run the pre-merge gates. Merging a stale base lets GitHub construct a +merge result nobody tested and put it on `dev` — the ancestry check in this doc runs +*after* the merge and would discover that too late. + +For PR2 and PR3 the same rule applies to their parent: after PR1 lands, retarget PR2 +to `dev` (`gh pr edit --base dev`), re-read the live `dev` head, and confirm it +equals PR1's merge result before merging PR2. ## Procedure +Merge in dependency order, PR1 → PR2 → PR3: + ``` gh pr merge --merge --admin ``` -Do NOT squash. The commit-by-commit history is the audit trail for five phases of -adversarial review, and the devlog references specific SHAs — a squash breaks every -one of those references. +Do NOT squash. The commit-by-commit history is the audit trail for five campaign +phases plus three integration audit rounds, and the devlog references specific SHAs +— a squash breaks every one of those references. ## Ancestry proof (the actual criterion) @@ -39,11 +57,13 @@ A merge API response is not proof: ``` git fetch origin dev -git merge-base --is-ancestor origin/dev # exit 0 -git log --oneline -5 origin/dev +git merge-base --is-ancestor origin/dev # exit 0 +git log --oneline -8 origin/dev ``` ## Verification (C) -Exit 0 from `--is-ancestor`, plus the `origin/dev` log showing the merge. +For each PR: the pre-merge live-`dev` SHA equal to the expected base, then exit 0 +from `--is-ancestor` for the final tip, plus the `origin/dev` log showing all three +merges. diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md index 88a2441257..0b7ba54ca9 100644 --- a/devlog/_plan/260818_cursor_call_integration/050_phase5.md +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -1,7 +1,7 @@ # 050 — WP6: release gates on dev + go/no-go note -Revised after audit `r1` finding F5: these gates are a RE-RUN on merged `dev`, not -first contact. First contact is `020`, before the PR. +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) @@ -13,48 +13,77 @@ 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 +## Gates (dedicated worktree, r3 F2) ``` -ssh lidge 'cd ~/Developer/opencodex && git fetch origin dev && git checkout -f origin/dev && git log --oneline -1' -ssh lidge 'cd ~/Developer/opencodex && bun install --frozen-lockfile' -ssh lidge 'cd ~/Developer/opencodex && bun x tsc --noEmit' -ssh lidge 'cd ~/Developer/opencodex && bun run privacy:scan' -ssh lidge 'cd ~/Developer/opencodex && bun run audit:high' -ssh lidge 'cd ~/Developer/opencodex && bun test --isolate tests' +ssh lidge 'cd ~/Developer/opencodex && git fetch origin dev' +ssh lidge 'cd ~/Developer/opencodex && git worktree add /tmp/ocx-dev- ' +ssh lidge 'cd /tmp/ocx-dev- && git log --oneline -1 && bun install --frozen-lockfile' +ssh lidge 'cd /tmp/ocx-dev- && bun x tsc --noEmit' +ssh lidge 'cd /tmp/ocx-dev- && bun run privacy:scan' +ssh lidge 'cd /tmp/ocx-dev- && bun run audit:high' +ssh lidge 'cd /tmp/ocx-dev- && bun run build:gui' +ssh lidge 'cd /tmp/ocx-dev- && bun test --isolate tests' ``` -GUI gates (`lint:gui`, `build:gui`) are N/A for a source-only diff — record the -evidence (`git diff --name-only` showing no `gui/` paths) rather than skipping -silently. +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, but production strips them +- 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 currently 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. + 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, merge was an owner-authorized exception; -- the open follow-ups from `000` that a reader would otherwise assume were fixed — - especially F1, because the campaign's own docs previously overstated it; + 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. Released - version is `2.24.2` (`origin/main` = `474584bcd`, tag `v2.24.2`). 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. +- 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, and the note exists and is committed. +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. From 7116ed92f945fe0dc77a7757b182a48925634945 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:49:52 +0900 Subject: [PATCH 17/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r4=20FAI?= =?UTF-8?q?L=20=E2=80=94=20four=20execution-procedure=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r4 was dispatched as an explorer role so the SubagentStop observer actually records the verdict; r2 and r3 had to be aborted as inconclusive because their reviewers ran outside that hook's matcher. Every code judgment held again — PR1 and PR2 tests genuinely do not depend on PR3 code, so the stack is real — and all four findings were about how the plan EXECUTES. F1 caught the pin being decorative: 020 recorded a live SHA while 010 still said git rebase origin/dev. And 040's rule that every merge must see dev equal to the original VERIFIED_BASE is false the moment PR1 lands, since PR1's merge result IS the new dev. It defined PR2's updated expectation and forgot PR3. EXPECTED_DEV now evolves through the stack, read live with ls-remote each time, with an explicit stop-and-rebase if it moved. dev drifted 87f7f970b -> e1bdbc1e5 -> 1645bb924 during planning alone. F2 is the one that would have produced mislabeled PRs. The commit ranges were miscounted (6d9744283..HEAD is 16, not "15 + WP2b"), the top range also edits three PR1-owned files via the r2 honesty corrections, and the rebase rewrites the very boundary SHAs 030 named as branch points. So 030 is rewritten around subsystem OWNERSHIP with a real procedure: map rewritten boundaries by subject line, move the late PR1-owned edits below the PR1 boundary, land WP2b inside PR1 before cutting the branch, then re-verify every layer's file set and refuse to open a PR whose contents do not match its title. F3: 54f68daf5's resolution drops CursorStreamTruncatedError from the import line that f145fd513 also edits, so a second conflict is guaranteed. Its functional context survives intact, so 010 now carries the literal resolved import line. F4: cursor-interaction-query.test.ts is WP2b's own contract test and was gated in PR3 while WP2b lands in PR1 — PR1 could have merged with the re-export untested. --- .../007_audit_r4.md | 92 +++++++++++++ .../010_phase1.md | 32 ++++- .../020_phase2.md | 23 +++- .../030_phase3.md | 121 +++++++++++++----- .../040_phase4.md | 75 ++++++----- 5 files changed, 266 insertions(+), 77 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/007_audit_r4.md 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/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md index 7866a309e2..e3d9fc240d 100644 --- a/devlog/_plan/260818_cursor_call_integration/010_phase1.md +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -160,15 +160,34 @@ MODIFY, at dev's current `:946`: ## Procedure -1. `git rebase origin/dev` on `cursor-call` (snapshot `cursor-call-prerebase-260818` - already exists at `fe2237038`). +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. At any `google.ts` conflict: apply the hunk at dev's current location. -4. Every later commit should apply cleanly (zero dev commits on those paths). If - one does not, STOP and investigate rather than resolving mechanically. -5. Adversarial audit round on the resolved diff before pushing. +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. ## Verification (C) @@ -184,4 +203,3 @@ 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 93c8850a03..8d22f3f78f 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -33,7 +33,7 @@ ssh lidge 'cd /tmp/ocx-cc- && bun install --frozen-lockfile' Remove the worktree when the phase closes (`git worktree remove`), and never touch the shared checkout's HEAD. -## Pin the base (r3 F1) +## Pin the base (r3 F1), and remember it EVOLVES (r4 F1) `dev` moves. Record, at the moment the rebase runs: @@ -41,10 +41,14 @@ the shared checkout's HEAD. git ls-remote origin refs/heads/dev # LIVE head, not the tracking ref ``` -That SHA is `VERIFIED_BASE`. Every later phase compares against it, and WP5 refuses -to merge if the live `dev` head has moved off it. Using `git ls-remote` rather than -`origin/dev` follows `scripts/release.ts:327-335`, which exists because the local -tracking ref can be minutes stale. +That SHA is `VERIFIED_BASE`, and it is what `010` step 1 rebases 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 @@ -106,7 +110,12 @@ 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` | +| PR1 (Cursor EOF + tool-result wire + **WP2b**) | `tests/cursor-eof-terminal.test.ts`, `tests/cursor-hardening.test.ts`, `tests/cursor-tool-result-image.test.ts`, `tests/cursor-request-builder.test.ts`, `tests/cursor-interaction-query.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-interaction-query.test.ts` + FULL suite | +| PR3 (bridge/adapter terminals) | `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` + FULL suite | +`tests/cursor-interaction-query.test.ts` sits in PR1, not PR3 (audit `r4` F4): it is +the existing contract for `partialUsageFromEventState`, WP2b moves that helper and +re-exports it for that file's five dynamic imports, and WP2b lands in PR1. Gating it +one layer above the change it verifies would let PR1 merge with the re-export +untested. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index cb38a2f751..e0031d24de 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,28 +1,86 @@ # 030 — WP4: the stacked pull requests against dev -Rewritten twice. Audit `r1` F4 killed the first version (a fabricated -adapter/bridge/docs split needing cherry-picks). Audit `r3` F3 then showed the -second version was over-corrected: an **honest** stack does exist at the campaign's -own phase boundaries, with no reordering at all. The user asked for a stacked PR, and -it turns out the history supports one. +Rewritten three times. `r1` F4 killed a fabricated adapter/bridge/docs split. `r3` +F3 showed an honest stack DOES exist at the campaign's phase boundaries. `r4` F2 +then showed the version written from that was not constructible: the ranges were +miscounted, PR1-owned files were edited in the top range, and rebasing rewrites the +very boundary SHAs the plan named. -## The stack (verified against the real topology) +## The stack, by OWNERSHIP not by original commit order -`git log --oneline --reverse ..cursor-call` splits at existing commits: +The layers are defined by which subsystem they change. The rebase must produce a +history where each layer is contiguous, and the plan's job is to say how. -| PR | Head | Base | Range | Commits | Files | -|----|------|------|-------|---------|-------| -| 1 | `cursor-call-wire` | `dev` | `..dfb6fb884` | 17 | `cursor-errors.ts`, `live-transport.ts`, `native-exec.ts`, `protobuf-request.ts`, `request-builder.ts`, 3 cursor tests, 8 decode docs | -| 2 | `cursor-call-cancel` | PR1 head | `dfb6fb884..6d9744283` | 3 | `cursor-errors.ts`, `live-transport.ts`, `cursor-cancel-provenance.test.ts`, `040_*.md` | -| 3 | `cursor-call` | PR2 head | `6d9744283..HEAD` | 15 + WP2b | `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, 4 tests, integration docs | +| PR | Head branch | Base | Owns | +|----|-------------|------|------| +| 1 | `cursor-call-wire` | `EXPECTED_DEV` | Cursor wire: `cursor-errors.ts`, `live-transport.ts` (EOF resolution), `native-exec.ts`, `protobuf-request.ts`, `request-builder.ts`, `protobuf-events.ts` (WP2b), tests `cursor-eof-terminal`, `cursor-request-builder`, `cursor-tool-result-image`, `cursor-interaction-query`, decode docs 000-030 | +| 2 | `cursor-call-cancel` | PR1 head | Unexpected CANCEL: `cursor-errors.ts`, `live-transport.ts` (`classifyTurnFailure`), `tests/cursor-cancel-provenance.test.ts`, decode doc 040 | +| 3 | `cursor-call` | PR2 head | Bridge/adapter terminals: `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, their 4 tests, decode doc 050, this integration unit | -The layering is not cosmetic: PR2's `CursorUnexpectedCancelError` guard reads the -`emittedTerminal` flag PR1 introduces, and PR3's bridge terminal logic is what makes -PR1's and PR2's adapter-level error events reportable instead of silently dropped. +The layering is real, not cosmetic: PR2's `classifyTurnFailure` reads the +`emittedTerminal` flag PR1 introduces (absent from `live-transport.ts` at +`dfb6fb884`, present at `6d9744283` — verified), and PR3's bridge terminal logic is +what makes PR1/PR2's adapter error events reportable instead of silently dropped. -WP2b (EOF usage) belongs in **PR1**, because it modifies `finalizeTurnEvents` — the -function PR1's EOF resolution selects. Land it during the rebase as part of that -layer rather than appending it to PR3. +## Why the naive commit-range split fails (r4 F2) + +Measured, not assumed: + +- `..dfb6fb884` = 17 commits — clean, PR1's original run. +- `dfb6fb884..6d9744283` = 3 commits — clean, PR2's original run. +- `6d9744283..fe2237038` = 11 commits; `6d9744283..HEAD` = 16. + +The top range is NOT pure PR3 work. `git diff --name-only 6d9744283 cursor-call` +shows it also touches `src/adapters/cursor/request-builder.ts`, +`tests/cursor-request-builder.test.ts`, and `tests/cursor-tool-result-image.test.ts` +— PR1-owned files edited later by `2ea12062d` (the r2 honesty corrections). And the +rebase REWRITES `dfb6fb884` and `6d9744283`, so those SHAs cannot be named as +branch points afterwards. + +## Procedure (this is the part r4 said was missing) + +Run AFTER the WP2 rebase and the WP2b patch, in the rebased history. + +1. **Record the rewritten boundaries.** The rebase preserves commit order, so map by + subject line rather than by SHA: + + git log --format='%h %s' EXPECTED_DEV..cursor-call + + `PR1_TIP` = the rewritten commit whose subject is + `docs(devlog): record what shipped for 010 and 020, and why 030 did not`. + `PR2_TIP` = the rewritten + `docs(devlog): record what shipped for 040`. + Verify each with `git show --stat` before using it. + +2. **Move the late PR1 edits below `PR1_TIP`.** The r2 honesty corrections to + `request-builder.ts` and the two cursor tests belong to PR1's subsystem. Rather + than reorder history (which `r1` F4 correctly warned against), fix it forward: + cherry-pick just those hunks into a small commit placed on `cursor-call-wire`, + and let the original commit on the top layer become a no-op for those files + during the retarget. If the cherry-pick is not clean, do NOT force it — split the + original commit with `git rebase -i` instead, and record which route was taken. + +3. **Land WP2b inside PR1.** WP2b edits `protobuf-events.ts` and + `cursor-eof-terminal.test.ts`, both PR1-owned, and its whole reason for existing + is PR1's EOF resolution. Commit it on `cursor-call-wire` before `PR1_TIP` is + branched, not on the tip. `015` is written as a WP2b work-phase precisely so it + exists before the stack is cut. + +4. **Create the branches:** + + git branch cursor-call-wire + git branch cursor-call-cancel + # cursor-call itself is PR3's head + +5. **Re-verify the split before opening anything:** + + git rev-list --count EXPECTED_DEV..cursor-call-wire + git diff --name-only EXPECTED_DEV cursor-call-wire + git diff --name-only cursor-call-wire cursor-call-cancel + git diff --name-only cursor-call-cancel cursor-call + + Each file set must match its Owns column. A PR1 file appearing in PR3's diff + means step 2 is unfinished — stop rather than opening a mislabeled PR. ## Policy constraints (`AGENTS.md`) @@ -31,22 +89,19 @@ layer rather than appending it to PR3. workflow; `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 empty, thin, or malformed descriptions. -- Each layer carries its OWN verification evidence (`AGENTS.md:178-180`), per the - table in `020`. Reusing the tip's evidence for all three is what `r3` flagged. + **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, per commit run. Two - honest notes are mandatory: (a) in PR1, that dev independently fixed the clean-EOF - defect and our surviving contribution there is `emittedTerminal` plus one guard; - (b) wherever tool-result images are mentioned, that the ENCODER supports them and - production does not reach it because all Cursor models are in `noVisionModels` — - a follow-up, not a shipped capability. -- **Verification** — that layer's commands and output with its SHA. No remembered - passes, no borrowing the tip's run. -- **Checklist** — three boxes, honestly. "Docs or release notes were updated when - needed" requires the `docs-site/` determination to be MADE here, not deferred. +- **Summary** — the defect and the wire behavior before/after. Two honest notes are + mandatory: (a) in PR1, that dev independently fixed the clean-EOF defect and our + surviving contribution is `emittedTerminal` plus one guard plus WP2b's usage fix; + (b) wherever tool-result images appear, that the ENCODER supports them and + production does not reach it because all Cursor models are in `noVisionModels`. +- **Verification** — that layer's own commands, output, and SHA. Not the tip's. +- **Checklist** — three boxes, honestly, with the `docs-site/` determination made + here rather than deferred. - No `Closes #`. ## Verification (C) @@ -56,6 +111,6 @@ gh pr list --state open --json number,baseRefName,headRefName,title gh pr view --json body ``` -PR1 base `dev`; PR2 base PR1 head; PR3 base PR2 head; all three template sections -present and non-thin in each. +PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; the +step-5 file-set checks recorded; all three template sections non-thin in each. diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index fe3b693c60..c56af68f3e 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -1,6 +1,7 @@ # 040 — WP5: merge the stack onto dev + ancestry proof -Revised by audit `r1` F2 (governance honesty) and audit `r3` F1 (base pinning). +Revised by `r1` F2 (governance honesty), `r3` F1 (base pinning), and `r4` F1 (the +pin has to EVOLVE through the stack). ## Authority, stated precisely @@ -11,45 +12,59 @@ What it is NOT: compliance with `MAINTAINERS.md:48-49`, which requires maintaine approval **and** successful required CI checks before merge. `AGENTS.md:251-253` makes `MAINTAINERS.md` authoritative. -So this merge is an **owner-authorized exception**, and every downstream claim must -say so: +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 audit `r3`). That is why - Linux evidence is adequate for this diff. -- `050`'s readiness note may say "gates green on Linux; CI waived by the owner". It - may **not** say "policy-compliant" or "all required checks passed". + 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. That is a one-line change to this plan. - -## Pre-merge base check (r3 F1) — do this BEFORE every merge - -``` -git ls-remote origin refs/heads/dev -``` - -Compare to `VERIFIED_BASE` from `020`. If they differ, **stop**: rebase onto the new -head and re-run the pre-merge gates. Merging a stale base lets GitHub construct a -merge result nobody tested and put it on `dev` — the ancestry check in this doc runs -*after* the merge and would discover that too late. - -For PR2 and PR3 the same rule applies to their parent: after PR1 lands, retarget PR2 -to `dev` (`gh pr edit --base dev`), re-read the live `dev` head, and confirm it -equals PR1's merge result before merging PR2. +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 + before PR1: git ls-remote origin refs/heads/dev == EXPECTED_DEV + merge PR1 + EXPECTED_DEV := + retarget PR2 to dev, then: live dev == EXPECTED_DEV + merge PR2 + EXPECTED_DEV := + retarget PR3 to dev, then: live dev == EXPECTED_DEV + merge PR3 + +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, PR1 → PR2 → PR3: +Merge in dependency order, PR1 → PR2 → PR3, with the base check before each: ``` +git ls-remote origin refs/heads/dev # must equal EXPECTED_DEV gh pr merge --merge --admin +gh pr edit --base dev # retarget the next layer ``` Do NOT squash. The commit-by-commit history is the audit trail for five campaign -phases plus three integration audit rounds, and the devlog references specific SHAs -— a squash breaks every one of those references. +phases plus four integration audit rounds, and the devlog references specific SHAs. ## Ancestry proof (the actual criterion) @@ -58,12 +73,12 @@ A merge API response is not proof: ``` git fetch origin dev git merge-base --is-ancestor origin/dev # exit 0 -git log --oneline -8 origin/dev +git log --oneline -10 origin/dev ``` ## Verification (C) -For each PR: the pre-merge live-`dev` SHA equal to the expected base, then exit 0 -from `--is-ancestor` for the final tip, plus the `origin/dev` log showing all three -merges. +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. From f246c534d4a02129c5fc0e8c243416da62499e6a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 12:58:14 +0900 Subject: [PATCH 18/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r5=20FAI?= =?UTF-8?q?L=20=E2=80=94=20build=20the=20stack=20forward=20instead=20of=20?= =?UTF-8?q?re-slicing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r5 verified r4's four closures: the evolving base pin, the import-line resolution, and WP2b's test placement all held. r4-F2's did not, and the reviewer was right that the sequence could not be executed as written. The procedure had four concrete defects: steps 2-3 put commits on cursor-call-wire before step 4 created it (and step 4 created it at a point that excluded them), no restack of PR2/PR3, a claim that retargeting could make an existing commit a no-op, and no handling for two late doc edits. The partition also still leaked — be1b881ec edited two decode docs that PR1 owns, and cursor-interaction-query.test.ts was in PR1's Owns set while 015 explicitly leaves that file unchanged, so an exact file-set match was impossible by construction. The real problem is that four rounds all tried to re-slice a finished linear history, which forces commits to move between layers, and every mechanism for moving them broke a different invariant. So 030 now builds the stack FORWARD: each layer branch is cut from its base and its files are checked out from FINAL (the rebased tip), then committed as that layer's contribution. PR1 union PR2 union PR3 is then identical to cursor-call by construction, and step 4 checks that mechanically instead of by reading. The late doc edits stop being a special case because step 1 takes the whole decode directory at its final state. live-transport.ts is the one file needing care, since FINAL's copy already contains PR2's classifyTurnFailure — PR1 takes that file from the wire-work boundary commit, whose subject r5 confirmed is unique in the range. 030 also states a fallback out loud: if the union check fails for a genuine interleaving reason, open one PR and say why. A fifth iteration on the split is not on the table. r5's third finding was a literal one: 010 wrote an escaped \$(...) that zsh would assign as command text and a literal \$VERIFIED_BASE passed to git rebase. Fixed. --- .../008_audit_r5.md | 85 +++++++++ .../010_phase1.md | 4 +- .../030_phase3.md | 172 +++++++++++------- 3 files changed, 190 insertions(+), 71 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/008_audit_r5.md 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/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md index e3d9fc240d..447ce3251c 100644 --- a/devlog/_plan/260818_cursor_call_integration/010_phase1.md +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -163,8 +163,8 @@ MODIFY, at dev's current `:946`: 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 + 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 diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index e0031d24de..b3e9e6577e 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,109 +1,143 @@ # 030 — WP4: the stacked pull requests against dev -Rewritten three times. `r1` F4 killed a fabricated adapter/bridge/docs split. `r3` -F3 showed an honest stack DOES exist at the campaign's phase boundaries. `r4` F2 -then showed the version written from that was not constructible: the ranges were -miscounted, PR1-owned files were edited in the top range, and rebasing rewrites the -very boundary SHAs the plan named. +Rewritten four times. `r1` F4 killed a fabricated split. `r3` F3 found the honest +layering. `r4` F2 found the commit-range version unconstructible. `r5` then found +the ownership version's PROCEDURE unexecutable: it placed commits on a branch before +creating it, never restacked PR2/PR3, claimed a retarget could neutralize a commit +that already exists, and missed two late doc edits. -## The stack, by OWNERSHIP not by original commit order +The lesson across all four: **do not try to re-slice a finished linear history.** +Every attempt produced a procedure that had to move commits between layers, and each +one broke differently. Build the stack FORWARD instead. -The layers are defined by which subsystem they change. The rebase must produce a -history where each layer is contiguous, and the plan's job is to say how. +## The stack, built forward from the rebase -| PR | Head branch | Base | Owns | -|----|-------------|------|------| -| 1 | `cursor-call-wire` | `EXPECTED_DEV` | Cursor wire: `cursor-errors.ts`, `live-transport.ts` (EOF resolution), `native-exec.ts`, `protobuf-request.ts`, `request-builder.ts`, `protobuf-events.ts` (WP2b), tests `cursor-eof-terminal`, `cursor-request-builder`, `cursor-tool-result-image`, `cursor-interaction-query`, decode docs 000-030 | -| 2 | `cursor-call-cancel` | PR1 head | Unexpected CANCEL: `cursor-errors.ts`, `live-transport.ts` (`classifyTurnFailure`), `tests/cursor-cancel-provenance.test.ts`, decode doc 040 | -| 3 | `cursor-call` | PR2 head | Bridge/adapter terminals: `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, their 4 tests, decode doc 050, this integration unit | +The rebase produces one linear history `EXPECTED_DEV..cursor-call`. Rather than +cutting that history into layers after the fact, create each layer's branch as a +FRESH commit series whose tree is the layer's contribution: -The layering is real, not cosmetic: PR2's `classifyTurnFailure` reads the -`emittedTerminal` flag PR1 introduces (absent from `live-transport.ts` at -`dfb6fb884`, present at `6d9744283` — verified), and PR3's bridge terminal logic is -what makes PR1/PR2's adapter error events reportable instead of silently dropped. +| PR | Head branch | Base | Owns (subsystem) | +|----|-------------|------|------------------| +| 1 | `cursor-call-wire` | `EXPECTED_DEV` | Cursor wire + WP2b: `cursor-errors.ts`, `live-transport.ts` (EOF resolution + `emittedTerminal`), `native-exec.ts`, `protobuf-request.ts`, `protobuf-events.ts`, `request-builder.ts`, tests `cursor-eof-terminal`, `cursor-request-builder`, `cursor-tool-result-image`, `cursor-interaction-query` (only if WP2b's re-export touches it), decode docs 000-030 | +| 2 | `cursor-call-cancel` | `cursor-call-wire` | Unexpected CANCEL: `cursor-errors.ts` (+`CursorUnexpectedCancelError`), `live-transport.ts` (`classifyTurnFailure`), `tests/cursor-cancel-provenance.test.ts`, decode doc 040 | +| 3 | `cursor-call` | `cursor-call-cancel` | Bridge/adapter terminals: `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, their 4 tests, decode doc 050, this integration unit | -## Why the naive commit-range split fails (r4 F2) +`cursor-errors.ts` and `live-transport.ts` appear in PR1 and PR2 on purpose: PR2 +edits them again. A file may cross layers; a layer's *contribution* to a file must +not. -Measured, not assumed: +The dependency is real: PR2's `classifyTurnFailure` reads PR1's `emittedTerminal` +(absent at `dfb6fb884`, present at `6d9744283` — verified), and PR3's bridge terminal +logic is what makes PR1/PR2's adapter error events reportable at all. -- `..dfb6fb884` = 17 commits — clean, PR1's original run. -- `dfb6fb884..6d9744283` = 3 commits — clean, PR2's original run. -- `6d9744283..fe2237038` = 11 commits; `6d9744283..HEAD` = 16. +## Procedure — forward construction, no commit ever moves -The top range is NOT pure PR3 work. `git diff --name-only 6d9744283 cursor-call` -shows it also touches `src/adapters/cursor/request-builder.ts`, -`tests/cursor-request-builder.test.ts`, and `tests/cursor-tool-result-image.test.ts` -— PR1-owned files edited later by `2ea12062d` (the r2 honesty corrections). And the -rebase REWRITES `dfb6fb884` and `6d9744283`, so those SHAs cannot be named as -branch points afterwards. +Run after the WP2 rebase and WP2b are on `cursor-call`. `FINAL` = the rebased +`cursor-call` tip. Nothing below rewrites `cursor-call`. -## Procedure (this is the part r4 said was missing) +1. **PR1 branch.** Cut from the pinned base and take the layer's final state + directly from `FINAL` — which is what will actually be reviewed and merged: -Run AFTER the WP2 rebase and the WP2b patch, in the rebased history. + git switch -c cursor-call-wire EXPECTED_DEV + git checkout FINAL -- src/adapters/cursor/cursor-errors.ts \ + src/adapters/cursor/native-exec.ts \ + src/adapters/cursor/protobuf-request.ts \ + src/adapters/cursor/protobuf-events.ts \ + src/adapters/cursor/request-builder.ts \ + tests/cursor-eof-terminal.test.ts \ + tests/cursor-request-builder.test.ts \ + tests/cursor-tool-result-image.test.ts \ + devlog/_plan/260817_cursor_toolcall_decode/ -1. **Record the rewritten boundaries.** The rebase preserves commit order, so map by - subject line rather than by SHA: + `live-transport.ts` is NOT taken wholesale: `FINAL`'s version contains PR2's + `classifyTurnFailure`. Take the PR1 state of that one file from the rebased + commit that ends the wire work (the rewritten + `docs(devlog): record what shipped for 010 and 020, and why 030 did not` — verified + unique by `git log --format='%s' EXPECTED_DEV..cursor-call | sort | uniq -d` + returning nothing), then re-apply WP2b's `protobuf-events.ts` hunk if it landed + after that point. - git log --format='%h %s' EXPECTED_DEV..cursor-call + Then `git add` + commit as ONE commit per phase intent, and check the tree: - `PR1_TIP` = the rewritten commit whose subject is - `docs(devlog): record what shipped for 010 and 020, and why 030 did not`. - `PR2_TIP` = the rewritten - `docs(devlog): record what shipped for 040`. - Verify each with `git show --stat` before using it. + git diff --stat EXPECTED_DEV cursor-call-wire -2. **Move the late PR1 edits below `PR1_TIP`.** The r2 honesty corrections to - `request-builder.ts` and the two cursor tests belong to PR1's subsystem. Rather - than reorder history (which `r1` F4 correctly warned against), fix it forward: - cherry-pick just those hunks into a small commit placed on `cursor-call-wire`, - and let the original commit on the top layer become a no-op for those files - during the retarget. If the cherry-pick is not clean, do NOT force it — split the - original commit with `git rebase -i` instead, and record which route was taken. +2. **PR2 branch.** Cut from PR1 and take PR2's two files plus its test and doc: -3. **Land WP2b inside PR1.** WP2b edits `protobuf-events.ts` and - `cursor-eof-terminal.test.ts`, both PR1-owned, and its whole reason for existing - is PR1's EOF resolution. Commit it on `cursor-call-wire` before `PR1_TIP` is - branched, not on the tip. `015` is written as a WP2b work-phase precisely so it - exists before the stack is cut. + git switch -c cursor-call-cancel cursor-call-wire + git checkout FINAL -- src/adapters/cursor/cursor-errors.ts \ + src/adapters/cursor/live-transport.ts \ + tests/cursor-cancel-provenance.test.ts \ + devlog/_plan/260817_cursor_toolcall_decode/040_phase4-server-cancel-terminal.md -4. **Create the branches:** + Here `FINAL`'s `live-transport.ts` IS correct — PR2 is the layer that adds + `classifyTurnFailure`, and nothing above PR2 touches that file. - git branch cursor-call-wire - git branch cursor-call-cancel - # cursor-call itself is PR3's head +3. **PR3.** `cursor-call` itself, unchanged. Its diff against + `cursor-call-cancel` must be exactly the bridge/adapter set plus docs: -5. **Re-verify the split before opening anything:** + git diff --name-only cursor-call-cancel cursor-call + +4. **Prove the partition.** Every changed path must appear in exactly the layer that + owns its contribution, and the union must equal the whole: - git rev-list --count EXPECTED_DEV..cursor-call-wire git diff --name-only EXPECTED_DEV cursor-call-wire git diff --name-only cursor-call-wire cursor-call-cancel git diff --name-only cursor-call-cancel cursor-call + # and the decisive one: + git diff EXPECTED_DEV cursor-call --stat # equals the union above + + **The load-bearing check:** `git diff cursor-call-cancel cursor-call` must show no + `src/adapters/cursor/` path other than what PR3 legitimately owns (none). If it + shows `request-builder.ts` or a cursor test, PR1's checkout in step 1 missed a + late edit — the exact defect `r5` caught, now caught mechanically instead of by + reading. + +5. **Late doc edits are handled automatically** by step 1 taking the whole + `devlog/_plan/260817_cursor_toolcall_decode/` directory from `FINAL`. `r5` was + right that `000_index.md` and `020_*.md` were edited late (by `be1b881ec`); taking + the final state of the directory rather than a mid-history state is what makes + that a non-issue. - Each file set must match its Owns column. A PR1 file appearing in PR3's diff - means step 2 is unfinished — stop rather than opening a mislabeled PR. +Because each branch's tree is copied from `FINAL`, **PR1 ∪ PR2 ∪ PR3 is identical to +the rebased `cursor-call` by construction.** That is the property the previous +procedures kept failing to guarantee. + +### Commit messages on the rebuilt layers + +Each layer's commits are new objects, so the campaign's original messages must be +carried over deliberately — they are the audit trail the devlog cites. Write one +commit per original phase intent with its original message body, and note in the PR +body that the layer is a re-slice of `cursor-call` for review purposes and the +canonical history is `cursor-call` itself. ## Policy constraints (`AGENTS.md`) - `dev` is the only integration target. Never `main`. -- Stacked children targeting an OPEN parent's head branch are an intentional - workflow; `enforce-target` skips the wrong-base gate for them - (`AGENTS.md:218-225`). Retarget each child to `dev` after its parent lands. +- 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. Two honest notes are - mandatory: (a) in PR1, that dev independently fixed the clean-EOF defect and our - surviving contribution is `emittedTerminal` plus one guard plus WP2b's usage fix; +- **Summary** — the defect and the wire behavior before/after. Two mandatory honest + notes: (a) in PR1, that dev independently fixed the clean-EOF defect and our + surviving contribution is `emittedTerminal`, one guard, and WP2b's usage fix; (b) wherever tool-result images appear, that the ENCODER supports them and production does not reach it because all Cursor models are in `noVisionModels`. -- **Verification** — that layer's own commands, output, and SHA. Not the tip's. -- **Checklist** — three boxes, honestly, with the `docs-site/` determination made - here rather than deferred. +- **Verification** — that layer's own commands, output, and SHA. +- **Checklist** — three boxes, honestly, `docs-site/` determination made here. - No `Closes #`. +## Fallback (state it, do not hide it) + +If step 4's union check fails and the cause is not a missed file but a genuine +interleaving that forward construction cannot express, open ONE PR from +`cursor-call` to `dev` and say why in the body. A single honest PR beats three PRs +whose union is not the branch. Do not iterate on the split a fifth time. + ## Verification (C) ``` @@ -111,6 +145,6 @@ 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`; the -step-5 file-set checks recorded; all three template sections non-thin in each. +PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; step 4's +union check recorded; all three template sections non-thin in each. From 7627534447153ef2c6adb2e14d839e6c7e4f0128 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:07:13 +0900 Subject: [PATCH 19/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r6=20FAI?= =?UTF-8?q?L=20=E2=80=94=20the=20stack=20was=20mine=20to=20get=20wrong,=20?= =?UTF-8?q?and=20it=20was=20a=20category=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r6 found four blockers in the forward-construction procedure. PR3 was not an ancestor of PR2, so a stacked PR would have shown the whole original series. PR1's pathspecs were overbroad: taking cursor-errors.ts from FINAL hands PR1 PR2's CursorUnexpectedCancelError, and taking the whole decode directory hands it PR2's and PR3's phase docs. WP2b's live-transport.ts portion landed in PR2 because PR1 deliberately took that file from an earlier boundary. And the union check proved nothing — a whole-range --stat is fixed by its endpoints and stays identical no matter which layer content lands in. That is four consecutive failures on the same question, each on a different mechanism, which under LOOP-REPAIR-01 is the point to stop patching the answer and re-read the question. The error was mine. I had been requiring the layers to be subsystem-pure — each touching only its own files — which I inferred from r4 F2 noting that the top commit range also edits PR1-owned files. A stacked PR does not promise subsystem purity; it promises reviewable increments in dependency order. Purity requires moving content between commits, the history is final, and every mechanism for moving it broke a different git invariant. r3 F3 had the right answer and I over-corrected it away. 030 now cuts the rebased history at two existing commits. Ancestry is automatic, the union equals the branch by construction, and no commit moves — which closes all four r6 findings at once instead of patching them individually. Step 3 proves it with two --is-ancestor checks and three range counts that must sum to the whole. Consequence: WP2b and cursor-interaction-query.test.ts are both in PR3, where WP2b lands chronologically. r4 F4's principle stands and is satisfied in the other direction. PR1 is still correct without it — PR1 makes a truncated turn reportable, PR3 makes it report tokens. PR3's body names both that and the late honesty corrections to PR1-owned files, so a reviewer is not left wondering why a bridge PR touches a cursor comment. --- .../009_audit_r6.md | 72 ++++++ .../020_phase2.md | 17 +- .../030_phase3.md | 231 +++++++++--------- 3 files changed, 191 insertions(+), 129 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/009_audit_r6.md 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 8d22f3f78f..77929e4596 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -110,12 +110,15 @@ typecheck plus the tests that layer owns: | Layer | Focused tests | |-------|---------------| -| PR1 (Cursor EOF + tool-result wire + **WP2b**) | `tests/cursor-eof-terminal.test.ts`, `tests/cursor-hardening.test.ts`, `tests/cursor-tool-result-image.test.ts`, `tests/cursor-request-builder.test.ts`, `tests/cursor-interaction-query.test.ts` | +| 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) | `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` + FULL suite | +| 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 | -`tests/cursor-interaction-query.test.ts` sits in PR1, not PR3 (audit `r4` F4): it is -the existing contract for `partialUsageFromEventState`, WP2b moves that helper and -re-exports it for that file's five dynamic imports, and WP2b lands in PR1. Gating it -one layer above the change it verifies would let PR1 merge with the re-export -untested. +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. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index b3e9e6577e..0bb3a529ab 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,114 +1,100 @@ # 030 — WP4: the stacked pull requests against dev -Rewritten four times. `r1` F4 killed a fabricated split. `r3` F3 found the honest -layering. `r4` F2 found the commit-range version unconstructible. `r5` then found -the ownership version's PROCEDURE unexecutable: it placed commits on a branch before -creating it, never restacked PR2/PR3, claimed a retarget could neutralize a commit -that already exists, and missed two late doc edits. - -The lesson across all four: **do not try to re-slice a finished linear history.** -Every attempt produced a procedure that had to move commits between layers, and each -one broke differently. Build the stack FORWARD instead. - -## The stack, built forward from the rebase - -The rebase produces one linear history `EXPECTED_DEV..cursor-call`. Rather than -cutting that history into layers after the fact, create each layer's branch as a -FRESH commit series whose tree is the layer's contribution: - -| PR | Head branch | Base | Owns (subsystem) | -|----|-------------|------|------------------| -| 1 | `cursor-call-wire` | `EXPECTED_DEV` | Cursor wire + WP2b: `cursor-errors.ts`, `live-transport.ts` (EOF resolution + `emittedTerminal`), `native-exec.ts`, `protobuf-request.ts`, `protobuf-events.ts`, `request-builder.ts`, tests `cursor-eof-terminal`, `cursor-request-builder`, `cursor-tool-result-image`, `cursor-interaction-query` (only if WP2b's re-export touches it), decode docs 000-030 | -| 2 | `cursor-call-cancel` | `cursor-call-wire` | Unexpected CANCEL: `cursor-errors.ts` (+`CursorUnexpectedCancelError`), `live-transport.ts` (`classifyTurnFailure`), `tests/cursor-cancel-provenance.test.ts`, decode doc 040 | -| 3 | `cursor-call` | `cursor-call-cancel` | Bridge/adapter terminals: `bridge.ts`, `truncated-stop-reason.ts`, `google.ts`, `anthropic.ts`, `command-code.ts`, their 4 tests, decode doc 050, this integration unit | - -`cursor-errors.ts` and `live-transport.ts` appear in PR1 and PR2 on purpose: PR2 -edits them again. A file may cross layers; a layer's *contribution* to a file must -not. - -The dependency is real: PR2's `classifyTurnFailure` reads PR1's `emittedTerminal` -(absent at `dfb6fb884`, present at `6d9744283` — verified), and PR3's bridge terminal -logic is what makes PR1/PR2's adapter error events reportable at all. - -## Procedure — forward construction, no commit ever moves - -Run after the WP2 rebase and WP2b are on `cursor-call`. `FINAL` = the rebased -`cursor-call` tip. Nothing below rewrites `cursor-call`. - -1. **PR1 branch.** Cut from the pinned base and take the layer's final state - directly from `FINAL` — which is what will actually be reviewed and merged: - - git switch -c cursor-call-wire EXPECTED_DEV - git checkout FINAL -- src/adapters/cursor/cursor-errors.ts \ - src/adapters/cursor/native-exec.ts \ - src/adapters/cursor/protobuf-request.ts \ - src/adapters/cursor/protobuf-events.ts \ - src/adapters/cursor/request-builder.ts \ - tests/cursor-eof-terminal.test.ts \ - tests/cursor-request-builder.test.ts \ - tests/cursor-tool-result-image.test.ts \ - devlog/_plan/260817_cursor_toolcall_decode/ - - `live-transport.ts` is NOT taken wholesale: `FINAL`'s version contains PR2's - `classifyTurnFailure`. Take the PR1 state of that one file from the rebased - commit that ends the wire work (the rewritten - `docs(devlog): record what shipped for 010 and 020, and why 030 did not` — verified - unique by `git log --format='%s' EXPECTED_DEV..cursor-call | sort | uniq -d` - returning nothing), then re-apply WP2b's `protobuf-events.ts` hunk if it landed - after that point. - - Then `git add` + commit as ONE commit per phase intent, and check the tree: - - git diff --stat EXPECTED_DEV cursor-call-wire - -2. **PR2 branch.** Cut from PR1 and take PR2's two files plus its test and doc: - - git switch -c cursor-call-cancel cursor-call-wire - git checkout FINAL -- src/adapters/cursor/cursor-errors.ts \ - src/adapters/cursor/live-transport.ts \ - tests/cursor-cancel-provenance.test.ts \ - devlog/_plan/260817_cursor_toolcall_decode/040_phase4-server-cancel-terminal.md - - Here `FINAL`'s `live-transport.ts` IS correct — PR2 is the layer that adds - `classifyTurnFailure`, and nothing above PR2 touches that file. - -3. **PR3.** `cursor-call` itself, unchanged. Its diff against - `cursor-call-cancel` must be exactly the bridge/adapter set plus docs: - - git diff --name-only cursor-call-cancel cursor-call - -4. **Prove the partition.** Every changed path must appear in exactly the layer that - owns its contribution, and the union must equal the whole: - - git diff --name-only EXPECTED_DEV cursor-call-wire - git diff --name-only cursor-call-wire cursor-call-cancel - git diff --name-only cursor-call-cancel cursor-call - # and the decisive one: - git diff EXPECTED_DEV cursor-call --stat # equals the union above - - **The load-bearing check:** `git diff cursor-call-cancel cursor-call` must show no - `src/adapters/cursor/` path other than what PR3 legitimately owns (none). If it - shows `request-builder.ts` or a cursor test, PR1's checkout in step 1 missed a - late edit — the exact defect `r5` caught, now caught mechanically instead of by - reading. - -5. **Late doc edits are handled automatically** by step 1 taking the whole - `devlog/_plan/260817_cursor_toolcall_decode/` directory from `FINAL`. `r5` was - right that `000_index.md` and `020_*.md` were edited late (by `be1b881ec`); taking - the final state of the directory rather than a mid-history state is what makes - that a non-issue. - -Because each branch's tree is copied from `FINAL`, **PR1 ∪ PR2 ∪ PR3 is identical to -the rebased `cursor-call` by construction.** That is the property the previous -procedures kept failing to guarantee. - -### Commit messages on the rebuilt layers +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. -Each layer's commits are new objects, so the campaign's original messages must be -carried over deliberately — they are the audit trail the devlog cites. Write one -commit per original phase intent with its original message body, and note in the PR -body that the layer is a re-slice of `cursor-call` for review purposes and the -canonical history is `cursor-call` itself. +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, `EXPECTED_DEV..cursor-call` is one linear history. Cut it: + +| PR | Head branch | Base | Range | Content | +|----|-------------|------|-------|---------| +| 1 | `cursor-call-wire` | `EXPECTED_DEV` | `EXPECTED_DEV..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' EXPECTED_DEV..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. + +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 # must be the 010/020 shipped-record doc commit + git show --stat # must be the 040 shipped-record doc commit + +2. Create the branches at those commits: + + git branch cursor-call-wire + git branch cursor-call-cancel + +3. Prove the stack mechanically — all four must pass: + + 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 + +4. Push the two new branches and open the PRs bottom-up. ## Policy constraints (`AGENTS.md`) @@ -122,22 +108,17 @@ canonical history is `cursor-call` itself. ## Description content -- **Summary** — the defect and the wire behavior before/after. Two mandatory honest - notes: (a) in PR1, that dev independently fixed the clean-EOF defect and our - surviving contribution is `emittedTerminal`, one guard, and WP2b's usage fix; - (b) wherever tool-result images appear, that the ENCODER supports them and - production does not reach it because all Cursor models are in `noVisionModels`. +- **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 #`. -## Fallback (state it, do not hide it) - -If step 4's union check fails and the cause is not a missed file but a genuine -interleaving that forward construction cannot express, open ONE PR from -`cursor-call` to `dev` and say why in the body. A single honest PR beats three PRs -whose union is not the branch. Do not iterate on the split a fifth time. - ## Verification (C) ``` @@ -145,6 +126,12 @@ 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 4's -union check recorded; all three template sections non-thin in each. +PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; step 3's +four checks 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. From 1a84d1dc8f6fe090c707ef1cd3b4756de6140491 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:16:19 +0900 Subject: [PATCH 20/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r7=20NEA?= =?UTF-8?q?R-PASS=20=E2=80=94=20the=20stack=20holds,=20and=20the=20push=20?= =?UTF-8?q?was=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r7 measured the branch-pointer stack instead of taking it on faith: 39 commits, zero merges, boundary subjects unique, ranges partitioning exactly 17 + 3 + 19 = 39, and branch creation adding pointers only. All four properties the previous four schemes fought for, obtained by dropping the subsystem-purity requirement that caused them. The blocker was a hole between phases. 020 has lidge fetch origin/cursor-call and build 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 either failed on an unknown revision or silently tested stale code without the rebase or WP2b. 010 now pushes with --force-with-lease and asserts ls-remote equals rev-parse; 020 re-confirms the SHA on lidge before installing. Also renamed 030's prose base from EXPECTED_DEV to VERIFIED_BASE — 040 defines EXPECTED_DEV as the evolving merge-time variable, and 030's own commands already used VERIFIED_BASE. r7 also judged the layering honest: PR1 gates independently, PR2's dependency on PR1's emittedTerminal is real, and PR3's breadth is disclosed rather than hidden. Running cursor-eof-terminal.test.ts in both PR1 and PR3 is meaningful because PR1 checks terminal shape and PR3 adds the usage cases, with PR1's toMatchObject tolerating the new field. Seven rounds, 27 findings, all absorbed. The roadmap cycle is done. --- .../00A_audit_r7.md | 65 +++++++++++++++++++ .../010_phase1.md | 10 +++ .../020_phase2.md | 9 +++ .../030_phase3.md | 6 +- 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/00A_audit_r7.md diff --git a/devlog/_plan/260818_cursor_call_integration/00A_audit_r7.md b/devlog/_plan/260818_cursor_call_integration/00A_audit_r7.md new file mode 100644 index 0000000000..6861ad9e77 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/00A_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/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md index 447ce3251c..32e63ca41d 100644 --- a/devlog/_plan/260818_cursor_call_integration/010_phase1.md +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -188,6 +188,16 @@ MODIFY, at dev's current `:946`: 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) diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 77929e4596..ef26d39d61 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -30,6 +30,15 @@ ssh lidge 'cd /tmp/ocx-cc- && git log --oneline -1' ssh lidge 'cd /tmp/ocx-cc- && bun install --frozen-lockfile' ``` +`` is the PUSHED rebase tip. `010` step 7 pushes it and asserts +`git ls-remote` matches `git rev-parse cursor-call` — without that, this fetch brings +the pre-rebase branch and the worktree either fails on an unknown revision or tests +stale code (audit `r7`). 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. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index 0bb3a529ab..fac15441d5 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -24,11 +24,11 @@ The natural stack is the rebased history itself, cut at existing commits. ## The stack -After the WP2 rebase, `EXPECTED_DEV..cursor-call` is one linear history. Cut it: +After the WP2 rebase, `VERIFIED_BASE..cursor-call` is one linear history. Cut it: | PR | Head branch | Base | Range | Content | |----|-------------|------|-------|---------| -| 1 | `cursor-call-wire` | `EXPECTED_DEV` | `EXPECTED_DEV..PR1_TIP` | Decode research docs + Cursor wire hardening: the EOF resolution (`emittedTerminal`), tool-result image encoder, and their tests | +| 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 | @@ -36,7 +36,7 @@ After the WP2 rebase, `EXPECTED_DEV..cursor-call` is one linear history. Cut it: `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' EXPECTED_DEV..cursor-call | sort | uniq -d` returns nothing. +`git log --format='%s' VERIFIED_BASE..cursor-call | sort | uniq -d` returns nothing. Every property the previous four versions fought for is now free: From b4a02fcecd6dd7d9cde33abfc45cb0481e62bc5c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:18:31 +0900 Subject: [PATCH 21/40] docs(devlog): record r7 PASS and r8 NEAR-PASS, and close the last proof gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r7 walked the whole sequence and found nothing. Its verdict could not be recorded because the goalplan's activeWorkPhaseId was null, and the review observer discards any sign-off whose round targets a work-phase that is not active (review-observer.ts:99-101). That is why r2 through r6 all had to be aborted as inconclusive after their findings were absorbed — the findings were real, the FSM record was not. Fixed. r8 then re-confirmed independently and found one thing worth having: step 3 asserted wire -> cancel -> tip but never VERIFIED_BASE -> wire, and the counts do not cover that gap because 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, where --is-ancestor exits 1 while the three counts still sum. So a stack could have passed step 3 with its bottom not on the verified base — exactly the class of defect this plan kept failing on. The assertion is added with its reasoning inline so nobody deletes it as redundant later. Eight rounds, 26 findings, all absorbed. Four of them attacked the same question and the fourth failure was the signal the question was wrong, which is recorded in 009. --- .../010a_audit_r7_r8.md | 69 +++++++++++++++++++ .../030_phase3.md | 16 ++++- 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md diff --git a/devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md new file mode 100644 index 0000000000..18d4a6322a --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md @@ -0,0 +1,69 @@ +# 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. + +## 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/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index fac15441d5..5be550f16b 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -84,8 +84,10 @@ anything. git branch cursor-call-wire git branch cursor-call-cancel -3. Prove the stack mechanically — all four must pass: +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 @@ -94,6 +96,14 @@ anything. # 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. Push the two new branches and open the PRs bottom-up. ## Policy constraints (`AGENTS.md`) @@ -127,11 +137,11 @@ gh pr view --json body ``` PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; step 3's -four checks recorded; all three template sections non-thin in each. +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. - From af002133ea044e902c5727f0a13248c68abea2bb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:19:16 +0900 Subject: [PATCH 22/40] docs(devlog): number the r7/r8 audit record per LEXICO-SPLIT-01 A suffixed 010a is not a numbered plan document, and cxc review-round rejects it as a plan path. --- .../{010a_audit_r7_r8.md => 011_audit_r7_r8.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename devlog/_plan/260818_cursor_call_integration/{010a_audit_r7_r8.md => 011_audit_r7_r8.md} (100%) diff --git a/devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md similarity index 100% rename from devlog/_plan/260818_cursor_call_integration/010a_audit_r7_r8.md rename to devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md From 417c81f6c706f39488a3fb0ee74963acf15dca96 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:22:27 +0900 Subject: [PATCH 23/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r8=20?= =?UTF-8?q?=E2=80=94=20the=20verification=20handoff=20pushed=20the=20wrong?= =?UTF-8?q?=20tip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r8 confirmed r7's two fixes and found one more phase-boundary hole. 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 that push while carrying only local checks. lidge would have authoritatively verified a tree without WP2b, and PR3's WP2b implementation would have reached dev backed by nothing but a local bun test. 015 now ends with its own force-with-lease push and SHA assertion, and 020 states which of the two pushes it consumes. The earlier push stays as a post-rebase checkpoint. Same class as r7's finding: a phase boundary where the artifact one side produces is not the artifact the other side reads. That is why both survived rounds of reading each document on its own terms — neither doc is wrong alone. r8's second finding was already closed by 045f1938a, which added the missing VERIFIED_BASE -> cursor-call-wire ancestry assertion; the reviewer saw the branch move mid-read and said so. --- .../011_audit_r7_r8.md | 20 ++++++++++++++++++- .../015_phase2b_eof_usage.md | 16 +++++++++++++++ .../020_phase2.md | 9 +++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md index 18d4a6322a..58a562105d 100644 --- a/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md +++ b/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md @@ -50,6 +50,25 @@ with the reasoning inline so a future reader does not delete it as redundant. 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 | @@ -66,4 +85,3 @@ once the assertion is added. 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/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md index 2495da6b03..0beba7d975 100644 --- a/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md +++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md @@ -140,3 +140,19 @@ so the two docs do not conflict (confirmed in audit `r2`). `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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index ef26d39d61..3cb6f1c208 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -30,10 +30,11 @@ ssh lidge 'cd /tmp/ocx-cc- && git log --oneline -1' ssh lidge 'cd /tmp/ocx-cc- && bun install --frozen-lockfile' ``` -`` is the PUSHED rebase tip. `010` step 7 pushes it and asserts -`git ls-remote` matches `git rev-parse cursor-call` — without that, this fetch brings -the pre-rebase branch and the worktree either fails on an unknown revision or tests -stale code (audit `r7`). Confirm the SHA here too before installing: +`` 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 From 8779470cc1e80f79d39f87dcae8ea6d852a54f4a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:24:03 +0900 Subject: [PATCH 24/40] docs(devlog): rename 00A to 009b so review-round accepts it as a plan path cxc review-round open requires a numbered document; 00A sorted correctly but did not match the numeric pattern. --- .../{00A_audit_r7.md => 009b_audit_r7.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename devlog/_plan/260818_cursor_call_integration/{00A_audit_r7.md => 009b_audit_r7.md} (100%) diff --git a/devlog/_plan/260818_cursor_call_integration/00A_audit_r7.md b/devlog/_plan/260818_cursor_call_integration/009b_audit_r7.md similarity index 100% rename from devlog/_plan/260818_cursor_call_integration/00A_audit_r7.md rename to devlog/_plan/260818_cursor_call_integration/009b_audit_r7.md From 6a5346791d85d564c3e56cc8491b1408ff73ab23 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:24:11 +0900 Subject: [PATCH 25/40] docs(devlog): use three-digit numbering for the r7/r8 audit records review-round open only accepts NNN_*.md; 009b and 011 collided with that check and with each other. --- .../{009b_audit_r7.md => 012_audit_r7.md} | 0 .../{011_audit_r7_r8.md => 013_audit_r7_r8.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename devlog/_plan/260818_cursor_call_integration/{009b_audit_r7.md => 012_audit_r7.md} (100%) rename devlog/_plan/260818_cursor_call_integration/{011_audit_r7_r8.md => 013_audit_r7_r8.md} (100%) diff --git a/devlog/_plan/260818_cursor_call_integration/009b_audit_r7.md b/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md similarity index 100% rename from devlog/_plan/260818_cursor_call_integration/009b_audit_r7.md rename to devlog/_plan/260818_cursor_call_integration/012_audit_r7.md diff --git a/devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md similarity index 100% rename from devlog/_plan/260818_cursor_call_integration/011_audit_r7_r8.md rename to devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md From 0916523eae5d52c5915f70bf7a80bf3eda2f6358 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:28:18 +0900 Subject: [PATCH 26/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r10=20?= =?UTF-8?q?=E2=80=94=20bind=20the=20verified=20tree=20to=20the=20merged=20?= =?UTF-8?q?PR=20heads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r10's job was to sweep every phase boundary for the defect class the previous two rounds each found: one phase produces an artifact, the next reads a different one. It found a third instance, and this one was invisible to the check meant to catch it. 020 verifies one specific SHA. 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. So a force-push to any PR head would merge commits no gate had seen — and 040's post-merge ancestry check still passes, because the verified tip stays an ancestor of a superset. The fix threads one named SHA through three phases. 020 records VERIFIED_TIP after WP2b's push. 030 step 0 refuses to cut branches unless cursor-call still equals it, and step 5 records each PR's expected head. 040 asserts headRefOid against that before every merge, with the reasoning inline so it does not get deleted as redundant with the base check — the base check proves dev has not moved and says nothing about what the PR points at. Three rounds, three instances, same shape: r7 (nothing pushed the rebase before remote verification), r8 (the push preceded WP2b), r10 (the verified tip was never bound to what merges). Each document was correct alone; the defect lived in the seam. 014 records the general lesson so the next unit inherits it. r10 also confirmed r8's fix: 015 ends with its own force-with-lease push and SHA assertion, 020 consumes that later push, both snippets parse correctly under zsh, and the remaining handoffs are coherent. --- .../014_audit_r10.md | 60 +++++++++++++++++++ .../020_phase2.md | 13 ++++ .../030_phase3.md | 16 +++++ .../040_phase4.md | 20 ++++++- 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/014_audit_r10.md 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 3cb6f1c208..b3fde83544 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -112,6 +112,19 @@ Typecheck, privacy:scan, audit:high, and build:gui each exit 0, and `0 fail` fro `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. Name it: + + VERIFIED_TIP=$(git rev-parse cursor-call) # after WP2b's push, before any gate + +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 diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index 5be550f16b..18eb49649b 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -69,6 +69,15 @@ PR3's range includes three kinds of change a reviewer should expect: 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): @@ -106,6 +115,13 @@ anything. 4. Push the two new branches and open the PRs bottom-up. +5. **Record each PR's expected head SHA.** `040` asserts these immediately before + merging: + + PR1_HEAD = + PR2_HEAD = + PR3_HEAD = $VERIFIED_TIP + ## Policy constraints (`AGENTS.md`) - `dev` is the only integration target. Never `main`. diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index c56af68f3e..f44a13cef7 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -63,6 +63,25 @@ gh pr merge --merge --admin gh pr edit --base dev # retarget the next layer ``` +### 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: + +``` +gh pr view --json headRefOid --jq .headRefOid # must equal PR_HEAD +``` + +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. @@ -81,4 +100,3 @@ git log --oneline -10 origin/dev 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. - From 2700f0c4fe408260998e63c870cbc861114f48aa Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:29:44 +0900 Subject: [PATCH 27/40] docs(devlog): name MERGED_DEV so WP6 gates what WP5 produced Sweeping for the r7/r8/r10 defect class one more time surfaced the last unbound handoff: 050 took as a placeholder with nothing tying it to what 040 actually merged. A fresh read of origin/dev there would gate whatever the branch happens to point at, so a green result could be attributed to someone else's push. 040 now records MERGED_DEV from ls-remote after PR3 lands and asserts VERIFIED_TIP is an ancestor of it; 050 says explicitly that it gates that SHA and not a re-read. That closes every artifact boundary in the plan: VERIFIED_BASE (rebase target), VERIFIED_TIP (the verified tree), PR1_HEAD/PR2_HEAD/PR3_HEAD (what merges), EXPECTED_DEV (the evolving merge base), and now MERGED_DEV (what the release gates run against). --- .../040_phase4.md | 26 +++++++++++++++++++ .../050_phase5.md | 7 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index f44a13cef7..b882afc9cb 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -100,3 +100,29 @@ git log --oneline -10 origin/dev 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, name the result and pass it on rather than letting `050` re-read a +moving ref: + + MERGED_DEV=$(git ls-remote origin refs/heads/dev | cut -f1) + git merge-base --is-ancestor "$VERIFIED_TIP" "$MERGED_DEV" # exit 0 + +`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`). + +## Hand `MERGED_DEV` to WP6 + +After PR3 merges, name the result and pass it on rather than letting `050` re-read a +moving ref: + +``` +MERGED_DEV=$(git ls-remote origin refs/heads/dev | cut -f1) +git merge-base --is-ancestor "$VERIFIED_TIP" "$MERGED_DEV" # exit 0 +``` + +`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 index 0b7ba54ca9..b180a9c34a 100644 --- a/devlog/_plan/260818_cursor_call_integration/050_phase5.md +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -26,6 +26,12 @@ ssh lidge 'cd /tmp/ocx-dev- && bun run build:gui' ssh lidge 'cd /tmp/ocx-dev- && bun test --isolate tests' ``` +`` is `MERGED_DEV` as recorded at the end of `040` — the SHA `dev` +carried when PR3 landed, 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. @@ -86,4 +92,3 @@ Write `060_release_readiness.md` with: 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. - From df5784ea70b9ffc01961660ceccd7659cf2042fb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:34:48 +0900 Subject: [PATCH 28/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r13=20?= =?UTF-8?q?=E2=80=94=20turn=20the=20SHA=20bindings=20into=20commands=20tha?= =?UTF-8?q?t=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r13 did what twelve prior rounds had not: it executed the snippets. Four blockers, all one kind — the right check described in prose, not expressed as something a shell would run. PR1_HEAD = is not an assignment; the reviewer's probe exited 127 with command not found, because NAME = value runs NAME as a command. 040 printed headRefOid instead of comparing it, so the gate depended on someone eyeballing a value. PR1_TIP and PR2_TIP were read by eye and never asserted against the branches actually created — the ancestry and count checks prove topology, not identity. And VERIFIED_TIP was captured after the gates rather than before, which reopens inside WP3 the exact drift r10 closed between phases. All five are now executable: 020 captures VERIFIED_TIP from ls-remote before building the worktree at that SHA and asserts HEAD matches; 030 binds the boundary SHAs with git log | grep -F | cut and asserts the created branch tips equal them; 040 merges behind a test against the recorded head. MERGED_DEV now comes from gh pr view --json mergeCommit — the merge itself — with a separate assertion that dev still points there, so a concurrent push cannot be attributed to this campaign. Probed the extraction against the real tree: it returns dfb6fb884 and 6d9744283, exactly the two boundary commits 030 names. Four rounds running (r7, r8, r10, r13) found the same failure mode at different altitudes: a binding that lives in prose instead of in a command. A seam is only real when it is a test. --- .../016_audit_r13.md | 59 +++++++++++++++++++ .../020_phase2.md | 54 +++++++++++++---- .../030_phase3.md | 27 +++++++-- .../040_phase4.md | 24 +++++--- 4 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/016_audit_r13.md 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index b3fde83544..e9f191f737 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -23,14 +23,17 @@ would silently discard any tracked uncommitted work. `git worktree list` on lidg already shows a dozen `/tmp/ocx-*` verification worktrees, so this is the established pattern there: -``` -ssh lidge 'cd ~/Developer/opencodex && git fetch origin cursor-call dev' -ssh lidge 'cd ~/Developer/opencodex && git worktree add /tmp/ocx-cc- ' -ssh lidge 'cd /tmp/ocx-cc- && git log --oneline -1' -ssh lidge 'cd /tmp/ocx-cc- && bun install --frozen-lockfile' -``` +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" -`` is the tip pushed at the END of WP2b, not `010`'s post-rebase checkpoint +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 @@ -115,9 +118,9 @@ recorded `VERIFIED_BASE`. ## Record `VERIFIED_TIP` (audit `r10`) The SHA these gates ran against is the ONLY tree this campaign has authoritative -evidence for. Name it: - - VERIFIED_TIP=$(git rev-parse cursor-call) # after WP2b's push, before any gate +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 @@ -145,3 +148,34 @@ 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, so this half of WP3 runs AFTER +that step and before the PRs are opened. Order inside the work-phase, not a new +work-phase: + +1. `020` first half: gates at `VERIFIED_TIP` (full suite, typecheck, privacy:scan, + audit:high, build:gui) — this is the stack-tip evidence PR3 cites. +2. `030` steps 0-3: bind to `VERIFIED_TIP`, cut `cursor-call-wire` and + `cursor-call-cancel`, prove the partition. +3. `020` this half: one dedicated worktree per layer, pinned to that layer's head: + + for LAYER_SHA in "$PR1_HEAD" "$PR2_HEAD"; do + ssh lidge "cd ~/Developer/opencodex && git fetch origin && git worktree add /tmp/ocx-L-${LAYER_SHA:0:9} $LAYER_SHA" + ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && test \"\$(git rev-parse HEAD)\" = \"$LAYER_SHA\" && bun install --frozen-lockfile" + ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && bun x tsc --noEmit" + ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && bun test " + done + + `PR3_HEAD` is `VERIFIED_TIP`, already covered by step 1 — do not re-run it. +4. `030` step 4: 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 index 18eb49649b..3f5dd0aa63 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -88,6 +88,13 @@ anything. git show --stat # must be the 010/020 shipped-record doc commit git show --stat # 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: git branch cursor-call-wire @@ -115,12 +122,22 @@ anything. 4. Push the two new branches and open the PRs bottom-up. -5. **Record each PR's expected head SHA.** `040` asserts these immediately before - merging: +5. **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): - PR1_HEAD = - PR2_HEAD = - PR3_HEAD = $VERIFIED_TIP + test "$PR1_HEAD" = "$PR1_TIP" + test "$PR2_HEAD" = "$PR2_TIP" + test "$PR3_HEAD" = "$VERIFIED_TIP" ## Policy constraints (`AGENTS.md`) diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index b882afc9cb..a1da7a54af 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -74,9 +74,11 @@ ancestor of a superset. So before EACH merge, compare the PR's live head against the SHA `030` step 5 recorded: -``` -gh pr view --json headRefOid --jq .headRefOid # must equal PR_HEAD -``` + 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. 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 @@ -103,11 +105,19 @@ the `origin/dev` log showing all three merges. ## Hand `MERGED_DEV` to WP6 -After PR3 merges, name the result and pass it on rather than letting `050` re-read a -moving ref: +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 - MERGED_DEV=$(git ls-remote origin refs/heads/dev | cut -f1) - 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 From 01101296350fc5af1b7da071eba2952d3191a3e0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:35:40 +0900 Subject: [PATCH 29/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r12=20?= =?UTF-8?q?=E2=80=94=20bind=20the=20gate=20evidence=20to=20the=20tree=20it?= =?UTF-8?q?=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more instances of the class r7, r8 and r10 already found: a phase boundary where the artifact one side produces is not the artifact the other side reads. 020 created the lidge worktree at an unnamed SHA, ran every gate, and only then introduced VERIFIED_TIP in a section that claimed to come before the gates. If cursor-call moved during the eight-minute suite, 030 would bind to a tree nobody tested. The capture now happens first, reads the live remote, asserts local and remote agree, builds the worktree at that SHA, and re-asserts HEAD inside it before installing — and the worktree path carries the SHA. The second one is worse in principle: 020's per-layer table said what each layer runs and never where. Running PR1's tests at the stack tip proves nothing about PR1, because that tree already contains PR2 and PR3, so a PR1 test could pass on code a PR1 reviewer never sees. The layer branches only exist from 030 step 2, so WP3 is now explicitly ordered — stack-tip gates, then cut and prove, then one pinned worktree per layer head, then open the PRs each citing its own run. Five instances of one class across five rounds is worth naming, so 016 tabulates them: each document reads correctly alone, and the defect only appears when you ask at every boundary what the next phase actually binds to. --- .../016_audit_r12.md | 73 +++++++++++++++++++ .../030_phase3.md | 13 +++- .../050_phase5.md | 27 +++---- 3 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/016_audit_r12.md diff --git a/devlog/_plan/260818_cursor_call_integration/016_audit_r12.md b/devlog/_plan/260818_cursor_call_integration/016_audit_r12.md new file mode 100644 index 0000000000..28985e88b3 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/016_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/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index 3f5dd0aa63..d6b2f1ce99 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -120,9 +120,7 @@ anything. add up. Only the ancestry chain `VERIFIED_BASE → wire → cancel → tip`, together with the counts, establishes the partition. -4. Push the two new branches and open the PRs bottom-up. - -5. **Record each PR's expected head SHA as real variables.** `040` asserts these +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`): @@ -139,6 +137,15 @@ anything. 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`. diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md index b180a9c34a..e54b29ffa7 100644 --- a/devlog/_plan/260818_cursor_call_integration/050_phase5.md +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -15,19 +15,20 @@ repository's OIDC workflow is the only publish mechanism — never a direct ## Gates (dedicated worktree, r3 F2) -``` -ssh lidge 'cd ~/Developer/opencodex && git fetch origin dev' -ssh lidge 'cd ~/Developer/opencodex && git worktree add /tmp/ocx-dev- ' -ssh lidge 'cd /tmp/ocx-dev- && git log --oneline -1 && bun install --frozen-lockfile' -ssh lidge 'cd /tmp/ocx-dev- && bun x tsc --noEmit' -ssh lidge 'cd /tmp/ocx-dev- && bun run privacy:scan' -ssh lidge 'cd /tmp/ocx-dev- && bun run audit:high' -ssh lidge 'cd /tmp/ocx-dev- && bun run build:gui' -ssh lidge 'cd /tmp/ocx-dev- && bun test --isolate tests' -``` - -`` is `MERGED_DEV` as recorded at the end of `040` — the SHA `dev` -carried when PR3 landed, already proven to descend from `VERIFIED_TIP`. Do not +`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). From 006bff1e6d5831535d38e23e58e5d2884dd93fc1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:36:08 +0900 Subject: [PATCH 30/40] docs(devlog): renumber the r12 record to 017 (016 was taken by r13) --- .../{016_audit_r12.md => 017_audit_r12.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename devlog/_plan/260818_cursor_call_integration/{016_audit_r12.md => 017_audit_r12.md} (100%) diff --git a/devlog/_plan/260818_cursor_call_integration/016_audit_r12.md b/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md similarity index 100% rename from devlog/_plan/260818_cursor_call_integration/016_audit_r12.md rename to devlog/_plan/260818_cursor_call_integration/017_audit_r12.md From e03015f316cd425e636e5d2b52fafd3d0f41d4c2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:40:25 +0900 Subject: [PATCH 31/40] docs(devlog): remove the duplicate MERGED_DEV block and finish the prose-to-command sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The r13 confirmation reviewer caught that my own fix had been nullified: 040 carried TWO "Hand MERGED_DEV to WP6" sections, and the second one — the original, left in place — reassigned MERGED_DEV from a fresh ls-remote of dev, overwriting the corrected binding to PR3's merge commit two paragraphs above it. Deleted. Three remaining prose bindings became commands. 040's EXPECTED_DEV ladder used := and English ("before PR1: live dev == EXPECTED_DEV"), which is a legend, not a gate; it is now a real per-layer sequence that tests the base, tests the PR head, merges, and advances EXPECTED_DEV from that layer's merge commit rather than from a fresh read. 040's Procedure block printed the live ref instead of comparing it. And 020 printed ls-remote and said the result "is" VERIFIED_BASE instead of assigning it. Probed the whole chain under zsh: parses clean, and the assertions pass against the live refs (VERIFIED_BASE=1645bb924, DEVDIR derived correctly). Five rounds found this same class — a seam described correctly in prose that no shell would enforce. This commit is the last of them: every named artifact in the plan is now captured by a command and asserted where it is consumed. --- .../020_phase2.md | 7 ++-- .../040_phase4.md | 42 +++++++------------ 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index e9f191f737..b6dcc1662b 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -50,11 +50,10 @@ the shared checkout's HEAD. `dev` moves. Record, at the moment the rebase runs: -``` -git ls-remote origin refs/heads/dev # LIVE head, not the tracking ref -``` + VERIFIED_BASE=$(git ls-remote origin refs/heads/dev | cut -f1) + test -n "$VERIFIED_BASE" -That SHA is `VERIFIED_BASE`, and it is what `010` step 1 rebases ONTO — not +That is `VERIFIED_BASE`, and it is what `010` step 1 rebases 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`. diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index a1da7a54af..ae1b8c9588 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -32,15 +32,17 @@ 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 - before PR1: git ls-remote origin refs/heads/dev == EXPECTED_DEV - merge PR1 - EXPECTED_DEV := - retarget PR2 to dev, then: live dev == EXPECTED_DEV - merge PR2 - EXPECTED_DEV := - retarget PR3 to dev, then: live dev == EXPECTED_DEV - merge PR3 + EXPECTED_DEV="$VERIFIED_BASE" # from 020, the rebase target + + # per layer N, with PRN and PRN_HEAD from 030 step 5: + test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" + test "$(gh pr view $PRN --json headRefOid --jq .headRefOid)" = "$PRN_HEAD" + gh pr merge $PRN --merge --admin + EXPECTED_DEV=$(gh pr view $PRN --json mergeCommit --jq .mergeCommit.oid) + gh pr edit $PR_NEXT --base dev # retarget the next layer + +`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`). Read the live head with `git ls-remote origin refs/heads/dev` every time, never `origin/dev` — the tracking ref goes stale within minutes @@ -57,11 +59,9 @@ to prevent it. Merge in dependency order, PR1 → PR2 → PR3, with the base check before each: -``` -git ls-remote origin refs/heads/dev # must equal EXPECTED_DEV -gh pr merge --merge --admin -gh pr edit --base dev # retarget the next layer -``` + test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" + gh pr merge $PRN --merge --admin + gh pr edit $PR_NEXT --base dev # retarget the next layer ### Also assert the PR HEAD, not just the base (audit `r10`) @@ -122,17 +122,3 @@ 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`). - -## Hand `MERGED_DEV` to WP6 - -After PR3 merges, name the result and pass it on rather than letting `050` re-read a -moving ref: - -``` -MERGED_DEV=$(git ls-remote origin refs/heads/dev | cut -f1) -git merge-base --is-ancestor "$VERIFIED_TIP" "$MERGED_DEV" # exit 0 -``` - -`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`). From bae83f2d6244d51b652a226146f5a8c679818b55 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:46:52 +0900 Subject: [PATCH 32/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r14=20?= =?UTF-8?q?=E2=80=94=20three=20defects=20found=20by=20running=20the=20plan?= =?UTF-8?q?,=20not=20reading=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r14 executed every shell fragment in the unit against a scratch zsh with real SHAs, simulating only the mutating commands. Three High findings. The gates in 020 were bare local commands sitting under a section that had just pinned a lidge worktree. Copied into a shell they run wherever the operator happens to be, so the phase could report green for a tree that is not VERIFIED_TIP — the exact failure r10 and r12 were about. They are now a loop that runs each gate over ssh inside the worktree and re-asserts HEAD first. The per-layer block could not run at all: it referenced $PR1_HEAD before 030 assigns it, and it contained a literal , which is a zsh parse error rather than an instruction. It now runs after 030 step 4, spells the test lists out as variables, and wraps the work in a run_layer function that pins, asserts, installs, typechecks and tests. The third is the sixth artifact-chain gap. 040 asserted whether dev moved and whether each PR head moved, but never whether the PR still points at dev. A retarget to main, or to a parent branch that has since merged, passes both checks and gh pr merge would merge into that base. The pre-merge check is now three assertions together: base is dev, head equals the recorded SHA, live dev equals EXPECTED_DEV. r14 also ran the focused Cursor tests itself: 71 pass, 0 fail, typecheck exit 0. And it confirmed the stack partition at its anchor: 17 + 3 + 31 = 51, no duplicate subjects, no merges. --- .../018_audit_r14.md | 76 +++++++++++++++++++ .../020_phase2.md | 72 +++++++++++------- .../040_phase4.md | 22 ++++++ 3 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/018_audit_r14.md 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..deb64f7a0b --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md @@ -0,0 +1,76 @@ +# 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. + +## 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/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index b6dcc1662b..0388957c07 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -64,13 +64,24 @@ live `dev` head legitimately differs from the original. ## Gates -``` -bun x tsc --noEmit -bun run privacy:scan -bun run audit:high -bun test --isolate tests -bun run build:gui # see r3 F4 — publish runs this unconditionally -``` +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 @@ -155,25 +166,34 @@ PR1's tests at `VERIFIED_TIP` proves nothing about PR1, because that tree alread 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, so this half of WP3 runs AFTER -that step and before the PRs are opened. Order inside the work-phase, not a new -work-phase: - -1. `020` first half: gates at `VERIFIED_TIP` (full suite, typecheck, privacy:scan, - audit:high, build:gui) — this is the stack-tip evidence PR3 cites. -2. `030` steps 0-3: bind to `VERIFIED_TIP`, cut `cursor-call-wire` and - `cursor-call-cancel`, prove the partition. -3. `020` this half: one dedicated worktree per layer, pinned to that layer's head: - - for LAYER_SHA in "$PR1_HEAD" "$PR2_HEAD"; do - ssh lidge "cd ~/Developer/opencodex && git fetch origin && git worktree add /tmp/ocx-L-${LAYER_SHA:0:9} $LAYER_SHA" - ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && test \"\$(git rev-parse HEAD)\" = \"$LAYER_SHA\" && bun install --frozen-lockfile" - ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && bun x tsc --noEmit" - ssh lidge "cd /tmp/ocx-L-${LAYER_SHA:0:9} && bun test " - done - - `PR3_HEAD` is `VERIFIED_TIP`, already covered by step 1 — do not re-run it. -4. `030` step 4: push the branches and open the PRs, each citing ITS OWN run. +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 diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index ae1b8c9588..de45b81042 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -80,6 +80,28 @@ recorded: `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: + + PR_BASE=$(gh pr view $PRN --json baseRefName --jq .baseRefName) + PR_HEAD=$(gh pr view $PRN --json headRefOid --jq .headRefOid) + test "$PR_BASE" = "dev" + test "$PR_HEAD" = "$EXPECTED_HEAD" + test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" + gh pr merge $PRN --merge --admin + +`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. From 34805a2c84478ed57b81cca074074c8b8ec8b9dd Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:52:52 +0900 Subject: [PATCH 33/40] =?UTF-8?q?docs(devlog):=20absorb=20audit=20r14=20?= =?UTF-8?q?=E2=80=94=20run=20the=20plan=20instead=20of=20reading=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r14 audited by executing every snippet under zsh -n plus the read-only extractions. Six of nine named artifacts failed, after thirteen rounds of reading had found none. VERIFIED_BASE was captured twice: 010 pins it before the rebase, 020 captured it again afterwards, so a second ls-remote would overwrite the pin with a newer dev and every later assertion would compare against a base the campaign never rebased onto. 020 now inherits and asserts it. git branch cursor-call-wire fails zsh -n outright — the angle-bracket form is not shell syntax, and 030 had captured the variables then not consumed them. Fixed in both the branch creation and the git show --stat confirmations. 040 carried three merge procedures that disagreed with each other. 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. r14's sharpest point: the angle-bracket forms in 040 parse only because zsh treats them as redirections, which is worse than failing, because they run and bind nothing. There is now 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. Probed the whole chain under zsh: parses clean and returns dfb6fb884 and 6d9744283, the two boundary commits 030 names. Also cleaned up after the auditor: it executed one 020 snippet during isolation and left worktree /tmp/ocx-L- and branch ocx-L- on lidge. Both removed; nothing was pushed or edited there. --- .../018_audit_r14b.md | 52 +++++++++++++++++++ .../020_phase2.md | 10 ++-- .../030_phase3.md | 11 ++-- .../040_phase4.md | 52 +++++++++++-------- 4 files changed, 95 insertions(+), 30 deletions(-) create mode 100644 devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md diff --git a/devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md b/devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md new file mode 100644 index 0000000000..45c6013def --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md @@ -0,0 +1,52 @@ +# 018 — Audit round r14-20260818043616: FAIL, and the last placeholders die + +`r14` audited by RUNNING the plan rather than reading it: a `zsh -n` matrix over +every snippet plus the read-only extractions. Six of nine named artifacts failed. + +## What it found + +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. + +2. **`git branch cursor-call-wire ` fails `zsh -n`.** The angle-bracket form + is not shell syntax. `030` captured `PR1_TIP`/`PR2_TIP` correctly and then did not + consume them. + +3. **`040` carried THREE merge procedures** that disagreed. One used `$PRN`, + `$PRN_HEAD` and `$PR_NEXT` — none of which any phase assigns. One omitted the head + assertion and the `EXPECTED_DEV` update entirely. `r14` noted the angle-bracket + forms in `040` parse only because zsh reads them as redirections, which is worse + than failing: they run and bind nothing. + +## The fix + +- `020` inherits `VERIFIED_BASE` and asserts it instead of re-capturing: + `test -n` plus `git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call`. +- `030` step 2 uses `git branch cursor-call-wire "$PR1_TIP"`, and step 1's + `git show --stat` calls consume the variables too. +- `040` now has ONE merge ladder, a `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. The two + half-procedures above it are gone; those sections state the invariant only. + +Verified by running the whole chain under zsh: parses clean, and the extractions +return `dfb6fb884…` and `6d974428…` — exactly the two boundary commits `030` names. + +## Side effect worth recording + +The reviewer executed one `020` scratch snippet during isolation and left a worktree +`/tmp/ocx-L-` plus branch `ocx-L-` on lidge. Removed (`git worktree remove --force`, +`git branch -D`); lidge is back to 13 worktrees and nothing was pushed or edited +there. Worth noting that a READ-ONLY audit brief still produced remote state — the +snippets are executable now, which is the point, and an auditor running them is a +foreseeable consequence. + +## Six rounds, one lesson + +`r7`, `r8`, `r10`, `r13`, the `r13` confirmation, and now `r14` all found the same +thing at different altitudes: a binding that reads correctly and enforces nothing. +The difference in `r14` is method — it ran the text instead of reading it, and found +six instances where thirteen rounds of reading had found none. + diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md index 0388957c07..d8410c0c00 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -48,12 +48,16 @@ the shared checkout's HEAD. ## Pin the base (r3 F1), and remember it EVOLVES (r4 F1) -`dev` moves. Record, at the moment the rebase runs: +`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: - VERIFIED_BASE=$(git ls-remote origin refs/heads/dev | cut -f1) test -n "$VERIFIED_BASE" + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call # exit 0 -That is `VERIFIED_BASE`, and it is what `010` step 1 rebases ONTO — not +`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`. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index d6b2f1ce99..862bfc9607 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -85,8 +85,8 @@ anything. Read `PR1_TIP` and `PR2_TIP` off that list by subject, then confirm each: - git show --stat # must be the 010/020 shipped-record doc commit - git show --stat # must be the 040 shipped-record doc commit + 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: @@ -95,10 +95,11 @@ anything. 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: +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 - git branch cursor-call-cancel + 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: diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index de45b81042..4b8f3a314b 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -32,17 +32,12 @@ 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 - - # per layer N, with PRN and PRN_HEAD from 030 step 5: - test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" - test "$(gh pr view $PRN --json headRefOid --jq .headRefOid)" = "$PRN_HEAD" - gh pr merge $PRN --merge --admin - EXPECTED_DEV=$(gh pr view $PRN --json mergeCommit --jq .mergeCommit.oid) - gh pr edit $PR_NEXT --base dev # retarget the next layer + 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`). +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 @@ -57,11 +52,8 @@ to prevent it. ## Procedure -Merge in dependency order, PR1 → PR2 → PR3, with the base check before each: - - test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" - gh pr merge $PRN --merge --admin - gh pr edit $PR_NEXT --base dev # retarget the next layer +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`) @@ -88,14 +80,30 @@ branch that has since merged — passes both, and `gh pr merge` would then merge 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: - - PR_BASE=$(gh pr view $PRN --json baseRefName --jq .baseRefName) - PR_HEAD=$(gh pr view $PRN --json headRefOid --jq .headRefOid) - test "$PR_BASE" = "dev" - test "$PR_HEAD" = "$EXPECTED_HEAD" - test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" - gh pr merge $PRN --merge --admin +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 From 3d4211dee15f8d2a96874b41fad82d055f39eb60 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 13:54:05 +0900 Subject: [PATCH 34/40] docs(devlog): fold the second r14 pass into one 018 record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two 018 files had appeared for the same round — one for r14's code-block pass, one for its named-variable pass. Merged: 018_audit_r14.md now carries both, and the duplicate is gone. The second pass is the one worth keeping in the same place as the first, because it shows the same method finding different things depending on what you enumerate. The code-block pass found fragments that could not run; the variable pass found bindings that ran and bound nothing — including angle-bracket forms in 040 that zsh silently reads as redirections rather than rejecting. --- .../018_audit_r14.md | 37 ++++++++++++- .../018_audit_r14b.md | 52 ------------------- 2 files changed, 36 insertions(+), 53 deletions(-) delete mode 100644 devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md diff --git a/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md index deb64f7a0b..ab9b54fdf3 100644 --- a/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md +++ b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md @@ -67,10 +67,45 @@ which is not a gate. - `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/018_audit_r14b.md b/devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md deleted file mode 100644 index 45c6013def..0000000000 --- a/devlog/_plan/260818_cursor_call_integration/018_audit_r14b.md +++ /dev/null @@ -1,52 +0,0 @@ -# 018 — Audit round r14-20260818043616: FAIL, and the last placeholders die - -`r14` audited by RUNNING the plan rather than reading it: a `zsh -n` matrix over -every snippet plus the read-only extractions. Six of nine named artifacts failed. - -## What it found - -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. - -2. **`git branch cursor-call-wire ` fails `zsh -n`.** The angle-bracket form - is not shell syntax. `030` captured `PR1_TIP`/`PR2_TIP` correctly and then did not - consume them. - -3. **`040` carried THREE merge procedures** that disagreed. One used `$PRN`, - `$PRN_HEAD` and `$PR_NEXT` — none of which any phase assigns. One omitted the head - assertion and the `EXPECTED_DEV` update entirely. `r14` noted the angle-bracket - forms in `040` parse only because zsh reads them as redirections, which is worse - than failing: they run and bind nothing. - -## The fix - -- `020` inherits `VERIFIED_BASE` and asserts it instead of re-capturing: - `test -n` plus `git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call`. -- `030` step 2 uses `git branch cursor-call-wire "$PR1_TIP"`, and step 1's - `git show --stat` calls consume the variables too. -- `040` now has ONE merge ladder, a `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. The two - half-procedures above it are gone; those sections state the invariant only. - -Verified by running the whole chain under zsh: parses clean, and the extractions -return `dfb6fb884…` and `6d974428…` — exactly the two boundary commits `030` names. - -## Side effect worth recording - -The reviewer executed one `020` scratch snippet during isolation and left a worktree -`/tmp/ocx-L-` plus branch `ocx-L-` on lidge. Removed (`git worktree remove --force`, -`git branch -D`); lidge is back to 13 worktrees and nothing was pushed or edited -there. Worth noting that a READ-ONLY audit brief still produced remote state — the -snippets are executable now, which is the point, and an auditor running them is a -foreseeable consequence. - -## Six rounds, one lesson - -`r7`, `r8`, `r10`, `r13`, the `r13` confirmation, and now `r14` all found the same -thing at different altitudes: a binding that reads correctly and enforces nothing. -The difference in `r14` is method — it ran the text instead of reading it, and found -six instances where thirteen rounds of reading had found none. - From b8c6c0059fdf431704d58c95f1c1f622378e335b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:01:00 +0900 Subject: [PATCH 35/40] docs(devlog): make the integration plan a program instead of a promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven audit rounds (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 silently reads as a redirection, PR1_TIP consumed at line 88 and assigned at line 94, PR numbers referenced but never assigned anywhere at all. Seven rounds of one 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 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 unenforceable by construction. cursor-call-integration.zsh is now the executable form of 010/020/030/040/050. Under set -euo pipefail an unset variable is a hard error, so the entire class is impossible. State persists to .tmp/ so a compaction costs nothing and each step re-reads what earlier steps recorded. Every assertion is a test or an ||die — no printed value for an operator to eyeball. Steps are idempotent. Nothing merges or pushes unless the operator names that step. Verified rather than asserted: parse OK; pin actually recorded VERIFIED_BASE=1645bb924 and it persisted; cut, merge and release_gates each refused with FATAL and exit 1 naming the missing variable; record_prs rejected two arguments; an unknown step exited 1. Those refusals are the point — the ordering the prose could only request, the script enforces. The decade docs keep what a script cannot carry: why dev's error-event EOF shape beat ours, why WP2b uses partialUsageFromEventState rather than resolvedTurnUsage, why the stack splits where it does, why the merge is an owner-authorized exception rather than policy compliance. Each now carries a banner naming its script step and saying that if the two disagree, the script is right. --- .../010_phase1.md | 6 + .../019_the_plan_becomes_a_program.md | 78 +++++++ .../020_phase2.md | 6 + .../030_phase3.md | 6 + .../040_phase4.md | 6 + .../050_phase5.md | 6 + .../cursor-call-integration.zsh | 201 ++++++++++++++++++ 7 files changed, 309 insertions(+) create mode 100644 devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md create mode 100755 devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh diff --git a/devlog/_plan/260818_cursor_call_integration/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md index 32e63ca41d..02bb6aa1cd 100644 --- a/devlog/_plan/260818_cursor_call_integration/010_phase1.md +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -1,5 +1,11 @@ # 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. 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..bf8eb775ec --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md @@ -0,0 +1,78 @@ +# 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 | +| `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 | + +`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 index d8410c0c00..7766d89d0c 100644 --- a/devlog/_plan/260818_cursor_call_integration/020_phase2.md +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -1,5 +1,11 @@ # 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). diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md index 862bfc9607..6a8e5d26a8 100644 --- a/devlog/_plan/260818_cursor_call_integration/030_phase3.md +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -1,5 +1,11 @@ # 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 diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md index 4b8f3a314b..5c3d542e73 100644 --- a/devlog/_plan/260818_cursor_call_integration/040_phase4.md +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -1,5 +1,11 @@ # 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). diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md index e54b29ffa7..6cb8ee20d8 100644 --- a/devlog/_plan/260818_cursor_call_integration/050_phase5.md +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -1,5 +1,11 @@ # 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. 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..985d5c6601 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -0,0 +1,201 @@ +#!/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 + +ROOT="${0:A:h}/../.." +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 } +save () { print -r -- "$1=$2" >> "$STATE"; log "recorded $1=$2" } + +# 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 } + +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 } + +# ---------------------------------------------------------------- 010: rebase + +step_pin () { + 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" + git rebase "$VERIFIED_BASE" + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \ + || die "rebase did not land on VERIFIED_BASE" + ! grep -rn "^<<<<<<<\|^>>>>>>>" src tests >/dev/null 2>&1 \ + || die "conflict markers survived the rebase" +} + +step_push () { + 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 + +LIDGE_HOME=~/Developer/opencodex + +step_verify () { + load_state + 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 && (git worktree add $wt $tip 2>/dev/null || true)" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\"" \ + || die "lidge worktree is not at VERIFIED_TIP" + 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" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\" && $gate" \ + || die "gate failed at $tip: $gate" + done + save GATES_GREEN_AT "$tip" +} + +# --------------------------------------------------------------- 030: the stack + +subject_sha () { git log --format="%H %s" "$VERIFIED_BASE"..cursor-call | grep -F "$1" | 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" + git push --no-verify origin cursor-call-wire cursor-call-cancel +} + +# 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 () { + [[ $# -eq 3 ]] || die "usage: step_record_prs " + save PR1 "$1"; save PR2 "$2"; save PR3 "$3" +} + +# ---------------------------------------------------------------- 040: the merge + +merge_layer () { + local pr="$1" expected_head="$2" + [[ -n "$pr" && -n "$expected_head" ]] || die "merge_layer needs a PR number and its expected head" + [[ "$(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 + EXPECTED_DEV="${EXPECTED_DEV:-$VERIFIED_BASE}" + merge_layer "$PR1" "$PR1_HEAD" + gh pr edit "$PR2" --base dev + merge_layer "$PR2" "$PR2_HEAD" + gh pr edit "$PR3" --base dev + 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" + save MERGED_DEV "$merged" + git fetch origin dev + git merge-base --is-ancestor "$VERIFIED_TIP" "$merged" \ + || die "the verified tip is not an ancestor of the merge result" + [[ "$(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 && (git worktree add $wt $MERGED_DEV 2>/dev/null || true)" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\"" \ + || die "dev worktree is not at MERGED_DEV" + 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" + ssh lidge "cd $wt && $gate" || die "release gate failed on dev: $gate" + done + save RELEASE_GATES_GREEN_AT "$MERGED_DEV" +} + +# ------------------------------------------------------------------------ driver + +main () { + local step="${1:-}" + [[ -n "$step" ]] || die "usage: cursor-call-integration.zsh [args]\n steps: pin rebase push verify cut record_prs merge release_gates state" + shift + case "$step" in + pin) step_pin ;; + rebase) step_rebase ;; + push) step_push ;; + verify) step_verify ;; + cut) step_cut ;; + record_prs) step_record_prs "$@" ;; + merge) step_merge ;; + release_gates) step_release_gates ;; + state) load_state; [[ -f "$STATE" ]] && cat "$STATE" || log "no state yet" ;; + *) die "unknown step: $step" ;; + esac +} + +main "$@" From 41bd73820ba9870dd11c628157bedef38526379d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:02:09 +0900 Subject: [PATCH 36/40] fix(devlog): the state file must not accumulate duplicate keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probing my own script found two defects the reviewer would have. save appended, so re-running a step left two rows for one artifact. source takes the last, but anything reading the file by eye takes the first — the same one-value-two-readings hazard the whole script exists to kill. It now rewrites the key and also exports it into the current shell, so a step that saves then reads sees its own value. step_push and step_record_prs never called load_state, so they ran with nothing loaded. Harmless today because neither reads an earlier artifact, but it is exactly the omission that becomes a bug the moment one of them does. Verified: three consecutive pin runs leave one line, not three; record_prs stores all three numbers and state reads them back. --- .../cursor-call-integration.zsh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh index 985d5c6601..dd9a3315cd 100755 --- a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -19,7 +19,17 @@ mkdir -p "${STATE:h}" log () { print -r -- "[cc] $*" >&2 } die () { print -r -- "[cc] FATAL: $*" >&2; exit 1 } -save () { print -r -- "$1=$2" >> "$STATE"; log "recorded $1=$2" } +# 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" + : >| "$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. @@ -54,6 +64,7 @@ step_rebase () { } step_push () { + load_state 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" @@ -119,6 +130,7 @@ step_cut () { # 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 " save PR1 "$1"; save PR2 "$2"; save PR3 "$3" } From d642d9baef1b1fdf6cff6cfeb313d919675c9ff5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:02:48 +0900 Subject: [PATCH 37/40] fix(devlog): resolve the repo root from git, not by counting .. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script lives three levels down (devlog/_plan//) and ROOT counted two, so it treated devlog/ as the repo root and wrote its state to devlog/.tmp/. That directory is also gitignored, which is why nothing leaked and why the bug was invisible — a wrong path that happens to be safe is worse than one that fails, because it survives. ROOT now comes from git rev-parse --show-toplevel with an explicit failure if the script is run outside a repo. Verified: state lands in .tmp/cursor-call-integration.env, git check-ignore confirms it is ignored, two consecutive pin runs leave one line, and the stale devlog/.tmp copy is moved aside rather than deleted. Third self-inflicted defect found by running the script instead of reading it. That is the argument for having written it. --- .../cursor-call-integration.zsh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh index dd9a3315cd..3cad83261c 100755 --- a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -12,7 +12,11 @@ set -euo pipefail -ROOT="${0:A:h}/../.." +# 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}" From e0dcf4b803ec84f818247336b349bab7a683bca0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:10:43 +0900 Subject: [PATCH 38/40] fix(devlog): ten defects in the driver, three of them fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 15 audited the script instead of the prose and found seven; running it myself found three more. The worst: LIDGE_HOME=~/Developer/opencodex expands LOCALLY under zsh, so every ssh command was sending /Users/jun/... to a Linux host. The remote gates could not have run at all. Now single-quoted so the remote shell expands it. merge could not resume: it restarted at PR1 on every invocation, so a disconnect between gh pr merge and save would re-attempt an already-merged PR. merge_layer now reads PR state first — MERGED adopts its merge commit and returns, OPEN proceeds, anything else is fatal — and retarget_to_dev is equally idempotent. Layers were never verified at their own heads, which AGENTS.md:178-180 requires and 020/030 both specify. cut pushed the branches and moved on. That is now verify_layers, with PR1_TESTS/PR2_TESTS as real variables, and merge refuses without LAYERS_GREEN_AT. Smaller: || true on worktree creation swallowed real failures and accepted a dirty tree sitting at the right HEAD (remote_worktree now creates only when missing and asserts clean); neither worktree was ever removed though both phases require it (cleanup step); the conflict scan missed a lone =======; push and verify did not require VERIFIED_BASE; re-running pin silently invalidated downstream artifacts (now needs --repin); and the state file is sourced, so save now refuses anything that is not a plain token. Plus my own three: save appended instead of replacing keys, ROOT counted .. wrong and wrote state into devlog/.tmp/, and push/record_prs never called load_state. Ten defects in 200 lines, none of which fifteen rounds of reading had surfaced. --- .../019_the_plan_becomes_a_program.md | 35 ++++- .../cursor-call-integration.zsh | 137 ++++++++++++++++-- 2 files changed, 160 insertions(+), 12 deletions(-) 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 index bf8eb775ec..f75a75f5b3 100644 --- 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 @@ -60,9 +60,43 @@ enforces. | `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. `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 @@ -75,4 +109,3 @@ why WP2b must use `partialUsageFromEventState` rather than `resolvedTurnUsage`, 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/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh index 3cad83261c..1a0dd832aa 100755 --- a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -27,6 +27,10 @@ die () { print -r -- "[cc] FATAL: $*" >&2; exit 1 } # 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" @@ -47,9 +51,32 @@ need () { 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 git fetch origin dev local base; base="$(live_dev)" [[ -n "$base" ]] || die "could not read live dev" @@ -63,12 +90,15 @@ step_rebase () { git rebase "$VERIFIED_BASE" git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \ || die "rebase did not land on VERIFIED_BASE" - ! grep -rn "^<<<<<<<\|^>>>>>>>" src tests >/dev/null 2>&1 \ + ! 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" @@ -76,18 +106,22 @@ step_push () { # ------------------------------------------------------- 020: remote verification -LIDGE_HOME=~/Developer/opencodex +# 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 && (git worktree add $wt $tip 2>/dev/null || true)" - ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\"" \ - || die "lidge worktree is not at VERIFIED_TIP" + ssh lidge "cd $LIDGE_HOME && git fetch origin cursor-call dev" || die "lidge fetch failed" + remote_worktree "$wt" "$tip" 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 @@ -96,6 +130,7 @@ step_verify () { || die "gate failed at $tip: $gate" done save GATES_GREEN_AT "$tip" + save CC_WORKTREE "$wt" } # --------------------------------------------------------------- 030: the stack @@ -128,7 +163,34 @@ step_cut () { 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" + 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" +} + +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" + save LAYERS_GREEN_AT "$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 @@ -141,9 +203,31 @@ step_record_prs () { # ---------------------------------------------------------------- 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" + 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" ]] \ @@ -161,11 +245,12 @@ step_merge () { 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 EXPECTED_DEV="${EXPECTED_DEV:-$VERIFIED_BASE}" merge_layer "$PR1" "$PR1_HEAD" - gh pr edit "$PR2" --base dev + retarget_to_dev "$PR2" merge_layer "$PR2" "$PR2_HEAD" - gh pr edit "$PR3" --base dev + 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" @@ -182,9 +267,8 @@ step_merge () { 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 && (git worktree add $wt $MERGED_DEV 2>/dev/null || true)" - ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\"" \ - || die "dev worktree is not at MERGED_DEV" + ssh lidge "cd $LIDGE_HOME && git fetch origin dev" || die "lidge fetch failed" + remote_worktree "$wt" "$MERGED_DEV" 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 @@ -192,13 +276,41 @@ step_release_gates () { ssh lidge "cd $wt && $gate" || die "release gate failed on dev: $gate" done save RELEASE_GATES_GREEN_AT "$MERGED_DEV" + save DEV_WORKTREE "$wt" +} + +# 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 + 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" + log "cleanup done" } # ------------------------------------------------------------------------ driver main () { local step="${1:-}" - [[ -n "$step" ]] || die "usage: cursor-call-integration.zsh [args]\n steps: pin rebase push verify cut record_prs merge release_gates state" + [[ -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 ;; @@ -206,9 +318,12 @@ main () { 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 From 9e679a2d6e09bff5021dd5b0723ff54b18dba49b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:20:49 +0900 Subject: [PATCH 39/40] fix(devlog): the validator that was meant to harden the driver had bricked it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 16 ran the script and found eight more defects. The worst was mine from round 15's fix: [[ "$v" == [A-Za-z0-9._/-]## ]] needs EXTENDED_GLOB, which set -euo pipefail does not enable, so every save died and pin could not even record a base. zsh -n passes it because the syntax is valid — only running it shows every match failing. A security check that silently rejects everything is worse than no check. LAYERS_GREEN_AT was presence-only: it stored PR2_HEAD and merge asked only 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 against the current heads. --repin invalidated nothing — the guard refused a silent re-pin while the override left every downstream artifact looking valid. It now clears them, which is what makes the guard mean anything. (And the driver never passed $@ to step_pin, so --repin could not reach the guard at all.) 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, since the release gates would otherwise run on a commit that never landed. Also: cleanup saved worktree paths only after all gates passed, so a failed run left them unreachable; release_state did not require the release gates to have run on that SHA; and rebase restarted instead of continuing, which would have discarded the resolution of the two conflicts 010 expects. Verified: pin records, record_prs stores three numbers, the re-pin guard refuses, and pin --repin clears every downstream key. --- .../019_the_plan_becomes_a_program.md | 36 +++++++++++++ .../cursor-call-integration.zsh | 52 ++++++++++++++++--- 2 files changed, 81 insertions(+), 7 deletions(-) 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 index f75a75f5b3..08d851f56a 100644 --- 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 @@ -98,6 +98,42 @@ Ten defects in a 200-line script, none of which fifteen rounds of reading prose 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. + `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. diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh index 1a0dd832aa..cb34c8df33 100755 --- a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -11,6 +11,9 @@ # 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 @@ -29,7 +32,7 @@ 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._/-]## ]] \ + [[ "$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 @@ -77,6 +80,14 @@ step_pin () { 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 + 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" @@ -87,7 +98,15 @@ 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" - git rebase "$VERIFIED_BASE" + # 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 \ @@ -122,6 +141,7 @@ step_verify () { 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 @@ -130,7 +150,6 @@ step_verify () { || die "gate failed at $tip: $gate" done save GATES_GREEN_AT "$tip" - save CC_WORKTREE "$wt" } # --------------------------------------------------------------- 030: the stack @@ -189,7 +208,9 @@ step_verify_layers () { git push --no-verify origin cursor-call-wire cursor-call-cancel verify_layer "$PR1_HEAD" "$PR1_TESTS" verify_layer "$PR2_HEAD" "$PR2_TESTS" - save LAYERS_GREEN_AT "$PR2_HEAD" + # 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 " } @@ -223,6 +244,15 @@ merge_layer () { 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" save EXPECTED_DEV "$oid" log "PR $pr already merged — adopted $oid" return 0 @@ -246,6 +276,8 @@ step_merge () { 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" @@ -258,6 +290,10 @@ step_merge () { 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" [[ "$(live_dev)" == "$merged" ]] \ || log "NOTE: dev has moved past our merge — 050 must say so in the readiness note" } @@ -269,6 +305,7 @@ step_release_gates () { 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 @@ -276,14 +313,15 @@ step_release_gates () { ssh lidge "cd $wt && $gate" || die "release gate failed on dev: $gate" done save RELEASE_GATES_GREEN_AT "$MERGED_DEV" - save DEV_WORKTREE "$wt" } # 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 + 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)" @@ -313,7 +351,7 @@ main () { [[ -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 ;; + pin) step_pin "$@" ;; rebase) step_rebase ;; push) step_push ;; verify) step_verify ;; From ff4b0bb4e627354bb57aa7317e690482b2a95312 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Tue, 18 Aug 2026 14:30:30 +0900 Subject: [PATCH 40/40] fix(devlog): a durable key is a claim its proofs passed, so it is written after them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 16's second pass found seven more, and one of them generalizes. MERGED_DEV was saved before its ancestry proofs. A disconnect there, or a failing 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, and that is the rule for 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. The MERGED resume path 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 — so a merge made after an unrelated commit landed on dev would have been adopted as the next layer. The merge commit's first parent must now equal EXPECTED_DEV. --repin cleared CC_WORKTREE and DEV_WORKTREE without removing them first, orphaning them on lidge with no handle left; it now removes them before forgetting. 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 if two commits shared a subject, producing a two-line "SHA" that every later assertion would compare against. It now fails unless exactly one matches. Gate markers were not evidence — 020 and 050 want the command, its output and the SHA. Every gate now appends a receipt to .tmp/cursor-call-receipts.log, including on failure. And record_prs verified nothing; it now checks 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. --- .../019_the_plan_becomes_a_program.md | 38 ++++++++++ .../cursor-call-integration.zsh | 74 +++++++++++++++++-- 2 files changed, 107 insertions(+), 5 deletions(-) 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 index 08d851f56a..7dabf115a2 100644 --- 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 @@ -134,6 +134,44 @@ 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. diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh index cb34c8df33..cc7db1499c 100755 --- a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -46,6 +46,19 @@ save () { # 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)" @@ -83,6 +96,10 @@ step_pin () { # --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" @@ -146,15 +163,24 @@ step_verify () { 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" - ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\" && $gate" \ - || die "gate failed at $tip: $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 -subject_sha () { git log --format="%H %s" "$VERIFIED_BASE"..cursor-call | grep -F "$1" | cut -d" " -f1 } +# 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 @@ -194,6 +220,8 @@ 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" \ @@ -201,6 +229,7 @@ verify_layer () { 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 () { @@ -219,6 +248,29 @@ step_verify_layers () { 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" } @@ -253,6 +305,12 @@ merge_layer () { 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 @@ -286,7 +344,6 @@ step_merge () { 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" - save MERGED_DEV "$merged" git fetch origin dev git merge-base --is-ancestor "$VERIFIED_TIP" "$merged" \ || die "the verified tip is not an ancestor of the merge result" @@ -294,6 +351,9 @@ step_merge () { # 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" } @@ -310,7 +370,10 @@ step_release_gates () { 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" - ssh lidge "cd $wt && $gate" || die "release gate failed on dev: $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" } @@ -341,6 +404,7 @@ 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" }