@junghanacs/entwurf 0.12.10 → 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 (86) hide show
  1. package/AGENTS.md +6 -3
  2. package/BASELINE.md +56 -163
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +117 -261
  6. package/README.md +74 -399
  7. package/VERIFY.md +59 -85
  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 +155 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  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 +66 -7
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  21. package/mcp/entwurf-bridge/src/index.ts +21 -48
  22. package/package.json +14 -9
  23. package/pi/settings.reference.json +1 -1
  24. package/pi-extensions/acp-provider.ts +20 -10
  25. package/pi-extensions/entwurf-control.ts +20 -49
  26. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  27. package/pi-extensions/lib/acp/augment.ts +99 -2
  28. package/pi-extensions/lib/acp/backend-adapter.ts +190 -14
  29. package/pi-extensions/lib/acp/backend.ts +310 -37
  30. package/pi-extensions/lib/acp/config.ts +19 -5
  31. package/pi-extensions/lib/acp/engraving.ts +46 -2
  32. package/pi-extensions/lib/acp/event-mapper.ts +26 -16
  33. package/pi-extensions/lib/acp/models.ts +69 -7
  34. package/pi-extensions/lib/acp/overlay.ts +234 -5
  35. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  36. package/prompts/engraving.md +13 -5
  37. package/run.sh +386 -119
  38. package/scripts/check-acp-carrier-augment.ts +246 -10
  39. package/scripts/check-acp-cortex.ts +668 -0
  40. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  41. package/scripts/check-acp-provider-surface.ts +50 -6
  42. package/scripts/check-acp-sdk-surface.ts +60 -21
  43. package/scripts/check-acp-session-reuse.ts +64 -1
  44. package/scripts/check-acp-stop-reason.ts +342 -0
  45. package/scripts/check-entwurf-v2-surface.ts +41 -0
  46. package/scripts/check-gate-qualification.ts +7 -2
  47. package/scripts/check-probe-cli-shim.ts +879 -0
  48. package/scripts/check-probe-ordering.ts +2482 -0
  49. package/scripts/check-release-gate-outcomes.ts +332 -0
  50. package/scripts/check-shell-quote.ts +4 -4
  51. package/scripts/fixtures/probe-cli-shim +20 -0
  52. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  53. package/scripts/lib/live-skip.ts +33 -0
  54. package/scripts/lib/probe-acp-turn.ts +228 -0
  55. package/scripts/lib/probe-cli-shim.ts +464 -0
  56. package/scripts/lib/probe-cli-target.ts +165 -0
  57. package/scripts/lib/probe-event-log.ts +383 -0
  58. package/scripts/lib/probe-verdict.ts +1213 -0
  59. package/scripts/lib/step-outcome.sh +88 -0
  60. package/scripts/mutants/acp-augment.json +106 -0
  61. package/scripts/mutants/acp-cortex.json +196 -0
  62. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  63. package/scripts/mutants/acp-stop-reason.json +80 -0
  64. package/scripts/mutants/probe-ordering.json +1054 -0
  65. package/scripts/mutants/release-gate.json +105 -0
  66. package/scripts/mutants/v2-surface.json +26 -15
  67. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  68. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  69. package/scripts/smoke-acp-cortex-live.ts +386 -0
  70. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  71. package/scripts/smoke-acp-mcp-live.ts +2 -2
  72. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  73. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  74. package/scripts/smoke-acp-overlay-live.ts +2 -2
  75. package/scripts/smoke-acp-provider-live.ts +2 -2
  76. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  77. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  78. package/scripts/smoke-acp-skill-live.ts +2 -2
  79. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  80. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  81. package/scripts/smoke-agy-native-push-live.ts +4 -3
  82. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  83. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  84. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  85. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
  86. package/scripts/smoke-meta-install-state.sh +4 -0
@@ -0,0 +1,332 @@
1
+ // Deterministic gate for the release-gate STEP OUTCOME protocol (P1).
2
+ //
3
+ // THE DEFECT THIS OWNS. The aggregate release gate documented — in run.sh's own
4
+ // usage, README, and VERIFY — that a cut needs `LIVE=1` and `SKIP=0`. Nothing
5
+ // enforced it. Exit authority read the FAIL counter alone, so
6
+ // `./run.sh release-gate <dir>` returned 0 while printing 14 SKIPs and the words
7
+ // "all green". And a step that WAS invoked could decline a prerequisite and exit
8
+ // 0 — Cortex without `ENTWURF_ACP_CORTEX_CONNECTION` is the measured case — which
9
+ // the aggregate counted as PASS. Both holes are the same shape: a skip that
10
+ // cannot be told apart from an acceptance, which means a release summary cannot
11
+ // prove the calls it claims.
12
+ //
13
+ // WHAT THIS GATE PINS, in the order the cells run (the order is load-bearing:
14
+ // each mutant must die on ITS claim, so a cell that would fire first on another
15
+ // cell's mutation is deliberately kept narrow):
16
+ // 1. the protocol is ONE number, agreed across the shell and TS halves;
17
+ // 2. the classifier never rounds a skip up to a pass;
18
+ // 3. `--cut` refuses a MUST skip while a bare diagnostic run does not, AND the
19
+ // refusal names its cause — a step that RAN AND BROKE is a different fact
20
+ // from one that NEVER RAN, and the counters are never fudged to carry it;
21
+ // 4. no LIVE smoke still carries the pre-P1 exit-0 skip shape;
22
+ // 5. a real smoke invoked with LIVE unset propagates the protocol code out
23
+ // through run_ts (the "direct LIVE!=1" case);
24
+ // 6. a run.sh wrapper that declines its own prerequisite does the same (the
25
+ // "internal prerequisite" case, Cortex being the one that was measured).
26
+ // 7. every LIVE smoke is either wired into the aggregate or excluded for a
27
+ // reason the docs actually state — the protocol cannot vouch for a step the
28
+ // gate never lists.
29
+ //
30
+ // Cells 5-6 SPAWN the real subcommands rather than reasoning about them: the
31
+ // whole defect was an assumption about what a step would do, so an assumption is
32
+ // exactly what this gate must not make. They are cheap — each smoke declines
33
+ // before it does any work.
34
+ //
35
+ // Pure + subprocess, no network/model — IN pnpm check.
36
+
37
+ import { strict as assert } from "node:assert";
38
+ import { execFileSync } from "node:child_process";
39
+ import { globSync, readFileSync } from "node:fs";
40
+ import { basename, join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { LIVE_SKIP_EXIT, LIVE_SKIP_MARKER } from "./lib/live-skip.ts";
43
+
44
+ const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
45
+ const SHELL_LIB = "scripts/lib/step-outcome.sh";
46
+
47
+ /** Run a snippet with the shell half sourced; returns trimmed stdout. */
48
+ function inShell(snippet: string): string {
49
+ return execFileSync("bash", ["-c", `. "${SHELL_LIB}"; ${snippet}`], {
50
+ cwd: REPO_DIR,
51
+ encoding: "utf8",
52
+ timeout: 20_000,
53
+ }).trim();
54
+ }
55
+
56
+ /** Invoke a real run.sh subcommand; returns its exit code + combined output. */
57
+ function runSubcommand(sub: string, env: Record<string, string | undefined>): { code: number; output: string } {
58
+ const childEnv: Record<string, string> = {};
59
+ for (const [k, v] of Object.entries({ ...process.env, ...env })) {
60
+ if (v !== undefined) childEnv[k] = v;
61
+ }
62
+ try {
63
+ const out = execFileSync("bash", ["run.sh", sub], {
64
+ cwd: REPO_DIR,
65
+ encoding: "utf8",
66
+ timeout: 120_000,
67
+ env: childEnv,
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ });
70
+ return { code: 0, output: out };
71
+ } catch (err) {
72
+ const e = err as { status?: number | null; stdout?: string; stderr?: string };
73
+ return { code: e.status ?? -1, output: `${e.stdout ?? ""}${e.stderr ?? ""}` };
74
+ }
75
+ }
76
+
77
+ // ===========================================================================
78
+ // 1) ONE protocol, two languages. A drifted constant does not degrade
79
+ // gracefully — it silently reclassifies every skip in the aggregate.
80
+ // ===========================================================================
81
+ {
82
+ const shellValue = inShell('echo "$ENTWURF_STEP_SKIP_EXIT"');
83
+ assert.equal(
84
+ shellValue,
85
+ String(LIVE_SKIP_EXIT),
86
+ `[QK:SKIP-EXIT-ONE-PROTOCOL] ${SHELL_LIB} and scripts/lib/live-skip.ts must name the SAME skip exit code. ` +
87
+ "The smokes exit with the TS constant and the aggregate classifies with the shell one, so a drift between " +
88
+ "them turns every honest skip into a FAIL (or, the other way, into a PASS) with nothing in the summary " +
89
+ `saying so. shell=${shellValue} ts=${LIVE_SKIP_EXIT}`,
90
+ );
91
+ // The code must also stay clear of the ranges that already mean something
92
+ // else, or a dependency's unrelated verdict reads as a skip.
93
+ assert.ok(
94
+ LIVE_SKIP_EXIT > 4 && LIVE_SKIP_EXIT < 126,
95
+ "the skip code must avoid the per-tool contract band (0..4) and the shell's signal band (126+)",
96
+ );
97
+ }
98
+
99
+ // ===========================================================================
100
+ // 2) The classifier. A skip is its own outcome, never rounded up or down.
101
+ // ===========================================================================
102
+ {
103
+ const table: Array<[string, string]> = [
104
+ ["0", "PASS"],
105
+ [String(LIVE_SKIP_EXIT), "SKIP"],
106
+ ["1", "FAIL"],
107
+ ["2", "FAIL"],
108
+ ["127", "FAIL"],
109
+ ["137", "FAIL"],
110
+ ];
111
+ for (const [code, expected] of table) {
112
+ const got = inShell(`entwurf_step_outcome ${code}`);
113
+ assert.equal(
114
+ got,
115
+ expected,
116
+ `[QK:STEP-OUTCOME-SKIP-NOT-PASS] exit ${code} must classify as ${expected}, got ${got}. A skip rounded up to ` +
117
+ "PASS is exactly the pre-P1 defect: the aggregate then reports acceptance for a step that told it, in the " +
118
+ "only channel it has, that it never ran. A skip rounded down to FAIL is the mirror error and makes the " +
119
+ "unattended diagnostic unusable.",
120
+ );
121
+ }
122
+ }
123
+
124
+ // ===========================================================================
125
+ // 3) Cut authority. `--cut` is the executable half of "a CUT needs SKIP=0";
126
+ // without it the diagnostic must stay green so an unattended run is usable.
127
+ // ===========================================================================
128
+ {
129
+ const releasable = (failc: number, skipc: number, cut: number): boolean =>
130
+ inShell(`if entwurf_release_releasable ${failc} ${skipc} ${cut}; then echo YES; else echo NO; fi`) === "YES";
131
+
132
+ assert.ok(releasable(0, 0, 1), "a cut with no failures and no skips is releasable");
133
+ assert.ok(!releasable(1, 0, 1), "a failure blocks a cut");
134
+ assert.ok(!releasable(1, 0, 0), "a failure blocks the diagnostic too — FAIL was always blocking");
135
+ assert.ok(
136
+ releasable(0, 3, 0),
137
+ "a DIAGNOSTIC run with skips stays exit 0 — an unattended `./run.sh release-gate` must remain runnable, and " +
138
+ "turning it red was never the ask",
139
+ );
140
+ assert.ok(
141
+ !releasable(0, 3, 1),
142
+ "[QK:CUT-REFUSES-SKIP] `--cut` must refuse a MUST SKIP. This is the whole point: the release procedure said " +
143
+ '"a CUT needs LIVE=1, SKIP=0" in prose while the code returned 0 with 14 skips, so a summary could be quoted ' +
144
+ "as acceptance for calls that never happened. It also removes the need for a separate LIVE assertion — with " +
145
+ "LIVE unset every LIVE-gated step skips, and the skip count is what blocks.",
146
+ );
147
+
148
+ // …and the refusal must SAY WHICH of the two it is. A blocked cut caused by a
149
+ // broken call and one caused by an absent prerequisite need different actions
150
+ // from whoever reads the record, and the counters must not be fudged to carry
151
+ // that (a synthesized FAIL=1 for a policy block erases the distinction).
152
+ const verdict = (failc: number, skipc: number, cut: number): string =>
153
+ inShell(`entwurf_release_verdict ${failc} ${skipc} ${cut}`);
154
+
155
+ assert.equal(verdict(0, 0, 1), "cut: OK", "a clean cut says so in one token");
156
+ assert.equal(
157
+ verdict(0, 3, 0),
158
+ "cut: n/a (diagnostic, 3 SKIP)",
159
+ "a diagnostic run names its skips without claiming a cut",
160
+ );
161
+ assert.equal(verdict(0, 0, 0), "cut: n/a (diagnostic)", "a clean diagnostic run still does not claim a cut");
162
+ assert.equal(
163
+ verdict(1, 0, 1),
164
+ "cut: BLOCKED (MUST FAIL)",
165
+ "a step that RAN AND BROKE must be named as a failure — that is a defect to fix",
166
+ );
167
+ assert.equal(
168
+ verdict(0, 3, 1),
169
+ "cut: BLOCKED (MUST SKIP)",
170
+ "[QK:CUT-VERDICT-NAMES-CAUSE] a cut blocked ONLY by skips must say so in its own token, distinct from a failure " +
171
+ "block. An operator (and the P5 release record) reads two different actions out of these: a MUST FAIL is a " +
172
+ "broken call to fix, a MUST SKIP is a prerequisite to supply. Collapsing them into one string — or worse, " +
173
+ "synthesizing FAIL=1 for the policy block — throws away the exact distinction this protocol was built to make.",
174
+ );
175
+ assert.equal(
176
+ verdict(2, 5, 1),
177
+ "cut: BLOCKED (MUST FAIL)",
178
+ "when both are present the FAILURE is the headline — a broken call outranks a missing prerequisite",
179
+ );
180
+ }
181
+
182
+ // ===========================================================================
183
+ // 4) No LIVE smoke still carries the pre-P1 skip shape (static, all of them).
184
+ // A future smoke that hand-rolls `exit 0` on a skip re-opens the hole for
185
+ // one lane only, which is precisely how this survived so long.
186
+ // ===========================================================================
187
+ {
188
+ const smokes = globSync("scripts/smoke-*live*.ts", { cwd: REPO_DIR }).sort();
189
+ assert.ok(smokes.length >= 15, `expected the LIVE smoke family, found ${smokes.length}`);
190
+ let liveGated = 0;
191
+ for (const rel of smokes) {
192
+ const src = readFileSync(join(REPO_DIR, rel), "utf8");
193
+ if (!src.includes("process.env.LIVE")) continue; // gated in run.sh instead (cell 6 owns that surface)
194
+ liveGated++;
195
+ // ONE assertion, three ways to fail it: no protocol import, or either of
196
+ // the two pre-P1 shapes (exit 0 / bare return) still inside the LIVE gate.
197
+ // Kept as one so the claim owns every way a smoke can go back to being
198
+ // indistinguishable from success — a split would let a mutation die on an
199
+ // unclaimed sibling assertion instead of here.
200
+ const importsProtocol = src.includes('from "./lib/live-skip.ts"');
201
+ const exitsZero = /LIVE !== "1"[\s\S]{0,400}?process\.exit\(0\)/.test(src);
202
+ const bareReturns = /LIVE !== "1"[\s\S]{0,400}?\n\t+return;/.test(src);
203
+ assert.ok(
204
+ importsProtocol && !exitsZero && !bareReturns,
205
+ `[QK:NO-SMOKE-SKIPS-WITH-ZERO] ${basename(rel)} gates on LIVE but does not decline through skipLive ` +
206
+ `(importsProtocol=${importsProtocol} exitsZero=${exitsZero} bareReturns=${bareReturns}). Every LIVE smoke ` +
207
+ "must take the one protocol exit — a hand-rolled `process.exit(0)` or bare `return` is indistinguishable " +
208
+ "from success, which is exactly what let the aggregate count a never-run step as PASS.",
209
+ );
210
+ }
211
+ assert.ok(liveGated >= 15, `expected most LIVE smokes to gate on LIVE, got ${liveGated}`);
212
+ }
213
+
214
+ // ===========================================================================
215
+ // 5) REAL propagation — the direct `LIVE!=1` case, end to end through run_ts.
216
+ // Static source pins cannot see a transport that swallows the code.
217
+ // ===========================================================================
218
+ {
219
+ const { code, output } = runSubcommand("smoke-acp-raw-turn-live", { LIVE: undefined });
220
+ assert.equal(
221
+ code,
222
+ LIVE_SKIP_EXIT,
223
+ `[QK:LIVE-SKIP-IS-PROTOCOL-EXIT] a LIVE smoke invoked with LIVE unset must leave the protocol's SKIP code on ` +
224
+ `the process, all the way out through run_ts — got exit ${code}. Exit 0 here is the original defect: the ` +
225
+ `aggregate cannot tell "I declined" from "I passed", so it reports acceptance. Output: ` +
226
+ `${JSON.stringify(output.slice(-300))}`,
227
+ );
228
+ assert.ok(
229
+ output.includes(LIVE_SKIP_MARKER),
230
+ `the skip must also be readable by a human in the log — ${LIVE_SKIP_MARKER} names the missing prerequisite so an ` +
231
+ "operator who hits a red --cut run knows what to supply. Output: " +
232
+ JSON.stringify(output.slice(-300)),
233
+ );
234
+ }
235
+
236
+ // ===========================================================================
237
+ // 6) REAL propagation — a run.sh WRAPPER declining its own prerequisite. This
238
+ // is the second skip surface: some smokes never reach their .ts file at all.
239
+ // ===========================================================================
240
+ {
241
+ const viaWrapper = runSubcommand("smoke-acp-cortex-live", { LIVE: undefined });
242
+ assert.equal(
243
+ viaWrapper.code,
244
+ LIVE_SKIP_EXIT,
245
+ `[QK:WRAPPER-SKIP-IS-PROTOCOL-EXIT] a run.sh smoke WRAPPER that declines a prerequisite must return the protocol ` +
246
+ `SKIP code, not 0 — got exit ${viaWrapper.code}. The wrapper is a skip surface of its own (cortex, matrix, ` +
247
+ "spawn-live and spawn-resume all decline before their .ts is ever reached), so fixing only the TypeScript " +
248
+ `half would leave the aggregate counting those as PASS. Output: ${JSON.stringify(viaWrapper.output.slice(-300))}`,
249
+ );
250
+
251
+ // The measured Cortex cell: LIVE IS set, but the connection the adapter needs
252
+ // is not. Which branch declines (the wrapper's `cortex` PATH check or the
253
+ // smoke's own connection check) depends on the host; the OUTCOME must not.
254
+ const missingPrereq = runSubcommand("smoke-acp-cortex-live", {
255
+ LIVE: "1",
256
+ ENTWURF_ACP_CORTEX_CONNECTION: undefined,
257
+ });
258
+ assert.equal(
259
+ missingPrereq.code,
260
+ LIVE_SKIP_EXIT,
261
+ "LIVE=1 with no ENTWURF_ACP_CORTEX_CONNECTION must be a SKIP, not a PASS — this is the exact cell that made a " +
262
+ "cortex-less host look like cortex acceptance. Output: " +
263
+ JSON.stringify(missingPrereq.output.slice(-300)),
264
+ );
265
+ }
266
+
267
+ // ===========================================================================
268
+ // 7) NO SILENT AGGREGATE OMISSION. The protocol tells the truth about the steps
269
+ // the gate RUNS; it says nothing about steps the gate never lists. Three LIVE
270
+ // smokes (cortex, spawn-live, claude-native-resume) sat outside the aggregate
271
+ // with no stated reason until 2026-07-31, so a green cut was silent about the
272
+ // second backend, the spawn substrate, and native resume.
273
+ //
274
+ // So: every LIVE smoke is either WIRED into release_gate or EXCLUDED for a
275
+ // reason an operator can read in the docs. The exclusion half is checked
276
+ // against the doc text, not against a list in this file — an exclusion that
277
+ // only this gate believes in is how the omission would come back.
278
+ // ===========================================================================
279
+ {
280
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
281
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
282
+ assert.ok(gateBody.length > 1000, "located the release_gate body");
283
+
284
+ // name → the sentence in the docs that carries its exclusion, and where.
285
+ const DOCUMENTED_EXCLUSIONS: Record<string, [file: string, sentence: string]> = {
286
+ "smoke-acp-long-turn-live": ["VERIFY.md", "on-demand, not part of `release-gate`"],
287
+ "smoke-agy-native-push-live": ["VERIFY.md", "Aggregate release-gate does not own an agy conversation id"],
288
+ "smoke-acp-ordering-probe-live": ["docs/acp-backend-rail.md", "opt-in paired observation"],
289
+ // Cortex needs an external Snowflake connection the HOST owns, so an aggregate
290
+ // that required it would block every cut taken without that account. Excluded —
291
+ // NOT waived: its direct call stays required for a Cortex-rail cut, and running
292
+ // it without the connection still reports protocol SKIP rather than a pass.
293
+ "smoke-acp-cortex-live": ["VERIFY.md", "The 0.13.1 aggregate does not re-certify Cortex"],
294
+ };
295
+
296
+ const allLive = globSync("scripts/smoke-*live*.{ts,sh}", { cwd: REPO_DIR })
297
+ .map((p) => basename(p).replace(/\.(ts|sh)$/, ""))
298
+ .sort();
299
+ assert.ok(allLive.length >= 18, `expected the full LIVE smoke family, found ${allLive.length}`);
300
+
301
+ for (const name of allLive) {
302
+ const wired = gateBody.includes(`"$self" ${name}\n`) || gateBody.includes(`"$self" ${name} `);
303
+ const excused = DOCUMENTED_EXCLUSIONS[name];
304
+ if (wired) {
305
+ assert.ok(!excused, `${name} is both wired and excused — pick one`);
306
+ continue;
307
+ }
308
+ assert.ok(
309
+ excused,
310
+ `[QK:NO-SILENT-AGGREGATE-OMISSION] ${name} is neither wired into release_gate nor excluded in the docs. ` +
311
+ "A LIVE smoke that exists but is never listed makes a green cut silent about the axis it covers — exactly " +
312
+ "how cortex (the second shipped backend), spawn-live and claude-native-resume went unrun for releases. " +
313
+ "Wire it, or state the exclusion where an operator reads it.",
314
+ );
315
+ const [file, sentence] = excused as [string, string];
316
+ assert.ok(
317
+ readFileSync(join(REPO_DIR, file), "utf8").includes(sentence),
318
+ `${name} claims a documented exclusion, but ${file} no longer says "${sentence}" — an exclusion only this ` +
319
+ "gate believes in is not documented",
320
+ );
321
+ }
322
+ }
323
+
324
+ console.log(
325
+ "[check-release-gate-outcomes] ok — STEP OUTCOME protocol: one skip exit code shared by the shell and TS halves " +
326
+ `(${LIVE_SKIP_EXIT}, clear of the per-tool 0..4 and shell 126+ bands), classifier maps 0→PASS / skip→SKIP / ` +
327
+ "everything else→FAIL (never rounding a skip up), `--cut` refuses a MUST SKIP while a bare diagnostic run stays " +
328
+ "exit 0, no LIVE smoke still carries the pre-P1 exit-0 skip shape, and both real skip surfaces were INVOKED and " +
329
+ "observed to propagate the code: a smoke with LIVE unset (through run_ts, with its operator-readable marker) and " +
330
+ "a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
331
+ "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry",
332
+ );
@@ -5,9 +5,10 @@
5
5
  * SSH command strings with `JSON.stringify`, which is NOT a shell-safe escape.
6
6
  * Backticks and `$(...)` inside a user prompt got executed by the remote shell
7
7
  * before pi ever saw them. The fix introduced `shellQuote()` (POSIX `'...'`
8
- * with `'\''` escape) in two places:
9
- * - pi-extensions/lib/entwurf-core.ts (the one remaining site; the entwurf.ts /
8
+ * with `'\''` escape) in:
9
+ * - pi-extensions/lib/entwurf-core.ts (the one remaining core site; the entwurf.ts /
10
10
  * entwurf-async.ts siblings this once cross-checked were removed in 0.12)
11
+ * - pi-extensions/lib/acp/backend-adapter.ts (cortex CORTEX_ACP_COMMAND override arg quoting)
11
12
  *
12
13
  * This script enforces two invariants:
13
14
  * 1. `shellQuote()` source matches the reference implementation byte-for-byte.
@@ -15,7 +16,6 @@
15
16
  * payload classes that caused the original incident — backtick command
16
17
  * substitution, `$(...)` command substitution, `$VAR` expansion, embedded
17
18
  * single quotes, whitespace, empty string, non-ASCII text.
18
- *
19
19
  * No process spawn, no SSH, no API. Pure-string verification — safe in `pnpm
20
20
  * check` chain.
21
21
  */
@@ -38,7 +38,7 @@ const REFERENCE_BODY = `function shellQuote(value: string): string {
38
38
  \treturn \`'\${value.replace(/'/g, \`'\\\\''\`)}\`;
39
39
  }`;
40
40
 
41
- const SOURCE_SITES = ["pi-extensions/lib/entwurf-core.ts"] as const;
41
+ const SOURCE_SITES = ["pi-extensions/lib/entwurf-core.ts", "pi-extensions/lib/acp/backend-adapter.ts"] as const;
42
42
 
43
43
  // Match the function block from `function shellQuote` up to the closing brace.
44
44
  // Tab indentation is required (matches the rest of the repo).
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // §11-7-c B-name-snapshot producer — the launcher the SDK actually execs.
3
+ //
4
+ // EXTENSIONLESS ON PURPOSE, and that is a contract, not a style choice. The SDK
5
+ // picks its launch branch off a pure suffix test: a path ending in one of
6
+ // SDK_SCRIPT_SUFFIXES (.js/.mjs/.tsx/.ts/.jsx) is run as `node|bun <path>`, and
7
+ // anything else is spawned DIRECTLY. The probe asserts the native branch instead
8
+ // of reproducing the interpreter choice (probe-cli-target.ts), so the instrument
9
+ // itself has to sit on that branch too — hence no extension, a shebang, and the
10
+ // executable bit tracked in git. Renaming this file to *.js would silently move
11
+ // the shim onto the other launch branch.
12
+ //
13
+ // It stays two lines because an extensionless file is outside `tsc --noEmit` and
14
+ // outside biome: every behaviour worth typechecking, linting and mutating lives
15
+ // in ../lib/probe-cli-shim.ts, which check-probe-cli-shim drives as a real
16
+ // process. Node >=24 (package.json engines) with "type": "module" resolves this
17
+ // file as ESM and strips types from the imported .ts SSOT.
18
+ import { runProbeCliShim } from "../lib/probe-cli-shim.ts";
19
+
20
+ runProbeCliShim();
@@ -1,4 +1,5 @@
1
- // Tiny stdio MCP server used ONLY by smoke-acp-mcp-live (S2g LIVE 1).
1
+ // Tiny stdio MCP server used by smoke-acp-mcp-live (S2g LIVE 1) and by the
2
+ // §11-7 ordering probe (smoke-acp-ordering-probe-live).
2
3
  //
3
4
  // It exposes ONE tool — `probe_nonce` — that returns a per-run secret nonce
4
5
  // supplied via the PROBE_NONCE env var. The smoke registers this server in a
@@ -9,25 +10,180 @@
9
10
  // is broken (the pre-S2g hardcoded `mcpServers:[]`), the tool never exists and
10
11
  // the nonce cannot appear.
11
12
  //
13
+ // §11-7 ordering-probe mode — active ONLY when PROBE_MCP_EVENT_LOG is set
14
+ // (smoke-acp-mcp-live never sets it, so its behavior is unchanged):
15
+ // - PROBE_MCP_STARTUP_DELAY_MS delays the transport connect — the controlled
16
+ // input the probe injects instead of trying to observe a window that has no
17
+ // observable signal on this server's ACP surface;
18
+ // - the tool takes a REQUIRED `probeRunId` argument, the cross-layer join key
19
+ // (§11-7: the ACP toolCallId and the MCP JSON-RPC id share no namespace, so
20
+ // correlation must ride an argument we control);
21
+ // - every wire step appends to the shared NDJSON event log, including THE
22
+ // wire-availability marker `tools_list_response_forwarded`, emitted only
23
+ // after the ENTIRE tools/list response frame was write()-n to downstream
24
+ // stdio AND the write callback fired. The SDK's own send() resolves on
25
+ // buffered-write/drain — NOT the write callback — which is exactly the
26
+ // false-green §11-7 forbids, so probe mode swaps in a callback-forwarding
27
+ // transport instead of trusting send().
28
+ //
12
29
  // Lives under the repo's scripts/fixtures/ so its `@modelcontextprotocol/sdk`
13
30
  // import resolves against the repo node_modules even though the server is spawned
14
31
  // (by the claude ACP child) with an arbitrary scratch cwd. Deliberately minimal:
15
- // no identity / env coupling beyond PROBE_NONCE, so a failure isolates to "did
16
- // the operator mcpServers reach newSession" — not to entwurf-bridge wiring.
32
+ // no identity / env coupling beyond the PROBE_* vars, so a failure isolates to
33
+ // "did the operator mcpServers reach newSession" — not to entwurf-bridge wiring.
17
34
 
18
35
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
36
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
37
+ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
38
+ import { z } from "zod";
39
+ import {
40
+ appendProbeEvent,
41
+ PROBE_EXPECTED_TOOL as EXPECTED_TOOL,
42
+ PROBE_ENV,
43
+ PROBE_EVENTS,
44
+ type ProbeEventName,
45
+ } from "../lib/probe-event-log.ts";
46
+
47
+ const eventLog = process.env[PROBE_ENV.eventLog];
48
+ const probeMode = typeof eventLog === "string" && eventLog.length > 0;
49
+ const runId = process.env[PROBE_ENV.runId] ?? "unknown-run";
50
+ const startupDelayMs = Math.max(0, Number(process.env[PROBE_ENV.startupDelayMs] ?? "0") || 0);
51
+
52
+ function emit(event: ProbeEventName, payload: Record<string, unknown> = {}): void {
53
+ if (probeMode && eventLog) appendProbeEvent(eventLog, runId, event, payload);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // §11-7 instrumented transport — write-callback semantics + wire markers.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ type JsonRpcMessage = {
61
+ id?: string | number;
62
+ method?: string;
63
+ params?: { name?: string; arguments?: Record<string, unknown> };
64
+ result?: { tools?: Array<{ name?: string }> };
65
+ };
66
+
67
+ class InstrumentedStdioServerTransport extends StdioServerTransport {
68
+ // JSON-RPC request id → method, so a response frame can be attributed to the
69
+ // request that caused it (the response itself carries no method).
70
+ private readonly pendingMethods = new Map<string | number, string>();
71
+
72
+ constructor() {
73
+ super();
74
+ // The SDK protocol layer assigns `transport.onmessage = handler` after
75
+ // connect; the base class reads `this.onmessage`. Intercept the assignment
76
+ // so every inbound frame is stamped BEFORE the handler runs — receipt
77
+ // markers, not handler-return markers. Installed via defineProperty because
78
+ // the base declares `onmessage` as a plain property (TS2611 forbids a
79
+ // static accessor override) yet never assigns it in its constructor, so an
80
+ // instance accessor here wins cleanly.
81
+ let raw: ((message: unknown, extra?: unknown) => void) | undefined;
82
+ const stampInbound = (msg: JsonRpcMessage) => this.stampInbound(msg);
83
+ Object.defineProperty(this, "onmessage", {
84
+ get(): ((message: unknown, extra?: unknown) => void) | undefined {
85
+ if (!raw) return undefined;
86
+ return (message: unknown, extra?: unknown) => {
87
+ stampInbound(message as JsonRpcMessage);
88
+ raw?.(message, extra);
89
+ };
90
+ },
91
+ set(handler: ((message: unknown, extra?: unknown) => void) | undefined) {
92
+ raw = handler;
93
+ },
94
+ });
95
+ }
96
+
97
+ private stampInbound(msg: JsonRpcMessage): void {
98
+ if (typeof msg?.method !== "string") return;
99
+ if (msg.id !== undefined) this.pendingMethods.set(msg.id, msg.method);
100
+ if (msg.method === "initialize") {
101
+ emit(PROBE_EVENTS.fixtureInitializeReceived, { jsonrpcId: msg.id });
102
+ } else if (msg.method === "tools/list") {
103
+ emit(PROBE_EVENTS.fixtureToolsListReceived, { jsonrpcId: msg.id });
104
+ } else if (msg.method === "tools/call") {
105
+ emit(PROBE_EVENTS.fixtureToolsCallReceived, {
106
+ jsonrpcId: msg.id,
107
+ tool: msg.params?.name,
108
+ probeRunId: msg.params?.arguments?.probeRunId,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Write with the CALLBACK, not the buffered-return the SDK send() settles
114
+ // for: the callback fires when the frame reached the OS pipe, which is the
115
+ // §11-7 definition of "forwarded". Markers are emitted inside the callback
116
+ // so they can never precede the hand-off they claim — and ONLY on a
117
+ // SUCCESSFUL callback: an errored write (EPIPE, closed stream) must reject,
118
+ // never stamp, or the marker would claim a hand-off that did not happen.
119
+ override send(message: unknown): Promise<void> {
120
+ const json = serializeMessage(message as never);
121
+ return new Promise((resolve, reject) => {
122
+ (this as unknown as { _stdout: NodeJS.WriteStream })._stdout.write(json, (err?: Error | null) => {
123
+ if (err) {
124
+ reject(err);
125
+ return;
126
+ }
127
+ this.stampOutbound(message as JsonRpcMessage);
128
+ resolve();
129
+ });
130
+ });
131
+ }
132
+
133
+ private stampOutbound(msg: JsonRpcMessage): void {
134
+ if (msg?.id === undefined || typeof msg.method === "string") return;
135
+ const requestMethod = this.pendingMethods.get(msg.id);
136
+ if (requestMethod === undefined) return;
137
+ this.pendingMethods.delete(msg.id);
138
+ if (requestMethod === "tools/list") {
139
+ const toolNames = (msg.result?.tools ?? []).map((t) => t?.name).filter((n): n is string => !!n);
140
+ if (toolNames.includes(EXPECTED_TOOL)) {
141
+ emit(PROBE_EVENTS.toolsListResponseForwarded, { jsonrpcId: msg.id, tools: toolNames });
142
+ }
143
+ } else if (requestMethod === "tools/call") {
144
+ emit(PROBE_EVENTS.fixtureToolsCallReplied, { jsonrpcId: msg.id });
145
+ }
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Server — one tool in both modes; probe mode adds the REQUIRED join key.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ emit(PROBE_EVENTS.fixtureProcessStart, { startupDelayMs });
20
154
 
21
155
  const server = new McpServer({ name: "probe", version: "0.0.1" });
22
156
 
23
- server.tool(
24
- "probe_nonce",
25
- "Return this session's secret probe nonce. Call this when asked for the probe nonce.",
26
- {},
27
- async () => ({
28
- content: [{ type: "text", text: `PROBE_NONCE=${process.env.PROBE_NONCE ?? "MISSING"}` }],
29
- }),
30
- );
157
+ if (probeMode) {
158
+ server.tool(
159
+ EXPECTED_TOOL,
160
+ "Return this session's secret probe nonce. Call this when asked for the probe nonce, " +
161
+ "passing the exact probeRunId given in the prompt.",
162
+ { probeRunId: z.string().describe("The exact probeRunId string given in the prompt.") },
163
+ async () => ({
164
+ content: [{ type: "text", text: `PROBE_NONCE=${process.env[PROBE_ENV.nonce] ?? "MISSING"}` }],
165
+ }),
166
+ );
167
+ } else {
168
+ server.tool(
169
+ EXPECTED_TOOL,
170
+ "Return this session's secret probe nonce. Call this when asked for the probe nonce.",
171
+ {},
172
+ async () => ({
173
+ content: [{ type: "text", text: `PROBE_NONCE=${process.env[PROBE_ENV.nonce] ?? "MISSING"}` }],
174
+ }),
175
+ );
176
+ }
177
+
178
+ // The injected startup delay sits BEFORE the transport exists at all — the
179
+ // probe's controlled input models a slow-to-serve MCP server, so nothing may be
180
+ // readable on the wire until the delay elapses.
181
+ if (startupDelayMs > 0) {
182
+ emit(PROBE_EVENTS.fixtureDelayStart, { delayMs: startupDelayMs });
183
+ await new Promise((resolve) => setTimeout(resolve, startupDelayMs));
184
+ emit(PROBE_EVENTS.fixtureDelayEnd, { delayMs: startupDelayMs });
185
+ }
31
186
 
32
- const transport = new StdioServerTransport();
187
+ const transport = probeMode ? new InstrumentedStdioServerTransport() : new StdioServerTransport();
33
188
  await server.connect(transport);
189
+ emit(PROBE_EVENTS.fixtureTransportConnected, {});
@@ -0,0 +1,33 @@
1
+ // The TypeScript half of the release-gate STEP OUTCOME protocol
2
+ // (scripts/lib/step-outcome.sh owns the contract prose and the shell half).
3
+ //
4
+ // A LIVE smoke that lacks a prerequisite must be DISTINGUISHABLE from one that
5
+ // ran and passed. Before P1 every smoke exited 0 on a skip, so `run_live_step`
6
+ // counted it PASS and the release summary could not prove a required call had
7
+ // happened at all. `skipLive` is the single exit any smoke takes when it
8
+ // declines: one reserved code the aggregate classifies as SKIP, plus one marker
9
+ // line so an operator reading the log sees WHICH prerequisite was missing
10
+ // without decoding a number.
11
+ //
12
+ // The two halves must agree. `check-release-gate-outcomes` reads this constant
13
+ // and the shell one and refuses a mismatch — a protocol that drifted between
14
+ // languages would silently reclassify every skip as a failure (or worse).
15
+
16
+ /** SKIP. Must equal `ENTWURF_STEP_SKIP_EXIT` in scripts/lib/step-outcome.sh. */
17
+ export const LIVE_SKIP_EXIT = 97;
18
+
19
+ /** The machine-greppable prefix every skip line carries. */
20
+ export const LIVE_SKIP_MARKER = "[entwurf:skip]";
21
+
22
+ /**
23
+ * Decline this smoke: print the marker + reason on stderr and exit with the
24
+ * protocol's SKIP code. Never returns.
25
+ *
26
+ * `label` is the smoke's own name (so a multi-step aggregate log stays
27
+ * attributable); `reason` states the missing prerequisite AND how to supply it —
28
+ * an operator who hits a SKIP in a cut run needs the fix, not the diagnosis.
29
+ */
30
+ export function skipLive(label: string, reason: string): never {
31
+ console.error(`${LIVE_SKIP_MARKER} ${label} — ${reason}`);
32
+ process.exit(LIVE_SKIP_EXIT);
33
+ }