@junghanacs/entwurf 0.14.1 → 0.14.2

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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +1 -1
  5. package/VERIFY.md +4 -4
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/setup-clean-host.md +3 -3
  11. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  12. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  13. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  14. package/package.json +9 -9
  15. package/pi-extensions/lib/acp/backend.ts +229 -9
  16. package/run.sh +70 -25
  17. package/scripts/agy-bridge-config.py +47 -13
  18. package/scripts/agy-bridge.sh +73 -23
  19. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  20. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  21. package/scripts/check-gate-qualification.ts +3 -2
  22. package/scripts/check-probe-bridge-command.ts +201 -0
  23. package/scripts/check-release-gate-outcomes.ts +54 -1
  24. package/scripts/doctor-pi-provider.ts +155 -51
  25. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  26. package/scripts/mutants/bridge-command-boot.json +107 -0
  27. package/scripts/mutants/release-gate.json +13 -0
  28. package/scripts/probe-bridge-command.ts +330 -0
  29. package/scripts/raw-async-delivery/README.md +158 -1
  30. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  31. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  32. package/scripts/smoke-agy-install-state.sh +76 -2
  33. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  34. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  35. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  36. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  37. package/scripts/smoke-pi-provider-state.sh +135 -6
  38. package/scripts/smoke-resident-garden-guard.sh +2 -2
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Raw Copilot CLI TUI+server delivery probe.
4
+ *
5
+ * This is probe evidence, not a shipped adapter. Start a visible native TUI:
6
+ * copilot --ui-server --port 43817 --model auto
7
+ *
8
+ * The official SDK is intentionally not a production dependency of entwurf.
9
+ * Point COPILOT_SDK_MODULE at its ESM entry (README has the pinned setup).
10
+ * Without LIVE=1 this proves only D0 and spends no model credit.
11
+ *
12
+ * ATTRIBUTION IS THE WHOLE CLAIM. A delivery probe that reads "the newest
13
+ * assistant.message" is reading the TUI, not its own turn: a human typing in the
14
+ * visible session, a queued earlier prompt, or a retried send all produce events
15
+ * that would be scored as OUR delivery.
16
+ *
17
+ * The join runs off the PROBE-OWNED MARKER BODY, not off what `send()` returned.
18
+ * On the bundled CLI 1.0.80 `session.send()` resolves to a `Promise<string>` whose
19
+ * id is the SDK's own submission handle: the server-side `user.message` does NOT
20
+ * carry it, and it is a different axis from that event's `id`/`interactionId`. A
21
+ * join on the returned string is therefore not merely fragile, it cannot hold — so
22
+ * the returned value is kept for the diagnostic log only. The chain that carries
23
+ * the claim is:
24
+ *
25
+ * unique marker body → exactly one `user.message`
26
+ * → its `interactionId`
27
+ * → exactly one `assistant.turn_start` on that interaction
28
+ * → that turn_start's required `turnId`
29
+ * → only `assistant.message` / `assistant.turn_end` on that turnId
30
+ *
31
+ * Every link is required and unambiguous. Absent, or matched more than once, the
32
+ * probe FAILS CLOSED (throws). There is no positional fallback and no "the next
33
+ * turn after ours" rule, because a wrong pass here would be published as capability
34
+ * evidence.
35
+ */
36
+ import { pathToFileURL } from "node:url";
37
+
38
+ const modulePath = process.env.COPILOT_SDK_MODULE;
39
+ if (!modulePath?.startsWith("/")) {
40
+ throw new Error("COPILOT_SDK_MODULE must be an absolute path to @github/copilot-sdk/dist/index.js");
41
+ }
42
+
43
+ const { CopilotClient, RuntimeConnection } = await import(pathToFileURL(modulePath).href);
44
+ const server = process.env.COPILOT_UI_SERVER ?? "localhost:43817";
45
+ const live = process.env.LIVE === "1";
46
+ const twoSessionControl = process.env.COPILOT_D3_CONTROL === "1";
47
+ if (twoSessionControl && !live) throw new Error("COPILOT_D3_CONTROL=1 requires LIVE=1");
48
+ const marker = `NATIVE-ENTWURF-${Date.now()}`;
49
+ const TURN_BUDGET_MS = 30_000;
50
+ /** Cleanup gets its own small bound: a probe must not hang on the way out. */
51
+ const CLEANUP_BOUND_MS = 5_000;
52
+ const seen = new Set();
53
+ const controlSeen = new Set();
54
+ let controlSession;
55
+
56
+ const client = new CopilotClient({
57
+ connection: RuntimeConnection.forUri(server),
58
+ mode: "copilot-cli",
59
+ logLevel: "error",
60
+ });
61
+
62
+ /**
63
+ * Bound ONE awaited SDK call. Promise.race does not cancel the underlying request —
64
+ * nothing in the SDK offers that — so this is a liveness bound on the probe, not a
65
+ * cancellation. That is the honest guarantee: the probe always reaches a verdict or
66
+ * an error within its budget, and never sits forever on a call that will not return.
67
+ */
68
+ async function bounded(label, promise, ms) {
69
+ if (ms <= 0) throw new Error(`${label}: no time left in the ${TURN_BUDGET_MS}ms turn budget`);
70
+ let timer;
71
+ try {
72
+ return await Promise.race([
73
+ promise,
74
+ new Promise((_, reject) => {
75
+ timer = setTimeout(() => reject(new Error(`${label} exceeded its ${ms}ms bound`)), ms);
76
+ }),
77
+ ]);
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+
83
+ // Identifier readers for the two links that DO exist server-side. The SDK nests payloads
84
+ // under `data`, but a top-level carrier is accepted too so a protocol revision that flattens
85
+ // one field does not silently turn a cell into a positional guess — an ABSENT id still fails
86
+ // closed below. There is deliberately no reader for send()'s returned handle: it is not a key
87
+ // any event carries, so joining on it would be inventing a relation.
88
+ const interactionOf = (event) => event?.data?.interactionId ?? event?.interactionId;
89
+ const turnOf = (event) => event?.data?.turnId ?? event?.turnId;
90
+
91
+ function observe(event) {
92
+ seen.add(event.type);
93
+ }
94
+
95
+ function report(cells) {
96
+ console.log("DELIVERY_LEVELS:");
97
+ console.log("harness=copilot-cli");
98
+ console.log(`transport=official-sdk-over-hidden-ui-server server=${server}`);
99
+ for (const [level, value] of Object.entries(cells)) console.log(`${level} ${value}`);
100
+ console.log(
101
+ "notes=probe-only; --ui-server is hidden from CLI help; loopback RPC authentication is not established; " +
102
+ "no managed citizen lane; completion is read through the official session event-history API " +
103
+ "(getEvents/getMessages) — no TUI, file, or database transcript scraping; " +
104
+ "evidence=L4 direct-native on ONE Linux workstation; this stdout is host-local and was not archived as a durable artifact",
105
+ );
106
+ }
107
+
108
+ /** Everything the probe must do on the way out, each bounded. Returns failure lines. */
109
+ async function cleanup(foregroundSessionId) {
110
+ const problems = [];
111
+ const attempt = async (label, thunk) => {
112
+ try {
113
+ return await bounded(label, thunk(), CLEANUP_BOUND_MS);
114
+ } catch (err) {
115
+ problems.push(`${label}: ${String(err)}`);
116
+ return undefined;
117
+ }
118
+ };
119
+
120
+ // RE-CONFIRM A as the foreground session BEFORE anything is torn down: creating the control
121
+ // session moved the TUI's foreground, and leaving the operator's visible window pointing at a
122
+ // probe-owned session would be the probe editing the operator's state. Read first and write
123
+ // only on drift — a probe that unconditionally re-sets the foreground is making a write it
124
+ // cannot tell apart from a no-op, and it would report a drift it caused as clean.
125
+ if (foregroundSessionId) {
126
+ const current = await attempt("getForegroundSessionId()", () => client.getForegroundSessionId());
127
+ if (current !== foregroundSessionId) {
128
+ console.error(`CLEANUP_NOTE foreground was ${current ?? "none"}, not target A — restoring A`);
129
+ await attempt("setForegroundSessionId(A)", () => client.setForegroundSessionId(foregroundSessionId));
130
+ }
131
+ }
132
+
133
+ // Only the session the PROBE created is deleted. A is the operator's live session and is
134
+ // never deleted here.
135
+ if (controlSession?.sessionId) {
136
+ await attempt("deleteSession(B)", () => client.deleteSession(controlSession.sessionId));
137
+ }
138
+
139
+ // Say what stop() actually does, not the flattering version. The probe issues no
140
+ // A.disconnect() of its own, but client.stop() tears down EVERY tracked session — including
141
+ // the resumed A — and that teardown goes out on the wire as session.destroy. What keeps this
142
+ // honest rather than destructive is the order above: A's foreground ownership is re-confirmed
143
+ // first, so the TUI keeps A as its foreground session and the net effect on A is
144
+ // detach-equivalent, not removal. A is not deleted. Do NOT "fix" this by reaching past the
145
+ // SDK for a raw detach — a bespoke wrapper would be a second lifecycle authority, and this
146
+ // probe's whole claim is that it used the official surface.
147
+ //
148
+ // stop() also reports its teardown failures by RETURNING them, so an unread array is a
149
+ // cleanup failure laundered into success.
150
+ const stopErrors = await attempt("client.stop()", () => client.stop());
151
+ if (Array.isArray(stopErrors) && stopErrors.length > 0) {
152
+ problems.push(
153
+ `client.stop() reported ${stopErrors.length} teardown error(s): ${stopErrors.map(String).join("; ")}`,
154
+ );
155
+ }
156
+ return problems;
157
+ }
158
+
159
+ let foregroundSessionId;
160
+ try {
161
+ await client.start();
162
+ const ping = await client.ping("entwurf-native-probe");
163
+ const sessionId = await client.getForegroundSessionId();
164
+ if (!sessionId) throw new Error("ui-server has no foreground session");
165
+ foregroundSessionId = sessionId;
166
+ const metadata = await client.getSessionMetadata(sessionId);
167
+ if (!metadata?.context?.workingDirectory) throw new Error("foreground session metadata has no workingDirectory");
168
+
169
+ console.error(
170
+ `protocol=${ping.protocolVersion ?? "unknown"} sessionId=${sessionId} cwd=${metadata.context.workingDirectory}`,
171
+ );
172
+ if (!live) {
173
+ report({
174
+ "D0 live_identity:": 'pass reason="ping + foreground session id + metadata cwd"',
175
+ "D1 native_continuation:": 'unproven reason="set LIVE=1 for one model turn"',
176
+ "D2 receiver_armed:": 'pass reason="SDK connected to the TUI ui-server"',
177
+ "D3 addressed_enqueue:": "unproven",
178
+ "D4 idle_wake:": "unproven",
179
+ "D5 context_injection:": "unproven",
180
+ "D6 continuity:": "unproven",
181
+ "D7 completion_reply:": "unproven",
182
+ "D8 robustness:": "unproven",
183
+ });
184
+ process.exitCode = 0;
185
+ } else {
186
+ if (twoSessionControl) {
187
+ controlSession = await client.createSession({
188
+ model: "auto",
189
+ workingDirectory: metadata.context.workingDirectory,
190
+ skipCustomInstructions: true,
191
+ onEvent: (event) => controlSeen.add(event.type),
192
+ });
193
+ await client.setForegroundSessionId(sessionId);
194
+ console.error(`controlSessionId=${controlSession.sessionId}`);
195
+ }
196
+
197
+ const session = await client.resumeSession(sessionId, {
198
+ suppressResumeEvent: true,
199
+ onEvent: observe,
200
+ });
201
+ // send() resolves to the SDK's own submission handle. On the bundled CLI 1.0.80 that string
202
+ // appears on NO server event — not as user.message.id, not as its interactionId — so it is
203
+ // logged as a diagnostic and never joined on. The marker body below is the real key.
204
+ const sentHandle = await session.send({
205
+ prompt: `This is a native delivery probe. Reply with exactly ${marker} and nothing else.`,
206
+ mode: "enqueue",
207
+ });
208
+ console.error(`sendHandle=${String(sentHandle)} (diagnostic only — not an event key)`);
209
+
210
+ /** Resolve the marker turn from the event history, or return why it is not resolvable yet. */
211
+ const resolveMarkerTurn = (events) => {
212
+ const matches = events.filter((event) => event.type === "user.message" && event.data?.content?.includes(marker));
213
+ if (matches.length === 0) return { pending: `no user.message carries the unique marker ${marker}` };
214
+ // AMBIGUITY IS A FAILURE, NOT A TIE-BREAK. Two user.messages with this body means the send
215
+ // was duplicated (retry, replay, a human pasting it); picking one would be a guess.
216
+ if (matches.length > 1) {
217
+ return {
218
+ fatal: `${matches.length} user.message events carry the unique marker ${marker} — the delivery is ambiguous`,
219
+ };
220
+ }
221
+ const markerEvent = matches[0];
222
+ const interactionId = interactionOf(markerEvent);
223
+ if (interactionId === undefined) {
224
+ return {
225
+ fatal: "the marker user.message exposes no interactionId — assistant events cannot be attributed to it",
226
+ };
227
+ }
228
+ const starts = events.filter(
229
+ (event) => event.type === "assistant.turn_start" && interactionOf(event) === interactionId,
230
+ );
231
+ if (starts.length === 0) return { pending: `no assistant.turn_start yet on interactionId=${interactionId}` };
232
+ if (starts.length > 1) {
233
+ return {
234
+ fatal: `${starts.length} assistant.turn_start events share interactionId=${interactionId} — the turn is ambiguous`,
235
+ };
236
+ }
237
+ const turnId = turnOf(starts[0]);
238
+ if (turnId === undefined) {
239
+ return {
240
+ fatal: `the assistant.turn_start on interactionId=${interactionId} exposes no turnId — its assistant events cannot be attributed`,
241
+ };
242
+ }
243
+ return { markerEvent, interactionId, turnId };
244
+ };
245
+
246
+ const deadline = Date.now() + TURN_BUDGET_MS;
247
+ const remaining = () => deadline - Date.now();
248
+ let events = [];
249
+ let resolved = { pending: "no getEvents() read completed" };
250
+ while (remaining() > 0) {
251
+ events = await bounded("session.getEvents()", session.getEvents(), remaining());
252
+ for (const event of events) observe(event);
253
+ resolved = resolveMarkerTurn(events);
254
+ // A fatal shape will not become valid by waiting — stop polling and fail closed below.
255
+ if (resolved.fatal) break;
256
+ if (resolved.turnId !== undefined) {
257
+ const inTurn = events.filter((event) => turnOf(event) === resolved.turnId);
258
+ const replied = inTurn.some((event) => event.type === "assistant.message");
259
+ const ended = inTurn.some((event) => event.type === "assistant.turn_end");
260
+ if (replied && ended) break;
261
+ }
262
+ await new Promise((resolve) => setTimeout(resolve, 100));
263
+ }
264
+
265
+ // FAIL CLOSED — an ambiguous or unnameable turn is a broken measurement, and a turn that
266
+ // never resolved inside the budget is an unfinished one. Neither is a partial pass, and
267
+ // neither falls back to "the assistant events that came after ours".
268
+ if (resolved.fatal) throw new Error(resolved.fatal);
269
+ if (resolved.turnId === undefined) {
270
+ throw new Error(
271
+ `the marker turn did not resolve within ${TURN_BUDGET_MS}ms (${resolved.pending}) — the delivery is unattributable`,
272
+ );
273
+ }
274
+ const { markerEvent, turnId } = resolved;
275
+
276
+ // From here on ONLY this turn's events are evidence.
277
+ const turnEvents = events.filter((event) => turnOf(event) === turnId);
278
+ const turnReplies = turnEvents.filter((event) => event.type === "assistant.message");
279
+ const replied = turnReplies.length > 0;
280
+ const completed = replied && turnEvents.some((event) => event.type === "assistant.turn_end");
281
+ const responseContent = turnReplies.at(-1)?.data?.content;
282
+ const responseModel = turnReplies.at(-1)?.data?.model;
283
+ const exact = responseContent === marker;
284
+ // D4 is read off the marker event itself — an older idle wake in the transcript is
285
+ // somebody else's evidence.
286
+ const idleDelivery = markerEvent.data?.delivery === "idle";
287
+ if (!completed) throw new Error("timeout waiting for the marker turn to complete");
288
+
289
+ if (controlSession) {
290
+ // Bounded like every other read. The turn finished inside the budget, so what is left of
291
+ // it is the natural bound; if it landed on the very edge, D3 still gets the cleanup bound
292
+ // rather than an unbounded call.
293
+ const controlBound = Math.max(remaining(), CLEANUP_BOUND_MS);
294
+ const controlEvents = await bounded("controlSession.getEvents()", controlSession.getEvents(), controlBound);
295
+ for (const event of controlEvents) controlSeen.add(event.type);
296
+ }
297
+ // D3's predicate is the SAME sentence the verdict prints: across onEvent AND getEvents,
298
+ // the non-target session received no user.message and no assistant.* event of any kind.
299
+ // The old check enumerated two assistant types, so an isolation break that arrived as
300
+ // assistant.turn_end (or any future assistant.*) would have been reported as a pass.
301
+ const controlTouched = [...controlSeen].some((type) => type === "user.message" || type.startsWith("assistant."));
302
+
303
+ report({
304
+ "D0 live_identity:": 'pass reason="ping + foreground session id + metadata cwd"',
305
+ "D1 native_continuation:": replied
306
+ ? 'pass reason="the turn opened on the marker user.message\'s interactionId replied in the same session"'
307
+ : 'fail reason="marker turn did not continue"',
308
+ "D2 receiver_armed:": 'pass reason="SDK connected to the TUI ui-server"',
309
+ "D3 addressed_enqueue:": twoSessionControl
310
+ ? controlTouched
311
+ ? `fail reason="non-target control session received a user.message or assistant.* event (${[...controlSeen].sort().join(",")})"`
312
+ : 'pass reason="exact target woke; second session received no user.message and no assistant.* event"'
313
+ : 'partial reason="exact session id selected; rerun with COPILOT_D3_CONTROL=1"',
314
+ "D4 idle_wake:": idleDelivery
315
+ ? 'pass reason="the marker user.message itself carries delivery=idle"'
316
+ : 'fail reason="the marker user.message does not carry delivery=idle"',
317
+ "D5 context_injection:": exact
318
+ ? 'pass reason="unique marker entered the attributed user.message and was returned exactly"'
319
+ : 'pass reason="unique marker entered the attributed user.message; reply was non-exact"',
320
+ "D6 continuity:": responseModel
321
+ ? `pass reason="the same session answered this turn on ${responseModel}"`
322
+ : 'fail reason="the attributed turn exposed no reply model"',
323
+ "D7 completion_reply:": seen.has("session.idle")
324
+ ? 'pass reason="attributed assistant.message + assistant.turn_end; session.idle also seen"'
325
+ : 'pass reason="attributed assistant.message + assistant.turn_end; session.idle not observed"',
326
+ "D8 robustness:": 'unproven reason="permission ownership, crash, ordering, auth, and stale endpoint remain"',
327
+ });
328
+ }
329
+ } finally {
330
+ const problems = await cleanup(foregroundSessionId);
331
+ if (problems.length > 0) {
332
+ // A probe that cannot close what it opened has not finished cleanly, and a delivery
333
+ // verdict printed above must not carry a zero exit past that.
334
+ for (const problem of problems) console.error(`CLEANUP_FAILED ${problem}`);
335
+ process.exitCode = 1;
336
+ }
337
+ }
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.66.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.70.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -54,9 +54,48 @@ PSTATE="$XDG_DATA_HOME/entwurf/agy-bridge/permission-state.json"
54
54
  mkdir -p "$(dirname "$GLOBAL")" "$(dirname "$LEGACY")" "$SB/bin"
55
55
 
56
56
  # fake stable bin (on PATH) + fake ss (unused by the deterministic path) — fake agy toggled per case.
57
- printf '#!/usr/bin/env bash\necho fake-entwurf-bridge\n' > "$SB/bin/entwurf-bridge"
57
+ #
58
+ # #81: the doctor now BOOTS the configured command and requires the entwurf MCP tool surface back,
59
+ # because `command -v` succeeding is not evidence agy gets a bridge — a relocated launcher resolves
60
+ # and still exits 127. So the healthy fake speaks the two frames the probe sends; the dead fake
61
+ # below reproduces the observed relocated-shim failure for the negative cell.
62
+ write_mcp_fake() { # $1 = path
63
+ cat > "$1" <<'FAKE'
64
+ #!/usr/bin/env bash
65
+ while IFS= read -r line; do
66
+ case "$line" in
67
+ *'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
68
+ *'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
69
+ esac
70
+ done
71
+ FAKE
72
+ chmod +x "$1"
73
+ }
74
+ write_dead_fake() { # $1 = path — resolves, then dies on exec (the relocated-shim shape)
75
+ cat > "$1" <<'FAKE'
76
+ #!/usr/bin/env bash
77
+ echo "bash: /nonexistent/../global/v11/deadbeef/node_modules/@junghanacs/entwurf/mcp/entwurf-bridge/start.sh: No such file or directory" >&2
78
+ exit 127
79
+ FAKE
80
+ chmod +x "$1"
81
+ }
82
+ write_invocation_sensitive_fake() { # command alone is healthy; configured argv or env makes it fail
83
+ cat > "$1" <<'FAKE'
84
+ #!/usr/bin/env bash
85
+ if [ "${1:-}" = "--fail" ]; then echo configured-arg-failure >&2; exit 23; fi
86
+ if [ "${REVIEW_ENV:-}" = "fail" ]; then echo configured-env-failure >&2; exit 24; fi
87
+ while IFS= read -r line; do
88
+ case "$line" in
89
+ *'"id":1'*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
90
+ *'"id":2'*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"entwurf_v2"},{"name":"entwurf_self"},{"name":"entwurf_peers"},{"name":"entwurf_inbox_read"},{"name":"entwurf_register_native"},{"name":"entwurf_fresh_call"},{"name":"entwurf_resume_call"}]}}' ;;
91
+ esac
92
+ done
93
+ FAKE
94
+ chmod +x "$1"
95
+ }
96
+ write_mcp_fake "$SB/bin/entwurf-bridge"
58
97
  printf '#!/usr/bin/env bash\nexit 0\n' > "$SB/bin/ss"
59
- chmod +x "$SB/bin/entwurf-bridge" "$SB/bin/ss"
98
+ chmod +x "$SB/bin/ss"
60
99
  export PATH="$SB/bin:$PATH"
61
100
  export AGY_MCP_CONFIG="$GLOBAL"
62
101
  export AGY_MCP_CONFIG_ALT="$LEGACY"
@@ -96,6 +135,41 @@ want "doctor(no-agy): SKIP is not disguised as a pass" "! printf '%s' \"\$DOC_OU
96
135
  want "doctor(installed): state-evidence confirms the managed config still configured" \
97
136
  "printf '%s' \"\$DOC_OUT\" | grep -q 'still configures entwurf-bridge'"
98
137
 
138
+ # ── B-boot: #81 A/B — a configured command that RESOLVES but does not serve MCP ──
139
+ # The static tier used to print "(resolvable)" and stay green here, which is exactly the state in
140
+ # which agy would have had no entwurf tool at all. Sandbox PATH only — the operator's launcher is
141
+ # never touched. `set -e` is fenced around the drive alone so the assertions still run.
142
+ write_dead_fake "$SB/bin/entwurf-bridge"
143
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
144
+ want "[QK:AGY-DOCTOR-BOOT-NEGATIVE] doctor(cmd resolves, does not boot): FAILS instead of blessing a resolvable name" "[ '$DOC_RC' -ne 0 ]"
145
+ want "doctor(cmd resolves, does not boot): says it does NOT serve MCP" \
146
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'does NOT serve MCP with its configured args/env'"
147
+ want "doctor(cmd resolves, does not boot): carries the launcher's own stderr" \
148
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'No such file or directory'"
149
+ want "doctor(cmd resolves, does not boot): does not offer to clobber a foreign launcher" \
150
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'repair/remove it yourself'"
151
+ write_mcp_fake "$SB/bin/entwurf-bridge"
152
+ DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
153
+ want "doctor(after repair): the SAME unchanged doctor goes green once the command boots" "[ '$DOC_RC' -eq 0 ]"
154
+ want "doctor(after repair): green names the exact invocation BOOT evidence" \
155
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'exact configured invocation boots the entwurf MCP surface'"
156
+
157
+ # The configured invocation is command + args + env, not command alone. Replant each review
158
+ # shape independently: this fake boots with defaults, but agy's configured argv/env make it fail.
159
+ write_invocation_sensitive_fake "$SB/bin/invocation-sensitive"
160
+ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d["mcpServers"]["entwurf-bridge"]={"command":"invocation-sensitive","args":["--fail"],"env":{"REVIEW_ENV":"ok"}}; json.dump(d,open(sys.argv[1],"w"))' "$GLOBAL"
161
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
162
+ want "[QK:AGY-DOCTOR-PROBES-ARGS] doctor(command healthy, configured argv red): FAILS exact invocation" "[ '$DOC_RC' -ne 0 ]"
163
+ want "doctor(configured argv red): carries the argv-sensitive stderr" \
164
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'configured-arg-failure'"
165
+ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d["mcpServers"]["entwurf-bridge"]={"command":"invocation-sensitive","args":[],"env":{"REVIEW_ENV":"fail"}}; json.dump(d,open(sys.argv[1],"w"))' "$GLOBAL"
166
+ set +e; DOC_OUT="$(bash "$BRIDGE" doctor 2>&1)"; DOC_RC=$?; set -e
167
+ want "[QK:AGY-DOCTOR-PROBES-ENV] doctor(command healthy, configured env red): FAILS exact invocation" "[ '$DOC_RC' -ne 0 ]"
168
+ want "doctor(configured env red): carries the env-sensitive stderr" \
169
+ "printf '%s' \"\$DOC_OUT\" | grep -q 'configured-env-failure'"
170
+ # Restore the managed shape for the remaining lifecycle cells.
171
+ python3 "$REPO_DIR/scripts/agy-bridge-config.py" install "$GLOBAL" entwurf-bridge "$STATE" >/dev/null
172
+
99
173
  # ── C: doctor with a fake agy present → live is CONSISTENT (honest, not overclaimed) ──
100
174
  fake_agy on
101
175
  DOC_OUT="$(bash "$BRIDGE" doctor)"; DOC_RC=$?
@@ -63,7 +63,7 @@ const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
63
63
  const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
64
64
  const SOCKET_SUFFIX = ".sock";
65
65
 
66
- const GPT_TARGET = process.env.ENTWURF_CHAIN_GPT_TARGET?.trim() || "openai-codex/gpt-5.4";
66
+ const GPT_TARGET = process.env.ENTWURF_CHAIN_GPT_TARGET?.trim() || "openai-codex/gpt-5.6-luna";
67
67
  const ACP_TARGET = process.env.ENTWURF_CHAIN_ACP_TARGET?.trim() || "entwurf/claude-sonnet-5";
68
68
  const BOOT_TIMEOUT_MS = 45_000;
69
69
  const CLAUDE_TURN_TIMEOUT_MS = Number(process.env.ENTWURF_CHAIN_CLAUDE_TIMEOUT_MS) || 240_000;
@@ -36,7 +36,7 @@
36
36
  *
37
37
  * LIVE-only (spawns a real pi, opens a real socket) — kept OUT of `pnpm check`; honest skip when
38
38
  * LIVE!=1 (a release-gate that hard-fails without auth/model is unrunnable unattended). Model:
39
- * ENTWURF_LIVE_TARGET = "<provider>/<model>" (default "openai-codex/gpt-5.4")
39
+ * ENTWURF_LIVE_TARGET = "<provider>/<model>" (default "openai-codex/gpt-5.6-luna")
40
40
  * (or split: ENTWURF_LIVE_PROVIDER + ENTWURF_LIVE_MODEL)
41
41
  * LIVE=1 ./run.sh smoke-entwurf-v2-matrix-live
42
42
  *
@@ -101,7 +101,7 @@ function resolveTarget(): { provider: string; model: string } {
101
101
  }
102
102
  return {
103
103
  provider: process.env.ENTWURF_LIVE_PROVIDER?.trim() || "openai-codex",
104
- model: process.env.ENTWURF_LIVE_MODEL?.trim() || "gpt-5.4",
104
+ model: process.env.ENTWURF_LIVE_MODEL?.trim() || "gpt-5.6-luna",
105
105
  };
106
106
  }
107
107
 
@@ -43,7 +43,7 @@ import { skipLive } from "./lib/live-skip.ts";
43
43
  const LABEL = "smoke-mux-fresh-call-live";
44
44
  const CALLBACK_WAIT_MS = 180_000;
45
45
  const LIVE_MODEL = {
46
- pi: "openai-codex/gpt-5.6-terra",
46
+ pi: "openai-codex/gpt-5.6-luna",
47
47
  "claude-code": "claude-sonnet-5",
48
48
  } as const;
49
49
 
@@ -108,7 +108,7 @@ const BRIDGE_LAUNCHER = path.join(REPO, "mcp", "entwurf-bridge", "start.sh");
108
108
 
109
109
  /** The two pi shapes. They differ in exactly one thing that matters here — whether the record
110
110
  * carries `provider=entwurf`, which is what makes the resume argv re-inject the bridge (#29). */
111
- const PI_NATIVE_MODEL = "openai-codex/gpt-5.6-terra";
111
+ const PI_NATIVE_MODEL = "openai-codex/gpt-5.6-luna";
112
112
  const PI_ACP_MODEL = "entwurf/claude-sonnet-5";
113
113
  const CLAUDE_MODEL = "claude-sonnet-5";
114
114