@junghanacs/entwurf 0.13.0 → 0.14.0

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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -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
+ );