@junghanacs/entwurf 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/AGENTS.md +5 -3
  2. package/BASELINE.md +57 -203
  3. package/CHANGELOG.md +19 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +117 -261
  6. package/README.md +68 -408
  7. package/VERIFY.md +58 -87
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +231 -0
  10. package/docs/external-mcp-host.md +132 -0
  11. package/docs/fresh-cut-policy.md +99 -0
  12. package/docs/setup-clean-host.md +123 -328
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +21 -48
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +3 -3
  20. package/mcp/entwurf-bridge/src/index.ts +21 -48
  21. package/package.json +12 -9
  22. package/pi-extensions/entwurf-control.ts +20 -49
  23. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  24. package/pi-extensions/lib/acp/augment.ts +42 -3
  25. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  26. package/pi-extensions/lib/acp/backend.ts +305 -36
  27. package/pi-extensions/lib/acp/config.ts +1 -1
  28. package/pi-extensions/lib/acp/engraving.ts +43 -1
  29. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  30. package/pi-extensions/lib/acp/models.ts +4 -4
  31. package/pi-extensions/lib/acp/overlay.ts +3 -3
  32. package/prompts/engraving.md +13 -5
  33. package/run.sh +244 -108
  34. package/scripts/check-acp-carrier-augment.ts +246 -10
  35. package/scripts/check-acp-cortex.ts +5 -5
  36. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  37. package/scripts/check-acp-sdk-surface.ts +60 -21
  38. package/scripts/check-acp-stop-reason.ts +342 -0
  39. package/scripts/check-entwurf-v2-surface.ts +41 -0
  40. package/scripts/check-gate-qualification.ts +6 -3
  41. package/scripts/check-probe-ordering.ts +39 -7
  42. package/scripts/check-release-gate-outcomes.ts +332 -0
  43. package/scripts/lib/live-skip.ts +33 -0
  44. package/scripts/lib/probe-acp-turn.ts +33 -12
  45. package/scripts/lib/step-outcome.sh +88 -0
  46. package/scripts/mutants/acp-augment.json +106 -0
  47. package/scripts/mutants/acp-cortex.json +2 -2
  48. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  49. package/scripts/mutants/acp-stop-reason.json +80 -0
  50. package/scripts/mutants/probe-ordering.json +27 -5
  51. package/scripts/mutants/release-gate.json +105 -0
  52. package/scripts/mutants/v2-surface.json +26 -15
  53. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  54. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  55. package/scripts/smoke-acp-cortex-live.ts +8 -14
  56. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  57. package/scripts/smoke-acp-mcp-live.ts +2 -2
  58. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  59. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  60. package/scripts/smoke-acp-overlay-live.ts +2 -2
  61. package/scripts/smoke-acp-provider-live.ts +2 -2
  62. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  63. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  64. package/scripts/smoke-acp-skill-live.ts +2 -2
  65. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  66. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  67. package/scripts/smoke-agy-native-push-live.ts +4 -3
  68. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  69. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  70. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  71. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
  72. package/scripts/smoke-meta-install-state.sh +4 -0
@@ -4,23 +4,62 @@
4
4
  // the peer-resolution that makes the Claude ACP adapter satisfiable:
5
5
  //
6
6
  // @agentclientprotocol/sdk 1.3.0 wire SDK (acp-bridge import source)
7
- // @agentclientprotocol/claude-agent-acp 0.62.0 Claude adapter (spawn binary)
7
+ // @agentclientprotocol/claude-agent-acp 0.64.0 Claude adapter (spawn binary)
8
8
  // @anthropic-ai/sdk 0.100.1 peer-resolution pin ONLY (see below)
9
9
  //
10
- // 2026-07-27 bump 0.61.0 → 0.62.0: the adapter's own `dist/` is byte-identical
11
- // across the two releases; 0.62.0 moves only its declared deps
12
- // (claude-agent-sdk 0.3.217 → 0.3.219; its devDep anthropic sdk, which does not
13
- // reach us). So this bump is a dependency refresh that carries NO adapter-code
14
- // change and specifically NOT a fix for the bundled-MCP readiness race
15
- // (ROADMAP 🔴): 0.62.0 adds no explicit readiness fence. Effective runtime
16
- // behavior is NOT claimed identical: the transitive SDK moved, and MCP startup
17
- // lives inside it, so timing may differ — what is established is only that no
18
- // new fence was added.
10
+ // 2026-07-31 bump 0.63.0 → 0.64.0 a ONE-FEATURE adapter release. Measured
11
+ // against the upstream checkout (release commit 9cc5a09): the entire functional
12
+ // delta is `src/elicitation.ts` +14 lines (commit d7a65ce), which stamps the
13
+ // AskUserQuestion form's "Other" free-text field with a shared
14
+ // `_meta._askUserQuestionCustomAnswer` marker so ACP clients can recognize a
15
+ // custom-answer companion across Codex/Claude bridges. Published package:
16
+ // unpackedSize 529,638 530,740 B, fileCount 24 24. Runtime deps are
17
+ // IDENTICAL (`@agentclientprotocol/sdk` 1.3.0, `@anthropic-ai/claude-agent-sdk`
18
+ // 0.3.220) the upstream lock's `@modelcontextprotocol/sdk` 1.29.0 → 1.30.0 is
19
+ // THEIR dev tree; our resolution stays 1.29.0 because our runtime dep set did
20
+ // not move. Re-measured: the anthropic peer still resolves 0.100.1.
21
+ //
22
+ // Reachability is STRONGER than "off by default", and it is measured, not
23
+ // assumed. `backend.ts` sends `clientCapabilities: {}` — a hardcoded literal with
24
+ // no operator-config seam — so upstream computes `elicitationSupport.form =
25
+ // false`. Two independent upstream gates then close the new marker off:
26
+ // `acp-agent.ts:5358` disallows `AskUserQuestion` outright, and `:5449` composes
27
+ // the final list by CONCATENATION (`[...userProvidedOptions.disallowedTools,
28
+ // ...disallowedTools]`), so no operator `disallowedTools` can remove it; and the
29
+ // form-elicitation emission branch at `:4558` is gated on the same flag. The only
30
+ // lever is `clientCapabilities`, which entwurf does not expose to configuration —
31
+ // so this marker cannot reach our wire without an entwurf SOURCE change. That is
32
+ // the honest claim for THIS delta; do not generalize it to other axes.
33
+ //
34
+ // HISTORY — 2026-07-30 bump 0.62.0 → 0.63.0 — an ADAPTER-CODE release, not a dependency
35
+ // refresh. Measured on the packed tarballs: 137,294 → 142,084 bytes, with
36
+ // `dist/acp-agent.js`, `dist/tools.js` and the `dist/acp-agent.d.ts` surface all
37
+ // moved. Three upstream fixes ride it (#923 denied-tool resolution, #916
38
+ // tool_progress heartbeat keying, #917 Bash terminal metas keyed off the
39
+ // announced tool_use id) plus claude-agent-sdk 0.3.219 → 0.3.220.
40
+ //
41
+ // Reachability onto OUR surface is narrower than that list, and is measured, not
42
+ // assumed: `backend.ts` sends `clientCapabilities: {}`, and the adapter gates
43
+ // terminal metas on `clientCapabilities?._meta?.["terminal_output"] === true`
44
+ // and subagent transcript on the matching capability — so both stay off for
45
+ // every backend we ship. What CAN still reach the wire is opt-in-free metadata
46
+ // (`_meta.claudeCode.title` / `.subagent`) and #916's re-keyed heartbeats; the
47
+ // mapper ignores those, so no visible state changes. Do not write this up as
48
+ // "0.63.0 does not reach us" — write what each axis was measured to do.
49
+ //
50
+ // HISTORY — 2026-07-27 bump 0.61.0 → 0.62.0: that one WAS a pure dependency
51
+ // refresh; the adapter's `dist/` was byte-identical across the two releases and
52
+ // only its declared deps moved (claude-agent-sdk 0.3.217 → 0.3.219). It was
53
+ // specifically NOT a fix for the bundled-MCP readiness race (ROADMAP 🔴), and
54
+ // 0.63.0 adds no explicit readiness fence either. Effective runtime behavior is
55
+ // NOT claimed identical across either bump: the transitive SDK moved both times,
56
+ // and MCP startup lives inside it, so timing may differ — what is established is
57
+ // only that no new fence was added.
19
58
  //
20
59
  // The anthropic SDK is NOT an API client / auth surface here. It is a direct
21
- // dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.219's peer floor
22
- // (>=0.93.0 — re-measured at 0.3.219, unchanged from 0.3.217, so the 0.100.1
23
- // pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
60
+ // dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.220's peer floor
61
+ // (>=0.93.0 — re-measured at 0.3.220, unchanged from 0.3.219 and 0.3.217, so the
62
+ // 0.100.1 pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
24
63
  // unmet — a failure that would only surface at the first raw turn. The
25
64
  // lockfile proves the same shape. Source-level import / API-client
26
65
  // instantiation / credential use stays forbidden — asserted in layer (4).
@@ -54,7 +93,7 @@ const pkg = JSON.parse(read("package.json")) as { dependencies?: Record<string,
54
93
  const deps = pkg.dependencies ?? {};
55
94
  const PINS: Record<string, string> = {
56
95
  "@agentclientprotocol/sdk": "1.3.0",
57
- "@agentclientprotocol/claude-agent-acp": "0.62.0",
96
+ "@agentclientprotocol/claude-agent-acp": "0.64.0",
58
97
  [ANTHROPIC_SDK]: "0.100.1",
59
98
  };
60
99
  for (const [name, ver] of Object.entries(PINS)) {
@@ -74,13 +113,13 @@ for (const [name, ver] of Object.entries(PINS)) {
74
113
  const lock = read("pnpm-lock.yaml");
75
114
  assert.match(
76
115
  lock,
77
- /@agentclientprotocol\/claude-agent-acp@0\.62\.0\(@anthropic-ai\/sdk@0\.100\.1/,
78
- "pnpm-lock: claude-agent-acp@0.62.0 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
116
+ /@agentclientprotocol\/claude-agent-acp@0\.64\.0\(@anthropic-ai\/sdk@0\.100\.1/,
117
+ "pnpm-lock: claude-agent-acp@0.64.0 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
79
118
  );
80
119
  assert.match(
81
120
  lock,
82
- /@anthropic-ai\/claude-agent-sdk@0\.3\.219\(@anthropic-ai\/sdk@0\.100\.1/,
83
- "pnpm-lock: claude-agent-sdk@0.3.219 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
121
+ /@anthropic-ai\/claude-agent-sdk@0\.3\.220\(@anthropic-ai\/sdk@0\.100\.1/,
122
+ "pnpm-lock: claude-agent-sdk@0.3.220 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
84
123
  );
85
124
 
86
125
  // ---------------------------------------------------------------------------
@@ -119,8 +158,8 @@ const casEntry = adapterRequire.resolve("@anthropic-ai/claude-agent-sdk");
119
158
  const casInfo = pkgInfoFromEntry(casEntry);
120
159
  assert.equal(
121
160
  casInfo.version,
122
- "0.3.219",
123
- `@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.219 from the adapter context (got ${casInfo.version})`,
161
+ "0.3.220",
162
+ `@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.220 from the adapter context (got ${casInfo.version})`,
124
163
  );
125
164
  const casRequire = createRequire(resolve(casInfo.dir, "package.json"));
126
165
  const sdkEntry = casRequire.resolve(ANTHROPIC_SDK);
@@ -163,7 +202,7 @@ const mcpEntry = casRequire.resolve("@modelcontextprotocol/sdk/server/index.js")
163
202
  const mcpInfo = pkgInfoFromEntry(mcpEntry);
164
203
  assert.ok(
165
204
  mcpInfo.version.startsWith("1.29."),
166
- `@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.219 declares a ^1.29.0 peer`,
205
+ `@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.220 declares a ^1.29.0 peer`,
167
206
  );
168
207
 
169
208
  // ---------------------------------------------------------------------------
@@ -0,0 +1,342 @@
1
+ // Deterministic gate for the ACP stop-reason contract (backend.ts + event-mapper.ts).
2
+ //
3
+ // WHAT THIS EXISTS TO STOP. `mapPromptStopReason` used to be a bare
4
+ // `switch` returning a StopReason with `default: return "stop"`. The ACP terminal
5
+ // set is closed — `@agentclientprotocol/sdk` 1.3.0 `schema/types.gen` declares
6
+ // `end_turn | max_tokens | max_turn_requests | refusal | cancelled` — so that
7
+ // default silently collapsed THREE distinct non-success outcomes into a clean
8
+ // successful turn:
9
+ //
10
+ // refusal the model declined; the answer is absent
11
+ // max_turn_requests the backend's per-turn budget ran out; the answer is cut
12
+ // <anything new> a reason this build has never seen
13
+ //
14
+ // plus a fourth: a prompt result carrying NO stopReason at all. pi rendered all
15
+ // four as a finished assistant message. pi 0.83 closed the identical hole in its
16
+ // own providers (#7272 — unmapped terminal reasons surface as provider errors,
17
+ // never successful stops) and added `AssistantMessage.rawStopReason` so the wire
18
+ // value survives mapping; this gate pins our side of that same contract.
19
+ //
20
+ // A pure-function check of the mapper is NOT enough — the defect people actually
21
+ // feel is "pi showed a truncated answer as done". So every cell is driven
22
+ // BEHAVIORALLY through `streamAcpTurn` against a fake ACP connection, and each
23
+ // asserts all four observable facts together:
24
+ //
25
+ // (a) the emitted event KIND done vs error
26
+ // (b) the emitted event REASON stop / length / aborted / error
27
+ // (c) the final message stopReason
28
+ // (d) rawStopReason + errorMessage on that same final message
29
+ //
30
+ // SEED CLAIM (separate, and deliberately not folded into the cells above).
31
+ // `createAcpStreamState` seeds `stopReason: "pending"` — pi 0.83's sentinel for
32
+ // "no terminal reason observed yet". Seeding "stop" would pre-claim success for
33
+ // the entire in-flight turn. That claim is asserted on the value returned by
34
+ // `createAcpStreamState` DIRECTLY: `state.output` is a mutable object that every
35
+ // terminal path overwrites, so reading it after a turn would only ever show the
36
+ // terminal value and could never fail. Reading it late is how this assertion
37
+ // would quietly stop testing anything.
38
+ //
39
+ // backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
40
+ // convention), so — like check-acp-session-reuse — we tsc-emit the project and
41
+ // import the COMPILED backend.js whose `.js` imports resolve to real siblings.
42
+
43
+ import { strict as assert } from "node:assert";
44
+ import { execFileSync } from "node:child_process";
45
+ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
46
+ import { tmpdir } from "node:os";
47
+ import { resolve } from "node:path";
48
+ import { pathToFileURL } from "node:url";
49
+ import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
50
+
51
+ const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
52
+
53
+ type Stream = AsyncIterable<AssistantMessageEvent> & {
54
+ result: () => Promise<{ stopReason: string; errorMessage?: string }>;
55
+ };
56
+
57
+ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
58
+ const events: AssistantMessageEvent[] = [];
59
+ for await (const ev of stream) events.push(ev);
60
+ return events;
61
+ }
62
+
63
+ function makeFakeChild() {
64
+ const pipe = () => ({ destroy() {}, unref() {} });
65
+ return {
66
+ pid: undefined as number | undefined,
67
+ exitCode: null as number | null,
68
+ signalCode: null as NodeJS.Signals | null,
69
+ stdin: pipe(),
70
+ stdout: pipe(),
71
+ stderr: { on() {}, destroy() {}, unref() {} },
72
+ kill() {
73
+ return true;
74
+ },
75
+ unref() {},
76
+ once() {},
77
+ };
78
+ }
79
+
80
+ const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
81
+ const DEFAULT_RESOLVED_CONFIG: any = {
82
+ settingSources: [],
83
+ strictMcpConfig: true,
84
+ showToolNotifications: true,
85
+ mcpServers: [],
86
+ mcpServersHash: EMPTY_MCP_HASH,
87
+ tools: ["Read"],
88
+ skillPlugins: [],
89
+ permissionAllow: ["Read(*)"],
90
+ disallowedTools: [],
91
+ };
92
+
93
+ /**
94
+ * A harness whose fake ACP `prompt` resolves with EXACTLY the terminal shape the
95
+ * cell is about. `wireReason === null` models a result object with no stopReason
96
+ * key at all (the "absent" cell) rather than a literal `undefined` value.
97
+ */
98
+ function makeHarness(recordDir: string, wireReason: string | null) {
99
+ return {
100
+ resolveLaunch: () => ({ command: "node", args: ["fake"] }),
101
+ ensureOverlay: () => {},
102
+ spawnChild: () => makeFakeChild(),
103
+ createConnection: (_child: any, handlers: any) => ({
104
+ initialize: async () => ({ agentCapabilities: {} }),
105
+ newSession: async () => ({ sessionId: "ACP-1" }),
106
+ setSessionConfigOption: async () => ({}),
107
+ prompt: async ({ sessionId }: any) => {
108
+ await handlers.sessionUpdate({
109
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "PARTIAL" } },
110
+ sessionId,
111
+ });
112
+ return wireReason === null ? {} : { stopReason: wireReason };
113
+ },
114
+ }),
115
+ lifecyclePolicy: () => "process-scoped",
116
+ loadConfig: () => DEFAULT_RESOLVED_CONFIG,
117
+ now: () => "2026-07-30T00:00:00Z",
118
+ sessionDir: recordDir,
119
+ };
120
+ }
121
+
122
+ const TMP_EMIT = ".tmp-verify/acp-stop-reason";
123
+ rmSync(TMP_EMIT, { recursive: true, force: true });
124
+ const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stop-rec-"));
125
+
126
+ /**
127
+ * One driven turn, described by everything an operator could observe.
128
+ *
129
+ * `claim` is the qualification token this cell's SEALING assertions carry. The
130
+ * mutant harness does not accept "the gate went red" — it requires the gate to
131
+ * go red *at the token of the claim under test*, so a cell that guards a claim
132
+ * must print that claim's `[QK:…]` in the very assertion the mutant trips.
133
+ * Cells whose sealing no mutant targets carry no token.
134
+ */
135
+ type Cell = {
136
+ wire: string | null;
137
+ kind: "done" | "error";
138
+ reason: string;
139
+ stopReason: string;
140
+ rawStopReason?: string;
141
+ /** Substring the errorMessage must carry; absent = there must be no errorMessage. */
142
+ errorContains?: string;
143
+ claim?: string;
144
+ why: string;
145
+ };
146
+
147
+ /** Qualification token for the claim that the raw ACP reason survives mapping. */
148
+ const RAW_CLAIM = "[QK:STOP-RAW-PRESERVED]";
149
+
150
+ /** Prefix an assertion message with a claim token when the cell guards one. */
151
+ function tag(cell: Cell, message: string): string {
152
+ return cell.claim ? `${cell.claim} ${message}` : message;
153
+ }
154
+
155
+ const CELLS: Cell[] = [
156
+ {
157
+ wire: "end_turn",
158
+ kind: "done",
159
+ reason: "stop",
160
+ stopReason: "stop",
161
+ rawStopReason: "end_turn",
162
+ why: "a normal completion is the only clean success",
163
+ },
164
+ {
165
+ wire: "max_tokens",
166
+ kind: "done",
167
+ reason: "length",
168
+ stopReason: "length",
169
+ rawStopReason: "max_tokens",
170
+ why: "output-cap truncation is honestly reported as length, not stop",
171
+ },
172
+ {
173
+ wire: "cancelled",
174
+ kind: "error",
175
+ reason: "aborted",
176
+ stopReason: "aborted",
177
+ rawStopReason: "cancelled",
178
+ why: "a cancelled turn is an abort, never a done",
179
+ },
180
+ {
181
+ wire: "refusal",
182
+ kind: "error",
183
+ reason: "error",
184
+ stopReason: "error",
185
+ rawStopReason: "refusal",
186
+ errorContains: "refusal",
187
+ claim: "[QK:STOP-REFUSAL-NOT-SUCCESS]",
188
+ why: "a model refusal has no answer to show — reporting it as done is the original defect",
189
+ },
190
+ {
191
+ wire: "max_turn_requests",
192
+ kind: "error",
193
+ reason: "error",
194
+ stopReason: "error",
195
+ rawStopReason: "max_turn_requests",
196
+ errorContains: "max_turn_requests",
197
+ claim: "[QK:STOP-TURNBUDGET-NOT-SUCCESS]",
198
+ why: "an exhausted per-turn request budget leaves the answer cut short",
199
+ },
200
+ {
201
+ wire: "teapot",
202
+ kind: "error",
203
+ reason: "error",
204
+ stopReason: "error",
205
+ rawStopReason: "teapot",
206
+ errorContains: "teapot",
207
+ claim: "[QK:STOP-UNKNOWN-NOT-SUCCESS]",
208
+ why: "a reason this build has never seen must fail loud, carrying the raw value",
209
+ },
210
+ {
211
+ wire: null,
212
+ kind: "error",
213
+ reason: "error",
214
+ stopReason: "error",
215
+ errorContains: "without a stop reason",
216
+ claim: "[QK:STOP-ABSENT-NOT-SUCCESS]",
217
+ why: "a prompt result with no stopReason at all is not a successful turn",
218
+ },
219
+ ];
220
+
221
+ try {
222
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
223
+ stdio: "pipe",
224
+ });
225
+ const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
226
+ mkdirSync(promptsOut, { recursive: true });
227
+ copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
228
+
229
+ const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
230
+ const eventUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/event-mapper.js")).href;
231
+ const backend = (await import(backendUrl)) as any;
232
+ const eventMod = (await import(eventUrl)) as any;
233
+
234
+ // ----------------------------------------------------------------------
235
+ // (1) SEED — asserted on the freshly returned state, never after a turn.
236
+ // ----------------------------------------------------------------------
237
+ {
238
+ const seeded = eventMod.createAcpStreamState(
239
+ { push() {}, end() {} },
240
+ {
241
+ api: "entwurf",
242
+ provider: "entwurf",
243
+ model: sonnet.id,
244
+ },
245
+ );
246
+ assert.equal(
247
+ seeded.output.stopReason,
248
+ "pending",
249
+ "[QK:STOP-PENDING-SEED] a freshly created ACP stream state must seed stopReason 'pending' (pi 0.83's " +
250
+ "no-terminal-reason-yet sentinel) — seeding 'stop' pre-claims success for the whole in-flight turn",
251
+ );
252
+ }
253
+
254
+ // ----------------------------------------------------------------------
255
+ // (2) The seven terminal cells, driven end to end. Each cell's qualification
256
+ // token lives in its `claim` field above — exactly one occurrence per
257
+ // token in this file, which is what the mutant manifest requires.
258
+ // ----------------------------------------------------------------------
259
+ for (const cell of CELLS) {
260
+ const label = cell.wire === null ? "<absent>" : cell.wire;
261
+ const events = await collect(
262
+ backend.streamAcpTurn(
263
+ sonnet,
264
+ { messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }] } as Context,
265
+ { sessionId: `stop-${label}` },
266
+ makeHarness(recordDir, cell.wire),
267
+ ) as Stream,
268
+ );
269
+
270
+ const done = events.find((e) => e.type === "done") as any;
271
+ const errored = events.find((e) => e.type === "error") as any;
272
+
273
+ if (cell.kind === "done") {
274
+ assert.ok(done, tag(cell, `[${label}] must seal as a done event — ${cell.why}`));
275
+ assert.ok(!errored, tag(cell, `[${label}] must not also emit an error event`));
276
+ } else {
277
+ assert.ok(errored, tag(cell, `[${label}] must seal as an ERROR event, not a done — ${cell.why}`));
278
+ assert.ok(
279
+ !done,
280
+ tag(cell, `[${label}] must NOT emit a done event — that is exactly the collapse this gate forbids`),
281
+ );
282
+ }
283
+
284
+ const sealed = cell.kind === "done" ? done : errored;
285
+ assert.equal(sealed.reason, cell.reason, tag(cell, `[${label}] sealed event reason must be "${cell.reason}"`));
286
+
287
+ const final = cell.kind === "done" ? sealed.message : sealed.error;
288
+ assert.equal(
289
+ final.stopReason,
290
+ cell.stopReason,
291
+ tag(cell, `[${label}] final message stopReason must be "${cell.stopReason}"`),
292
+ );
293
+ // Deliberately carries no claim token: the seed mutant is caught by the
294
+ // direct assertion in (1), and this one is the standing guarantee that no
295
+ // terminal path ever leaks the sentinel out to a consumer.
296
+ assert.notEqual(
297
+ final.stopReason,
298
+ "pending",
299
+ `[${label}] the seed must never survive a sealed turn — every terminal path overwrites it`,
300
+ );
301
+ assert.equal(
302
+ final.rawStopReason,
303
+ cell.rawStopReason,
304
+ `${RAW_CLAIM} [${label}] rawStopReason must preserve the wire value (${cell.rawStopReason ?? "absent"}) so ` +
305
+ "the raw ACP reason survives the mapping",
306
+ );
307
+
308
+ if (cell.errorContains) {
309
+ assert.ok(
310
+ typeof final.errorMessage === "string" && final.errorMessage.includes(cell.errorContains),
311
+ tag(
312
+ cell,
313
+ `[${label}] errorMessage must name the reason (expected to contain "${cell.errorContains}", got ` +
314
+ `${JSON.stringify(final.errorMessage)})`,
315
+ ),
316
+ );
317
+ } else {
318
+ assert.equal(final.errorMessage, undefined, tag(cell, `[${label}] a clean end must not carry an errorMessage`));
319
+ }
320
+ }
321
+ } finally {
322
+ rmSync(TMP_EMIT, { recursive: true, force: true });
323
+ try {
324
+ // The qualification harness's work-surface hash walks ignored paths too, so
325
+ // a leftover EMPTY parent dir reads as IMPURE tree drift even though git
326
+ // porcelain is clean. Remove it when empty; a concurrent sibling gate's
327
+ // emit keeps it alive and this rmdir simply fails.
328
+ rmdirSync(".tmp-verify");
329
+ } catch {
330
+ // non-empty or already gone — fine either way
331
+ }
332
+ rmSync(recordDir, { recursive: true, force: true });
333
+ }
334
+
335
+ console.log(
336
+ "[check-acp-stop-reason] ok — the ACP terminal set is driven end to end through streamAcpTurn: end_turn→done/stop " +
337
+ "and max_tokens→done/length are the only clean seals; cancelled→error/aborted; refusal, max_turn_requests, an " +
338
+ "unrecognized reason, and an ABSENT reason each seal as an ERROR event with no done (the collapse-to-stop defect " +
339
+ "pi 0.83 #7272 closed on its own providers); rawStopReason preserves the wire value on every cell that carried " +
340
+ "one; and the stream state seeds 'pending' (asserted on the fresh state, not after a turn) so no in-flight turn " +
341
+ "pre-claims success",
342
+ );
@@ -162,6 +162,45 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
162
162
  * parameter marker in the long-description slice: if the slice ever swallows the schema
163
163
  * again, this fails instead of silently satisfying the schema's own text.
164
164
  */
165
+ /**
166
+ * HOST TRUNCATION CAP. Measured 2026-07-30 on both Claude ACP and stock Claude Code: a tool
167
+ * description longer than this is cut mid-sentence and the tail is replaced with `… [truncated]`.
168
+ * The shipped entwurf_v2 text was 4,022 chars, so everything from the INTENT contract onward —
169
+ * the reject taxonomy, the three-valued probe, the lock scope — never reached the model that had
170
+ * to choose an intent. Every other assertion in this file checks that a sentence is PRESENT in
171
+ * source; none of them can see that the host threw half of it away. This one can.
172
+ *
173
+ * Judged on `modelText` (the rendered string), not the source slice: pi-native wraps inside one
174
+ * template literal and the MCP surface `+`-concatenates, so only the normalized text has the
175
+ * length the host actually measures.
176
+ */
177
+ const HOST_DESCRIPTION_CAP = 2048;
178
+
179
+ /**
180
+ * The string the HOST measures, recovered from either surface's source scaffolding: drop the
181
+ * `description:` key and the outer template-literal / string-literal fence, and unescape `\"`.
182
+ * Counting the raw slice instead would charge the text for punctuation the model never sees —
183
+ * and the two surfaces carry DIFFERENT punctuation, so the cap would mean two different things.
184
+ */
185
+ function renderedDescription(raw: string): string {
186
+ const flat = modelText(raw);
187
+ // Anchor on the description's own first word rather than on scaffolding: the two surfaces put
188
+ // DIFFERENT preludes in front of it (the MCP slice starts at the tool name), and counting those
189
+ // would report a number the host never measures — a cap gate that cites the wrong length gets
190
+ // quoted as if it were the real one.
191
+ const from = flat.indexOf("CANONICAL");
192
+ return (from >= 0 ? flat.slice(from) : flat).replace(/[`"],?$/, "").replace(/\\"/g, '"');
193
+ }
194
+
195
+ function assertDescriptionFitsHostCap(tag: string, longDescRaw: string): void {
196
+ const rendered = renderedDescription(longDescRaw);
197
+ ok(
198
+ `${tag} — long description fits the host's ${HOST_DESCRIPTION_CAP}-char cap (${rendered.length}) ` +
199
+ "[QK:V2SURF-DESC-FITS-HOST-CAP]",
200
+ rendered.length <= HOST_DESCRIPTION_CAP,
201
+ );
202
+ }
203
+
165
204
  function assertLongDescExcludesParams(tag: string, longDesc: string, markers: readonly string[]): void {
166
205
  for (const marker of markers) {
167
206
  ok(`${tag} — long description slice excludes the \`${marker}\` parameter marker`, !longDesc.includes(marker));
@@ -551,6 +590,7 @@ async function main(): Promise<void> {
551
590
  ]);
552
591
  const piModeDesc = sliceDescription(piV2Block, "mode: Type.Optional(", "wants_reply:");
553
592
  assertRailSemantics("4: pi-native", piLongDesc, piIntentDesc, piModeDesc);
593
+ assertDescriptionFitsHostCap("4: pi-native", piLongDesc);
554
594
  }
555
595
 
556
596
  // ── 5: MCP bridge wiring guard ────────────────────────────────────────────
@@ -635,6 +675,7 @@ async function main(): Promise<void> {
635
675
  assertLongDescExcludesParams("5: MCP", mcpLongDesc, ["target: z", "intent: z", "mode: z", "wants_reply: z"]);
636
676
  const mcpModeDesc = sliceDescription(v2Block, "mode: z", "wants_reply: z");
637
677
  assertRailSemantics("5: MCP", mcpLongDesc, mcpIntentDesc, mcpModeDesc);
678
+ assertDescriptionFitsHostCap("5: MCP", mcpLongDesc);
638
679
 
639
680
  // entwurf_peers is a FACT surface, but it still told the model what `unsupported` means —
640
681
  // and "does NOT mean unreachable" is false for a record whose backend has no adapter on
@@ -718,13 +718,16 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
718
718
  // manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
719
719
  // Extend this inventory and the manifests TOGETHER, never silently.
720
720
  const EXPECTED_LANE_MUTANTS: Record<string, number> = {
721
- "acp-augment": 2,
721
+ "acp-augment": 10,
722
722
  "acp-cortex": 12,
723
+ "acp-prompt-lifecycle": 8,
724
+ "acp-stop-reason": 6,
723
725
  "agy-permission": 6,
724
726
  "meta-identity": 1,
725
- "probe-ordering": 83,
727
+ "probe-ordering": 85,
728
+ "release-gate": 8,
726
729
  "self-address": 3,
727
- "v2-surface": 4,
730
+ "v2-surface": 5,
728
731
  };
729
732
  const laneTally: Record<string, number> = {};
730
733
  for (const man of manifests) laneTally[man.lane] = (laneTally[man.lane] ?? 0) + man.mutants.length;
@@ -94,6 +94,7 @@ import { terminateChild } from "./lib/acp-child-cleanup.ts";
94
94
  import {
95
95
  driveProbeTurn,
96
96
  PROBE_PHASE_TIMEOUTS,
97
+ PROBE_PROMPT_OBSERVATION_MS,
97
98
  type ProbeAdapterSeam,
98
99
  type ProbeMcpEnricher,
99
100
  ProbePhaseError,
@@ -290,7 +291,7 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
290
291
  `set-model start/end are marked between newSession end and prompt start (got ${eventOrder.join(",")})`,
291
292
  );
292
293
 
293
- // §9-4 shape: an undefined session meta omits the `_meta` KEY entirely.
294
+ // Carrier-less shape: an undefined session meta omits the `_meta` KEY entirely.
294
295
  const calls2: RecordedCall[] = [];
295
296
  await driveProbeTurn(makeRecordingConnection(calls2), {
296
297
  cwd: "/scratch/gate2",
@@ -305,7 +306,7 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
305
306
  });
306
307
  assert.ok(
307
308
  !Object.hasOwn(calls2.find((c) => c.method === "newSession")?.params as object, "_meta"),
308
- "undefined session meta omits the _meta KEY entirely (§9-4)",
309
+ "undefined session meta omits the _meta KEY entirely (carrier-less shape)",
309
310
  );
310
311
 
311
312
  // --- production source pins ---------------------------------------------
@@ -319,15 +320,37 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
319
320
  initializeMs: PROBE_PHASE_TIMEOUTS.initializeMs,
320
321
  newSessionMs: PROBE_PHASE_TIMEOUTS.newSessionMs,
321
322
  setModelMs: PROBE_PHASE_TIMEOUTS.setModelMs,
322
- promptMs: PROBE_PHASE_TIMEOUTS.promptMs,
323
323
  },
324
324
  {
325
325
  initializeMs: timeoutOf("INITIALIZE_TIMEOUT_MS"),
326
326
  newSessionMs: timeoutOf("NEW_SESSION_TIMEOUT_MS"),
327
327
  setModelMs: timeoutOf("SET_MODEL_TIMEOUT_MS"),
328
- promptMs: timeoutOf("PROMPT_TIMEOUT_MS"),
329
328
  },
330
- "probe phase timeouts EQUAL backend.ts's — D is only readable against production boundaries [QK:PROBE-TIMEOUTS-MATCH-PRODUCTION]",
329
+ "probe BOOTSTRAP timeouts EQUAL backend.ts's — a bootstrap-phase D is only readable against production " +
330
+ "boundaries [QK:PROBE-BOOTSTRAP-TIMEOUTS-MATCH-PRODUCTION]",
331
+ );
332
+
333
+ // --- the prompt phase has NO production boundary to match --------------
334
+ //
335
+ // backend.ts ends a prompt on lifecycle events only. The probe still needs a
336
+ // bounded observation horizon so a measurement RUN cannot hang forever, but
337
+ // that horizon is the harness's own: pinning it to a production number (or
338
+ // letting production grow one back) would make the probe report a
339
+ // harness-invented cutoff as if it were the turn contract.
340
+ const promptCutoffTraces = [/\bPROMPT_TIMEOUT_MS\b/, /withTimeout\(\s*\n?\s*"prompt"/].filter((re) =>
341
+ re.test(BACKEND_SRC),
342
+ );
343
+ assert.equal(
344
+ promptCutoffTraces.length,
345
+ 0,
346
+ "[QK:NO-PRODUCTION-PROMPT-CUTOFF] backend.ts carries NO prompt wall-clock — neither a PROMPT_TIMEOUT_MS constant " +
347
+ 'nor a withTimeout("prompt", …) race. A running turn is not a failed turn for being long, and the old 600s ' +
348
+ `cutoff also fed pi's transient-retry dictionary. Found: ${promptCutoffTraces.join(", ")}`,
349
+ );
350
+ assert.ok(
351
+ PROBE_PROMPT_OBSERVATION_MS > 0 && !("promptMs" in PROBE_PHASE_TIMEOUTS),
352
+ "[QK:PROMPT-HORIZON-NOT-PRODUCTION] the prompt horizon lives OUTSIDE the production-pinned bootstrap set — it is " +
353
+ "the harness's own observation bound, and folding it back in would report a harness number as the turn contract",
331
354
  );
332
355
 
333
356
  const idx = (needle: string): number => {
@@ -338,11 +361,19 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
338
361
  const iInit = idx("connection.initialize({");
339
362
  const iNew = idx("connection.newSession(newSessionArgs)");
340
363
  const iEnforce = idx("adapter.enforceModel({");
341
- const iPrompt = BACKEND_SRC.indexOf("connection.prompt({", iEnforce);
364
+ // The prompt no longer sits inline: runNewTurn hands the wire call to
365
+ // awaitAcpPromptTurn (the lifecycle-bounded driver), so the ordering pin
366
+ // follows the dispatch, and the driver's own `session.connection.prompt(...)`
367
+ // is the single place the wire call lives.
368
+ const iPrompt = BACKEND_SRC.indexOf("await awaitAcpPromptTurn(", iEnforce);
342
369
  assert.ok(
343
370
  iInit < iNew && iNew < iEnforce && iEnforce < iPrompt && iPrompt !== -1,
344
371
  "backend.ts runNewTurn keeps initialize → newSession → enforceModel → prompt; the probe mirrors THIS sequence",
345
372
  );
373
+ assert.ok(
374
+ BACKEND_SRC.includes("await Promise.race([session.connection.prompt(promptArgs), lifecycle])"),
375
+ "the prompt driver races the wire call against LIFECYCLE endings only — the probe mirrors that same wire call",
376
+ );
346
377
  assert.ok(
347
378
  BACKEND_SRC.includes('clientInfo: { name: "entwurf", version: "s2d" }'),
348
379
  "backend.ts still sends the clientInfo the probe mirrors",
@@ -490,7 +521,8 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
490
521
  await driveProbeTurn(makeRecordingConnection(calls), {
491
522
  ...base,
492
523
  adapter: { buildSessionMeta: () => undefined, enforceModel: () => new Promise<never>(() => {}) },
493
- timeouts: { initializeMs: 1000, newSessionMs: 1000, setModelMs: 50, promptMs: 1000 },
524
+ timeouts: { initializeMs: 1000, newSessionMs: 1000, setModelMs: 50 },
525
+ promptObservationMs: 1000,
494
526
  });
495
527
  assert.fail("hung set-model must time the turn out");
496
528
  } catch (err) {