@junghanacs/entwurf 0.12.9 → 0.13.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 (100) hide show
  1. package/AGENTS.md +135 -248
  2. package/BASELINE.md +46 -7
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +5 -4
  6. package/README.md +46 -18
  7. package/VERIFY.md +10 -6
  8. package/demo/demo.sh +7 -1
  9. package/docs/setup-clean-host.md +10 -4
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
  28. package/mcp/entwurf-bridge/src/index.ts +190 -82
  29. package/package.json +7 -3
  30. package/pi/settings.reference.json +1 -1
  31. package/pi-extensions/acp-provider.ts +20 -10
  32. package/pi-extensions/entwurf-control.ts +69 -27
  33. package/pi-extensions/lib/acp/augment.ts +60 -2
  34. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  35. package/pi-extensions/lib/acp/backend.ts +5 -1
  36. package/pi-extensions/lib/acp/config.ts +19 -5
  37. package/pi-extensions/lib/acp/engraving.ts +3 -1
  38. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  39. package/pi-extensions/lib/acp/models.ts +69 -7
  40. package/pi-extensions/lib/acp/overlay.ts +234 -3
  41. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  42. package/pi-extensions/lib/entwurf-core.ts +26 -9
  43. package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
  44. package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
  45. package/pi-extensions/lib/entwurf-facts.ts +13 -13
  46. package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
  47. package/pi-extensions/lib/entwurf-self-address.ts +8 -9
  48. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
  49. package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
  50. package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
  51. package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
  52. package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
  53. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
  54. package/pi-extensions/lib/meta-session.ts +17 -20
  55. package/run.sh +200 -62
  56. package/scripts/agy-bridge-config.py +249 -49
  57. package/scripts/agy-bridge.sh +59 -14
  58. package/scripts/check-acp-carrier-augment.ts +34 -2
  59. package/scripts/check-acp-cortex.ts +668 -0
  60. package/scripts/check-acp-provider-surface.ts +50 -6
  61. package/scripts/check-acp-sdk-surface.ts +22 -11
  62. package/scripts/check-acp-session-reuse.ts +64 -1
  63. package/scripts/check-agy-permission-matrix.py +655 -0
  64. package/scripts/check-entwurf-deliverability.ts +9 -6
  65. package/scripts/check-entwurf-fact-provider.ts +7 -7
  66. package/scripts/check-entwurf-facts.ts +12 -12
  67. package/scripts/check-entwurf-resume-args.ts +22 -60
  68. package/scripts/check-entwurf-self-address.ts +187 -4
  69. package/scripts/check-entwurf-v2-contract.ts +2 -2
  70. package/scripts/check-entwurf-v2-production.ts +5 -5
  71. package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
  72. package/scripts/check-entwurf-v2-surface.ts +327 -14
  73. package/scripts/check-gate-qualification.ts +787 -0
  74. package/scripts/check-meta-identity-consumers.ts +1 -1
  75. package/scripts/check-probe-cli-shim.ts +879 -0
  76. package/scripts/check-probe-ordering.ts +2450 -0
  77. package/scripts/check-shell-quote.ts +5 -4
  78. package/scripts/fixtures/probe-cli-shim +20 -0
  79. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  80. package/scripts/lib/mutation-qualify.ts +794 -0
  81. package/scripts/lib/probe-acp-turn.ts +207 -0
  82. package/scripts/lib/probe-cli-shim.ts +464 -0
  83. package/scripts/lib/probe-cli-target.ts +165 -0
  84. package/scripts/lib/probe-event-log.ts +383 -0
  85. package/scripts/lib/probe-verdict.ts +1213 -0
  86. package/scripts/mutants/acp-augment.json +30 -0
  87. package/scripts/mutants/acp-cortex.json +196 -0
  88. package/scripts/mutants/agy-permission.json +144 -0
  89. package/scripts/mutants/meta-identity.json +17 -0
  90. package/scripts/mutants/probe-ordering.json +1032 -0
  91. package/scripts/mutants/self-address.json +59 -0
  92. package/scripts/mutants/v2-surface.json +87 -0
  93. package/scripts/smoke-acp-cortex-live.ts +392 -0
  94. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  95. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  96. package/scripts/smoke-acp-v2-send-live.ts +33 -18
  97. package/scripts/smoke-agy-install-state.sh +205 -20
  98. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
  99. package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -100
  100. package/scripts/check-entwurf-mailbox-guard.ts +0 -266
@@ -0,0 +1,848 @@
1
+ // §11-7 ordering probe — LIVE paired-run driver. LIVE-gated, OUT of `pnpm check`.
2
+ //
3
+ // LIVE=1 ./run.sh smoke-acp-ordering-probe-live
4
+ //
5
+ // The question this answers (§11-7, nothing more): against a delay=0 control,
6
+ // does injecting a startup delay into an operator MCP server shift this ACP
7
+ // server's newSession — i.e. does it wait for the delayed MCP — or does the
8
+ // prompt open ahead of wire-availability, or does it fail loud? The answer is an
9
+ // INPUT to the causal question about the 2026-07-24 samples, never its
10
+ // conclusion, and no prescription follows from a single probe run.
11
+ //
12
+ // The probe unit is a PAIRED set on identical pins/config/fixture:
13
+ // run 1 control delay=0 — must be visible AND callable, else P0
14
+ // run 2 intervention D1 — B/C/D readable here
15
+ // run 3 intervention D2 — A additionally needs latency tracking D
16
+ // across BOTH nonzero delays
17
+ //
18
+ // Seam: probe-dedicated RAW CLIENT (scripts/lib/probe-acp-turn.ts) — bound by
19
+ // check-probe-ordering to issue the same calls/arguments/order as backend.ts.
20
+ // Fidelity beyond the sequence: launch, overlay, env defaults, session meta,
21
+ // carrier, permission policy, and mcpServers enrichment all come from the REAL
22
+ // production modules — backend-adapter.ts / config.ts carry `.js` value imports
23
+ // (not strip-types-loadable), so this runner tsc-emits pi-extensions and
24
+ // dynamic-imports the emitted twins, the house pattern check-acp-session-reuse
25
+ // established. Nothing production-shaped is re-implemented here.
26
+ //
27
+ // Artifacts: every run appends to ONE shared NDJSON log; the roster, the raw
28
+ // classification, and a human verdict land under .probe-artifacts/ (gitignored,
29
+ // preserved — promotion into any ledger is a separate, manual, §11-7-gated act).
30
+ //
31
+ // Exit: 0 = the instrument produced a judgeable verdict (A / A-withheld / B /
32
+ // B-name-snapshot / C / D-*). 1 = P0 / recurring I0 / inconclusive — the
33
+ // artifact is still written.
34
+ //
35
+ // §11-7-c preconditions (before any run): refuse an ambient
36
+ // CLAUDE_CODE_EXECUTABLE, resolve the native CLI through upstream
37
+ // claudeCliPath(), and pin the pair's target as path + sha256 (re-hashed after
38
+ // the runs — drift INVALIDATES the pair). The B-name-snapshot CHANNEL is not
39
+ // armed here yet: snapshotInstrumented is pinned false until the shim lands.
40
+
41
+ import { type ChildProcessByStdio, execFileSync, spawn } from "node:child_process";
42
+ import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
43
+ import { mkdtemp, rm } from "node:fs/promises";
44
+ import { tmpdir } from "node:os";
45
+ import { dirname, join, resolve } from "node:path";
46
+ import { Readable, Writable } from "node:stream";
47
+ import { fileURLToPath, pathToFileURL } from "node:url";
48
+ import { ndJsonStream } from "@agentclientprotocol/sdk";
49
+ import { type AcpClientHandlers, connectAcpClient } from "../pi-extensions/lib/acp/acp-client.ts";
50
+ import type { AcpBackendAdapter } from "../pi-extensions/lib/acp/backend-adapter.ts";
51
+ import type { ResolvedAcpConfig } from "../pi-extensions/lib/acp/config.ts";
52
+ import { terminateChild } from "./lib/acp-child-cleanup.ts";
53
+ import { driveProbeTurn, type ProbeMcpEnricher, ProbePhaseError } from "./lib/probe-acp-turn.ts";
54
+ import {
55
+ AMBIENT_OVERRIDE_ENV,
56
+ assertNoAmbientOverride,
57
+ hashFileSha256,
58
+ PROBE_SHIM_ENV,
59
+ ProbeCliPreconditionError,
60
+ type ResolvedProbeCliTarget,
61
+ resolveProbeCliTarget,
62
+ } from "./lib/probe-cli-target.ts";
63
+ import {
64
+ appendProbeEvent,
65
+ PROBE_ENV,
66
+ PROBE_EVENTS,
67
+ type ProbeEventName,
68
+ readProbeEvents,
69
+ } from "./lib/probe-event-log.ts";
70
+ import { classifyProbe, DELAY_WELL_BELOW_MS, type ProbeRunRecord } from "./lib/probe-verdict.ts";
71
+
72
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
73
+ const PROBE_SERVER = join(REPO_ROOT, "scripts", "fixtures", "probe-mcp-server.ts");
74
+ // §11-7-c producer. EXTENSIONLESS is a contract, not a filename: the SDK routes a
75
+ // script-suffixed executable through `node|bun <path>` and everything else through
76
+ // a direct spawn, and the pair's target is asserted onto that direct branch — so
77
+ // the instrument standing in front of it has to be on the same branch. The asserts
78
+ // below are the SAME ones the target goes through, deliberately.
79
+ const PROBE_SHIM = join(REPO_ROOT, "scripts", "fixtures", "probe-cli-shim");
80
+ // …and the INSTRUMENT is not that one file. The launcher is two lines of delegate;
81
+ // what actually runs is a fresh Node process reading a local module graph, so
82
+ // "control and interventions shared one shim" (§11-7-c condition 5) is a claim
83
+ // about the WHOLE graph. Pinning only the launcher would let an edit to the
84
+ // implementation land between two runs of the same pair, with the boot marker —
85
+ // which reports the CLI target, not the instrument — showing nothing at all
86
+ // (adversarial review 2026-07-29). check-probe-ordering derives this list from the
87
+ // launcher's and implementation's static local imports and refuses any drift, so
88
+ // it is a pinned list rather than a second unverified copy.
89
+ const SHIM_RUNTIME_FILES: readonly string[] = [
90
+ PROBE_SHIM,
91
+ join(REPO_ROOT, "scripts", "lib", "probe-cli-shim.ts"),
92
+ join(REPO_ROOT, "scripts", "lib", "probe-cli-target.ts"),
93
+ join(REPO_ROOT, "scripts", "lib", "probe-event-log.ts"),
94
+ ];
95
+ const MODEL_ID = process.env.ENTWURF_ACP_PROBE_MODEL?.trim() || "claude-sonnet-5";
96
+ const D1_MS = Number(process.env.PROBE_D1_MS ?? "2000") || 2000;
97
+ const D2_MS = Number(process.env.PROBE_D2_MS ?? "8000") || 8000;
98
+ const TEXT_TAIL_CAP = 400;
99
+
100
+ function fail(msg: string): never {
101
+ console.error(`[smoke-acp-ordering-probe-live] FAIL: ${msg}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ if (process.env.LIVE !== "1") {
106
+ console.error("[smoke-acp-ordering-probe-live] skipped — set LIVE=1 to run the real paired probe.");
107
+ process.exit(0);
108
+ }
109
+
110
+ for (const [label, d] of [
111
+ ["PROBE_D1_MS", D1_MS],
112
+ ["PROBE_D2_MS", D2_MS],
113
+ ] as const) {
114
+ if (!(d > 0 && d < DELAY_WELL_BELOW_MS)) {
115
+ fail(`${label}=${d} must satisfy 0 < D < ${DELAY_WELL_BELOW_MS}ms — §11-7: well below the 30 s boundaries`);
116
+ }
117
+ }
118
+ if (D1_MS === D2_MS) fail(`PROBE_D1_MS and PROBE_D2_MS are both ${D1_MS} — A needs two DISTINCT nonzero delays`);
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Production modules — tsc emit + dynamic import (house pattern, see header).
122
+ // ---------------------------------------------------------------------------
123
+ const TMP_EMIT = join(REPO_ROOT, ".tmp-verify", "probe-ordering-live");
124
+ rmSync(TMP_EMIT, { recursive: true, force: true });
125
+ console.error("[smoke-acp-ordering-probe-live] emitting pi-extensions (tsc) for the production adapter/config…");
126
+ execFileSync(
127
+ join(REPO_ROOT, "node_modules", ".bin", "tsc"),
128
+ ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"],
129
+ {
130
+ cwd: REPO_ROOT,
131
+ stdio: "pipe",
132
+ },
133
+ );
134
+ // tsc emits only .ts→.js; the engraving carrier is a .md asset shipped alongside
135
+ // engraving.js in the real package — copy it so loadCarrier finds its default.
136
+ const promptsOut = join(TMP_EMIT, "pi-extensions", "lib", "acp", "prompts");
137
+ mkdirSync(promptsOut, { recursive: true });
138
+ copyFileSync(
139
+ join(REPO_ROOT, "pi-extensions", "lib", "acp", "prompts", "engraving.md"),
140
+ join(promptsOut, "engraving.md"),
141
+ );
142
+
143
+ const adapterMod = (await import(
144
+ pathToFileURL(join(TMP_EMIT, "pi-extensions", "lib", "acp", "backend-adapter.js")).href
145
+ )) as { claudeAdapter: AcpBackendAdapter };
146
+ const configMod = (await import(pathToFileURL(join(TMP_EMIT, "pi-extensions", "lib", "acp", "config.js")).href)) as {
147
+ resolveProviderConfig: (params: Record<string, unknown>) => ResolvedAcpConfig;
148
+ enrichMcpServersWithEnvelope: ProbeMcpEnricher;
149
+ mcpServerNames: (config: ResolvedAcpConfig) => string[];
150
+ };
151
+ const claudeAdapter = adapterMod.claudeAdapter;
152
+ const enrichMcpServersWithEnvelope = configMod.enrichMcpServersWithEnvelope;
153
+
154
+ const routed = claudeAdapter.routeModel(MODEL_ID);
155
+ if (!routed) fail(`model ${MODEL_ID} does not route to the claude adapter`);
156
+ const NATIVE_MODEL_ID = routed.nativeModelId;
157
+
158
+ // §11-7-c precondition gate — resolved in main() BEFORE any run, AFTER the
159
+ // artifact directory exists, so a refusal leaves a named classification on the
160
+ // artifact instead of stderr alone. Assigned exactly once there.
161
+ let CLI_TARGET: ResolvedProbeCliTarget;
162
+ // The shim, resolved through the same precondition asserts as the target and
163
+ // pinned before any run. Assigned exactly once in main().
164
+ let SHIM_TARGET: ResolvedProbeCliTarget;
165
+ // path+sha256 for every file in the instrument's runtime graph, pinned before the
166
+ // first run and re-hashed after the last. Its own axis, separate from the CLI
167
+ // target's: a stimulus that moved and an INSTRUMENT that moved are different
168
+ // findings and must not be reported under one name.
169
+ let SHIM_RUNTIME: Array<{ path: string; sha256: string }> = [];
170
+
171
+ function hashShimRuntime(): Array<{ path: string; sha256: string }> {
172
+ return SHIM_RUNTIME_FILES.map((path) => ({ path, sha256: hashFileSha256(path) }));
173
+ }
174
+
175
+ // Approve-all permission policy — mirrors backend.ts resolvePermissionResponse
176
+ // (module-private there; check-probe-ordering pins this copy against its source).
177
+ function approveAllPermission(params: { options?: Array<{ optionId: string; kind?: string }> }): {
178
+ outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
179
+ } {
180
+ const options = Array.isArray(params?.options) ? params.options : [];
181
+ if (options.length === 0) return { outcome: { outcome: "cancelled" } };
182
+ const allow = options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
183
+ return { outcome: { outcome: "selected", optionId: (allow ?? options[0]).optionId } };
184
+ }
185
+
186
+ const NO_SUCH_TOOL_RE = /No such tool(?: available)?:?\s*"?([\w:.-]+)"?/i;
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // The OBSERVATION WINDOW (§11-7, GPT review 2026-07-29).
190
+ //
191
+ // The first LIVE pair tore the ACP child down the instant the turn settled. At
192
+ // D=8000ms that happened while the fixture was still inside its injected delay:
193
+ // the fixture went on to finish its delay and complete `initialize` 2.7 s AFTER
194
+ // run_end, and the tools/list it would have forwarded ~14 ms later never
195
+ // happened, because the client that would have asked was gone. The classifier
196
+ // then read that self-inflicted absence as an MCP handshake / fixture / config
197
+ // candidate — an attribution about the SERVER derived from a fact about OUR
198
+ // teardown.
199
+ //
200
+ // So absence is only a reading when we kept looking long enough to have seen the
201
+ // marker. After the turn settles the runner keeps the child alive until the
202
+ // FIRST of:
203
+ // - the wire marker lands → reason `wire-marker` (marker seen)
204
+ // - the ACP child exits on its own → reason `child-exit` (CENSORED)
205
+ // - the deadline passes → reason `deadline` (window sufficient)
206
+ // and stamps `probe_observation_window_end` with which one it was, BEFORE
207
+ // teardown. Only `deadline` lets a missing marker be read as evidence.
208
+ //
209
+ // The deadline is anchored on the FIXTURE'S OWN delay markers, never on run
210
+ // start: the injected delay begins when the fixture process boots, which is
211
+ // itself some way into newSession, so a run-start-relative deadline would drift
212
+ // with spawn latency and silently shorten the window it claims to guarantee.
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /** Grace kept after the fixture's delay ends before calling the window closed.
216
+ * A CONSTANT, deliberately not an env knob: this value is what lets a missing
217
+ * wire marker be read as evidence at all, so an operator (or a stray export)
218
+ * able to shrink it could make the probe close its own window early and then
219
+ * call that absence "deadline-sufficient" (GPT review 2026-07-29). The earlier
220
+ * `Number(env) || 5000` form accepted 0 and negatives outright. */
221
+ const POST_DELAY_SLACK_MS = 5_000;
222
+ /** Boot allowance used only when the fixture logged no delay marker at all. */
223
+ const FIXTURE_BOOT_ALLOWANCE_MS = 5_000;
224
+ const WINDOW_POLL_MS = 100;
225
+
226
+ function runEventsOf(logPath: string, runId: string) {
227
+ return readProbeEvents(logPath).events.filter((e) => e.runId === runId);
228
+ }
229
+
230
+ /** Did the wire-availability marker for this run land? */
231
+ function wireMarkerSeen(logPath: string, runId: string): boolean {
232
+ return runEventsOf(logPath, runId).some((e) => e.event === PROBE_EVENTS.toolsListResponseForwarded);
233
+ }
234
+
235
+ /** Deadline for THIS run, recomputed each poll because the fixture's delay
236
+ * markers may still be arriving while we wait. Returns the absolute ms plus the
237
+ * basis actually used, which is recorded so a reader can tell a well-anchored
238
+ * window from a fallback one. */
239
+ function windowDeadline(
240
+ logPath: string,
241
+ runId: string,
242
+ delayMs: number,
243
+ windowOpenedMs: number,
244
+ ): { deadlineMs: number; basis: "fixture-delay-end" | "fixture-delay-start" | "no-fixture-delay-marker" } {
245
+ const events = runEventsOf(logPath, runId);
246
+ const delayEnd = events.find((e) => e.event === PROBE_EVENTS.fixtureDelayEnd);
247
+ if (delayEnd) return { deadlineMs: delayEnd.tsMs + POST_DELAY_SLACK_MS, basis: "fixture-delay-end" };
248
+ const delayStart = events.find((e) => e.event === PROBE_EVENTS.fixtureDelayStart);
249
+ if (delayStart) return { deadlineMs: delayStart.tsMs + delayMs + POST_DELAY_SLACK_MS, basis: "fixture-delay-start" };
250
+ return {
251
+ deadlineMs: windowOpenedMs + delayMs + FIXTURE_BOOT_ALLOWANCE_MS + POST_DELAY_SLACK_MS,
252
+ basis: "no-fixture-delay-marker",
253
+ };
254
+ }
255
+
256
+ /** Hold the window open past the turn, then stamp how it closed. */
257
+ async function observeWindowClose(
258
+ logPath: string,
259
+ runId: string,
260
+ delayMs: number,
261
+ child: ChildProcessByStdio<Writable, Readable, Readable>,
262
+ log: (event: ProbeEventName, payload?: Record<string, unknown>) => void,
263
+ ): Promise<void> {
264
+ const openedMs = Date.now();
265
+ let reason: "wire-marker" | "child-exit" | "deadline";
266
+ let basis = windowDeadline(logPath, runId, delayMs, openedMs).basis;
267
+ for (;;) {
268
+ if (wireMarkerSeen(logPath, runId)) {
269
+ reason = "wire-marker";
270
+ break;
271
+ }
272
+ // The child ending is NOT the deadline being met — we stop looking because
273
+ // the thing being observed is gone, which is precisely a censored reading.
274
+ if (child.exitCode !== null || child.signalCode !== null) {
275
+ reason = "child-exit";
276
+ break;
277
+ }
278
+ const d = windowDeadline(logPath, runId, delayMs, openedMs);
279
+ basis = d.basis;
280
+ if (Date.now() >= d.deadlineMs) {
281
+ reason = "deadline";
282
+ break;
283
+ }
284
+ await new Promise((r) => setTimeout(r, WINDOW_POLL_MS));
285
+ }
286
+ // Re-read rather than trusting the loop's exit branch: the marker can land in
287
+ // the same tick the child exits, and a marker seen is a marker seen.
288
+ const markerSeen = wireMarkerSeen(logPath, runId);
289
+ log(PROBE_EVENTS.observationWindowEnd, {
290
+ reason: markerSeen ? "wire-marker" : reason,
291
+ markerSeen,
292
+ deadlineBasis: basis,
293
+ waitedMs: Date.now() - openedMs,
294
+ });
295
+ }
296
+
297
+ interface RunOutcome {
298
+ record: ProbeRunRecord;
299
+ ok: boolean;
300
+ phase?: string;
301
+ }
302
+
303
+ async function runOne(
304
+ logPath: string,
305
+ role: "control" | "intervention",
306
+ delayMs: number,
307
+ attempt: number,
308
+ index: number,
309
+ ): Promise<RunOutcome> {
310
+ const runId = `run${attempt}-${index}-${Math.random().toString(36).slice(2, 8)}`;
311
+ const probeRunId = `prb-${Math.random().toString(36).slice(2, 10)}`;
312
+ const nonce = `MCP_${process.pid.toString(36)}${Date.now().toString(36)}`;
313
+ // The B-name-snapshot channel is ARMED: the §11-7-c producer is built and this
314
+ // run installs it in front of the CLI (below). Arming is a deliberate act —
315
+ // under it the classifier holds the CONTROL to the calibration floor, so a run
316
+ // whose shim never reported in, or reported a different target, is a NAMED
317
+ // structural finding rather than a quiet absence. The pair's expected CLI
318
+ // target identity rides the roster so the classifier can CONSUME it
319
+ // (condition 5): a shim boot reporting any other path/sha is a
320
+ // snapshot-topology INVALIDATION, never a substitution nobody notices.
321
+ const record: ProbeRunRecord = {
322
+ runId,
323
+ role,
324
+ delayMs,
325
+ probeRunId,
326
+ snapshotInstrumented: true,
327
+ cliTargetPath: CLI_TARGET.path,
328
+ cliTargetSha256: CLI_TARGET.sha256,
329
+ };
330
+ const log = (event: ProbeEventName, payload: Record<string, unknown> = {}) =>
331
+ appendProbeEvent(logPath, runId, event, payload);
332
+
333
+ const scratch = await mkdtemp(join(tmpdir(), "entwurf-probe-ordering-"));
334
+ let child: ChildProcessByStdio<Writable, Readable, Readable> | undefined;
335
+ let connection: ReturnType<typeof connectAcpClient> | undefined;
336
+ try {
337
+ // Register the fixture exactly as an operator would (S2g surface), then
338
+ // resolve through the REAL config path — scratch global keeps the
339
+ // operator's own settings out of the experiment.
340
+ mkdirSync(join(scratch, ".pi"), { recursive: true });
341
+ const projectSettingsPath = join(scratch, ".pi", "settings.json");
342
+ writeFileSync(
343
+ projectSettingsPath,
344
+ `${JSON.stringify(
345
+ {
346
+ entwurfProvider: {
347
+ mcpServers: {
348
+ probe: {
349
+ command: process.execPath,
350
+ args: ["--experimental-strip-types", PROBE_SERVER],
351
+ env: {
352
+ [PROBE_ENV.nonce]: nonce,
353
+ [PROBE_ENV.eventLog]: logPath,
354
+ [PROBE_ENV.startupDelayMs]: String(delayMs),
355
+ [PROBE_ENV.runId]: runId,
356
+ },
357
+ },
358
+ },
359
+ },
360
+ },
361
+ null,
362
+ 2,
363
+ )}\n`,
364
+ );
365
+ const config = configMod.resolveProviderConfig({
366
+ cwd: scratch,
367
+ modelId: MODEL_ID,
368
+ adapter: claudeAdapter,
369
+ globalSettingsPath: join(scratch, ".pi", "global-settings-absent.json"),
370
+ projectSettingsPath,
371
+ });
372
+ const carrier = claudeAdapter.loadCarrier({ mcpServerNames: configMod.mcpServerNames(config), config });
373
+
374
+ // Production spawn: adapter launch + overlay + env defaults over process.env.
375
+ // sessionKey mirrors resolveSessionKey's cwd fallback (this probe runs with
376
+ // no opts.sessionId / PI_SESSION_ID); claude's overlay ignores it.
377
+ const overlay = claudeAdapter.ensureOverlay({
378
+ cwd: scratch,
379
+ modelId: MODEL_ID,
380
+ nativeModelId: NATIVE_MODEL_ID,
381
+ config,
382
+ sessionKey: `cwd:${scratch}`,
383
+ });
384
+ const launch = claudeAdapter.resolveLaunch({
385
+ cwd: scratch,
386
+ modelId: MODEL_ID,
387
+ nativeModelId: NATIVE_MODEL_ID,
388
+ config,
389
+ });
390
+ // The COMPOSED env is asserted, not just process.env: launch defaults or
391
+ // overlay overrides injecting the executable override would hijack which
392
+ // CLI the pair measures, silently (§11-7-c precondition).
393
+ const spawnEnv = { ...process.env, ...claudeAdapter.launchEnvDefaults(), ...overlay.envOverrides };
394
+ assertNoAmbientOverride(spawnEnv, `composed acp child env for ${runId}`);
395
+ // ORDER IS THE CONTRACT (§11-7-c condition 1, GPT GO condition): the refusal
396
+ // above runs against the env as PRODUCTION composed it, and only then does
397
+ // the probe install its own override. Inverted, the checkpoint would inspect
398
+ // the override the probe itself just injected and REFUSE every run — loudly,
399
+ // but for the wrong reason, and the operator's ambient environment (the one
400
+ // thing this precondition exists to observe) would never be examined at all.
401
+ spawnEnv[AMBIENT_OVERRIDE_ENV] = SHIM_TARGET.path;
402
+ spawnEnv[PROBE_SHIM_ENV.target] = CLI_TARGET.path;
403
+ spawnEnv[PROBE_SHIM_ENV.eventLog] = logPath;
404
+ spawnEnv[PROBE_SHIM_ENV.runId] = runId;
405
+ child = spawn(launch.command, launch.args, {
406
+ cwd: scratch,
407
+ env: spawnEnv,
408
+ stdio: ["pipe", "pipe", "pipe"],
409
+ }) as ChildProcessByStdio<Writable, Readable, Readable>;
410
+ const spawned = child;
411
+ const stderrTail: string[] = [];
412
+ spawned.stderr.on("data", (c) => {
413
+ stderrTail.push(c.toString());
414
+ if (stderrTail.length > 50) stderrTail.shift();
415
+ });
416
+
417
+ let collectedText = "";
418
+ // The runtime No-such-tool error is read ONLY off structured tool_call
419
+ // frames, and ONLY off their runtime-produced fields (rawOutput / content /
420
+ // _meta / error — never rawInput, never agent prose). Scanning model text
421
+ // would let the model MINT the marker by saying the sentence, silently
422
+ // promoting prose to runtime evidence — the exact §11-7 bypass GPT review
423
+ // 2026-07-28 flagged.
424
+ const scanStructuredNoSuchTool = (u: Record<string, unknown>) => {
425
+ const runtimeFields = JSON.stringify({
426
+ rawOutput: u.rawOutput,
427
+ content: u.content,
428
+ _meta: u._meta,
429
+ error: u.error,
430
+ });
431
+ const m = NO_SUCH_TOOL_RE.exec(runtimeFields);
432
+ if (m) log(PROBE_EVENTS.acpNoSuchTool, { toolId: m[1], raw: runtimeFields.slice(0, TEXT_TAIL_CAP) });
433
+ };
434
+ // tool_call state per toolCallId: claude-agent-acp emits `tool_call`
435
+ // (pending, name/title) and streams rawInput on LATER `tool_call_update`
436
+ // frames — the first LIVE control proved that reading rawInput off the
437
+ // initial frame alone records nothing. Accumulate per id, emit the observed
438
+ // event ONCE when the probeRunId argument becomes visible, and keep every
439
+ // raw frame as forensics so a failed extraction stays diagnosable.
440
+ const toolCalls = new Map<string, { providerToolId?: string; observed: boolean }>();
441
+ const handlers: AcpClientHandlers = {
442
+ sessionUpdate: async (notification) => {
443
+ const u = notification?.update as Record<string, unknown> | undefined;
444
+ const kind = u?.sessionUpdate;
445
+ if (kind === "agent_message_chunk") {
446
+ const t = (u?.content as { text?: string } | undefined)?.text;
447
+ if (typeof t === "string") collectedText += t;
448
+ } else if (kind === "tool_call" || kind === "tool_call_update") {
449
+ // §11-7: toolCallId and wire ids are forensics, never cross-layer
450
+ // join keys. The join key is the probeRunId argument we control.
451
+ log(PROBE_EVENTS.acpToolCallRaw, { kind, raw: JSON.stringify(u).slice(0, 600) });
452
+ const toolCallId = typeof u?.toolCallId === "string" ? u.toolCallId : "<none>";
453
+ const entry = toolCalls.get(toolCallId) ?? { observed: false };
454
+ // The provider-bound id candidate: prefer an explicit tool name
455
+ // field, fall back to title; keep the FIRST value seen for the id.
456
+ const rawName = typeof u?.name === "string" ? u.name : undefined;
457
+ const title = typeof u?.title === "string" ? u.title : undefined;
458
+ entry.providerToolId = entry.providerToolId ?? rawName ?? title;
459
+ const rawInput = u?.rawInput as Record<string, unknown> | undefined;
460
+ const probeRunIdArg = typeof rawInput?.probeRunId === "string" ? rawInput.probeRunId : undefined;
461
+ if (!entry.observed && probeRunIdArg !== undefined) {
462
+ entry.observed = true;
463
+ log(PROBE_EVENTS.acpToolCallObserved, {
464
+ providerToolId: entry.providerToolId,
465
+ probeRunId: probeRunIdArg,
466
+ toolCallId,
467
+ });
468
+ }
469
+ toolCalls.set(toolCallId, entry);
470
+ if (u) scanStructuredNoSuchTool(u);
471
+ }
472
+ },
473
+ requestPermission: async (req) => approveAllPermission(req),
474
+ readTextFile: async () => {
475
+ throw new Error("unexpected readTextFile in ordering probe turn");
476
+ },
477
+ writeTextFile: async () => {
478
+ throw new Error("unexpected writeTextFile in ordering probe turn");
479
+ },
480
+ };
481
+ const stdoutWeb = Readable.toWeb(spawned.stdout) as unknown as ReadableStream<Uint8Array>;
482
+ const stdinWeb = Writable.toWeb(spawned.stdin) as unknown as WritableStream<Uint8Array>;
483
+ connection = connectAcpClient(ndJsonStream(stdinWeb, stdoutWeb) as never, handlers);
484
+
485
+ log(PROBE_EVENTS.runStart, {
486
+ role,
487
+ delayMs,
488
+ probeRunId,
489
+ model: MODEL_ID,
490
+ attempt,
491
+ // The pair's stimulus identity — which CLI binary the ACP child will
492
+ // resolve — pinned on the artifact per run (§11-7-c condition 5).
493
+ cliTargetPath: CLI_TARGET.path,
494
+ cliTargetSha256: CLI_TARGET.sha256,
495
+ });
496
+ console.error(`[smoke-acp-ordering-probe-live] ${runId}: role=${role} delay=${delayMs}ms probeRunId=${probeRunId}`);
497
+
498
+ const promptText =
499
+ `Call the probe_nonce tool now with probeRunId set to exactly "${probeRunId}", then reply with ` +
500
+ "exactly the PROBE_NONCE value it returns (the part after the '=' sign), and nothing else.";
501
+ try {
502
+ const result = await driveProbeTurn(connection, {
503
+ cwd: scratch,
504
+ modelId: MODEL_ID,
505
+ nativeModelId: NATIVE_MODEL_ID,
506
+ config,
507
+ carrier,
508
+ promptText,
509
+ adapter: claudeAdapter,
510
+ enrichMcpServers: enrichMcpServersWithEnvelope,
511
+ log,
512
+ });
513
+ log(PROBE_EVENTS.promptReply, {
514
+ carriesNonce: collectedText.includes(nonce),
515
+ stopReason: result.stopReason,
516
+ textTail: collectedText.slice(-TEXT_TAIL_CAP),
517
+ });
518
+ await observeWindowClose(logPath, runId, delayMs, spawned, log);
519
+ log(PROBE_EVENTS.runEnd, { ok: true });
520
+ return { record, ok: true };
521
+ } catch (err) {
522
+ const phase = err instanceof ProbePhaseError ? err.phase : "unknown";
523
+ // The window question is moot once a phase failed — the phase reading
524
+ // owns the run — but the marker is stamped anyway so the exactly-once
525
+ // runner topology holds on this path too.
526
+ log(PROBE_EVENTS.observationWindowEnd, {
527
+ reason: "run-failed",
528
+ markerSeen: wireMarkerSeen(logPath, runId),
529
+ phase,
530
+ });
531
+ log(PROBE_EVENTS.runEnd, { ok: false, phase, error: err instanceof Error ? err.message : String(err) });
532
+ console.error(`[smoke-acp-ordering-probe-live] ${runId}: turn failed at ${phase}`);
533
+ console.error(`[smoke-acp-ordering-probe-live] ${runId}: stderr tail:\n${stderrTail.slice(-10).join("")}`);
534
+ return { record, ok: false, phase };
535
+ }
536
+ } finally {
537
+ connection?.close?.();
538
+ if (child) await terminateChild(child);
539
+ try {
540
+ await rm(scratch, { recursive: true, force: true });
541
+ } catch {
542
+ // scratch cleanup is best-effort
543
+ }
544
+ }
545
+ }
546
+
547
+ async function runRoster(logPath: string, attempt: number): Promise<ProbeRunRecord[]> {
548
+ const roster: ProbeRunRecord[] = [];
549
+ const control = await runOne(logPath, "control", 0, attempt, 0);
550
+ roster.push(control.record);
551
+ // P0 short-circuit is decided by the CLASSIFIER (single authority) — but a
552
+ // control that already failed can never classify PASS, so spare the two live
553
+ // intervention turns and let the final classification state the P0.
554
+ const { events } = readProbeEvents(logPath);
555
+ const preview = classifyProbe(roster, events);
556
+ if (preview.verdict === "P0") return roster;
557
+ roster.push((await runOne(logPath, "intervention", D1_MS, attempt, 1)).record);
558
+ roster.push((await runOne(logPath, "intervention", D2_MS, attempt, 2)).record);
559
+ return roster;
560
+ }
561
+
562
+ async function main(): Promise<void> {
563
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
564
+ const artifactDir = join(REPO_ROOT, ".probe-artifacts", "acp-ordering", stamp);
565
+ mkdirSync(artifactDir, { recursive: true });
566
+ const logPath = join(artifactDir, "events.ndjson");
567
+ // §11-7-c precondition gate — BEFORE any run. Refuse an ambient
568
+ // CLAUDE_CODE_EXECUTABLE (claudeCliPath() would return it VERBATIM and the
569
+ // pair would measure an unpinned executable), resolve the native CLI once
570
+ // through the upstream resolver, and pin the pair's stimulus identity as
571
+ // path + sha256. A refusal is a NAMED classification on the artifact, not a
572
+ // stderr-only exit. The deep import is a version-pinned internal resolver
573
+ // dependency; its disappearance breaks check-probe-ordering offline, never a
574
+ // LIVE run first.
575
+ try {
576
+ CLI_TARGET = await resolveProbeCliTarget({
577
+ env: process.env,
578
+ resolveNative: async () => {
579
+ const mod = (await import("@agentclientprotocol/claude-agent-acp/dist/acp-agent.js")) as {
580
+ claudeCliPath: () => Promise<string>;
581
+ };
582
+ return mod.claudeCliPath();
583
+ },
584
+ });
585
+ } catch (err) {
586
+ const reason = err instanceof ProbeCliPreconditionError ? `precondition-${err.reason}` : "precondition-unknown";
587
+ writeFileSync(
588
+ join(artifactDir, "classification.json"),
589
+ `${JSON.stringify(
590
+ { verdict: "INVALIDATED", reason, message: err instanceof Error ? err.message : String(err) },
591
+ null,
592
+ 2,
593
+ )}\n`,
594
+ );
595
+ fail(`${reason}: ${err instanceof Error ? err.message : String(err)} (artifact preserved at ${artifactDir})`);
596
+ }
597
+ // The instrument goes through the SAME gate as the stimulus. `env: {}` because
598
+ // the ambient refusal already ran against the real environment above; what is
599
+ // asked here is only the shape question — absolute, native branch (no script
600
+ // suffix), a present regular file, executable — because a shim that fails any
601
+ // of those either does not run at all or runs on the OTHER launch branch, and
602
+ // either way the pair would measure something else. Refusal is a NAMED
603
+ // classification on the artifact, exactly like the target's.
604
+ try {
605
+ SHIM_TARGET = await resolveProbeCliTarget({ env: {}, resolveNative: async () => PROBE_SHIM });
606
+ } catch (err) {
607
+ const reason =
608
+ err instanceof ProbeCliPreconditionError ? `precondition-shim-${err.reason}` : "precondition-shim-unknown";
609
+ writeFileSync(
610
+ join(artifactDir, "classification.json"),
611
+ `${JSON.stringify(
612
+ { verdict: "INVALIDATED", reason, message: err instanceof Error ? err.message : String(err) },
613
+ null,
614
+ 2,
615
+ )}\n`,
616
+ );
617
+ fail(`${reason}: ${err instanceof Error ? err.message : String(err)} (artifact preserved at ${artifactDir})`);
618
+ }
619
+ console.error(
620
+ `[smoke-acp-ordering-probe-live] cli target: ${CLI_TARGET.path} (sha256 ${CLI_TARGET.sha256.slice(0, 12)}…)`,
621
+ );
622
+ try {
623
+ SHIM_RUNTIME = hashShimRuntime();
624
+ } catch (err) {
625
+ writeFileSync(
626
+ join(artifactDir, "classification.json"),
627
+ `${JSON.stringify(
628
+ {
629
+ verdict: "INVALIDATED",
630
+ reason: "shim-runtime-unreadable",
631
+ message: err instanceof Error ? err.message : String(err),
632
+ },
633
+ null,
634
+ 2,
635
+ )}\n`,
636
+ );
637
+ fail(`shim-runtime-unreadable: ${err instanceof Error ? err.message : String(err)} (artifact at ${artifactDir})`);
638
+ }
639
+ writeFileSync(join(artifactDir, "shim-runtime.json"), `${JSON.stringify(SHIM_RUNTIME, null, 2)}\n`);
640
+ console.error(
641
+ `[smoke-acp-ordering-probe-live] shim: ${SHIM_TARGET.path} (sha256 ${SHIM_TARGET.sha256.slice(0, 12)}…) — snapshot channel ARMED`,
642
+ );
643
+ console.error(
644
+ `[smoke-acp-ordering-probe-live] instrument: ${SHIM_RUNTIME.length} runtime files pinned (${SHIM_RUNTIME.map((f) => f.sha256.slice(0, 8)).join(" ")})`,
645
+ );
646
+ console.error(`[smoke-acp-ordering-probe-live] model: ${MODEL_ID} (native ${NATIVE_MODEL_ID})`);
647
+ console.error(`[smoke-acp-ordering-probe-live] delays: control=0, D1=${D1_MS}ms, D2=${D2_MS}ms`);
648
+ console.error(`[smoke-acp-ordering-probe-live] artifact: ${artifactDir}`);
649
+
650
+ // Attempt 2 exists ONLY for the I0 policy: control passed but an intervention
651
+ // failed at initialize (environment drift) → re-run the same pair ONCE.
652
+ let roster = await runRoster(logPath, 1);
653
+ let { events, malformed, sequenceViolations } = readProbeEvents(logPath);
654
+ let classification = classifyProbe(roster, events);
655
+ if (classification.verdict === "I0") {
656
+ console.error("[smoke-acp-ordering-probe-live] I0 — re-running the same pair once (§11-7 bounded retry)");
657
+ roster = await runRoster(logPath, 2);
658
+ ({ events, malformed, sequenceViolations } = readProbeEvents(logPath));
659
+ classification = classifyProbe(roster, events);
660
+ }
661
+
662
+ writeFileSync(join(artifactDir, "roster.json"), `${JSON.stringify(roster, null, 2)}\n`);
663
+ // The INSTRUMENT's own drift axis. The CLI-target rehash below answers "did the
664
+ // runs share one stimulus"; this answers "did they share one instrument", and
665
+ // an edit to the implementation between control and intervention is invisible
666
+ // to every other check — the shim's boot marker reports the CLI target, not
667
+ // itself.
668
+ let shimRehash: Array<{ path: string; sha256: string }>;
669
+ try {
670
+ shimRehash = hashShimRuntime();
671
+ } catch (err) {
672
+ writeFileSync(
673
+ join(artifactDir, "classification.json"),
674
+ `${JSON.stringify(
675
+ {
676
+ verdict: "INVALIDATED",
677
+ reason: "shim-runtime-unreadable",
678
+ pinned: SHIM_RUNTIME,
679
+ message: err instanceof Error ? err.message : String(err),
680
+ },
681
+ null,
682
+ 2,
683
+ )}\n`,
684
+ );
685
+ fail(
686
+ `a file in the shim runtime graph became unreadable during the pair — the runs cannot be shown to share one ` +
687
+ `INSTRUMENT; pair INVALIDATED, artifact preserved at ${artifactDir}`,
688
+ );
689
+ }
690
+ if (JSON.stringify(shimRehash) !== JSON.stringify(SHIM_RUNTIME)) {
691
+ writeFileSync(
692
+ join(artifactDir, "classification.json"),
693
+ `${JSON.stringify(
694
+ { verdict: "INVALIDATED", reason: "shim-runtime-drift", pinned: SHIM_RUNTIME, observed: shimRehash },
695
+ null,
696
+ 2,
697
+ )}\n`,
698
+ );
699
+ fail(
700
+ `the shim runtime graph changed content during the pair — control and interventions did not run the SAME ` +
701
+ `instrument; pair INVALIDATED, artifact preserved at ${artifactDir}`,
702
+ );
703
+ }
704
+ // §11-7-c condition 5 — the pair is a delta only while every run resolved the
705
+ // SAME executable. The path was pinned before the runs; if its content hash
706
+ // moved underneath the pair (an install, a version switch), the runs did not
707
+ // share a stimulus and nothing may be judged across them. An UNREADABLE
708
+ // target at re-hash time (deleted, permissions) is the same finding with its
709
+ // own name — an uncaught throw here would exit without the INVALIDATED
710
+ // artifact this block exists to write.
711
+ let rehash: string;
712
+ try {
713
+ rehash = hashFileSha256(CLI_TARGET.path);
714
+ } catch (err) {
715
+ writeFileSync(
716
+ join(artifactDir, "classification.json"),
717
+ `${JSON.stringify(
718
+ {
719
+ verdict: "INVALIDATED",
720
+ reason: "cli-target-unreadable",
721
+ cliTargetPath: CLI_TARGET.path,
722
+ sha256Before: CLI_TARGET.sha256,
723
+ message: err instanceof Error ? err.message : String(err),
724
+ },
725
+ null,
726
+ 2,
727
+ )}\n`,
728
+ );
729
+ fail(
730
+ `cli target ${CLI_TARGET.path} became unreadable during the pair — the runs cannot be shown to share one ` +
731
+ `stimulus; pair INVALIDATED, artifact preserved at ${artifactDir}`,
732
+ );
733
+ }
734
+ if (rehash !== CLI_TARGET.sha256) {
735
+ writeFileSync(
736
+ join(artifactDir, "classification.json"),
737
+ `${JSON.stringify(
738
+ {
739
+ verdict: "INVALIDATED",
740
+ reason: "cli-target-drift",
741
+ cliTargetPath: CLI_TARGET.path,
742
+ sha256Before: CLI_TARGET.sha256,
743
+ sha256After: rehash,
744
+ },
745
+ null,
746
+ 2,
747
+ )}\n`,
748
+ );
749
+ fail(
750
+ `cli target ${CLI_TARGET.path} changed content during the pair (sha256 ${CLI_TARGET.sha256} → ${rehash}) — ` +
751
+ `the runs did not share one stimulus; pair INVALIDATED, artifact preserved at ${artifactDir}`,
752
+ );
753
+ }
754
+ // A corrupted line is a run-invalidating state, not a footnote: the missing
755
+ // line could be the very wire marker whose absence the classifier would then
756
+ // read as evidence. The artifact must say so ON ITS FACE — a reader of the
757
+ // directory alone must find INVALIDATED as the verdict, never a
758
+ // judgeable-looking thin-log classification (GPT review round 2). The
759
+ // thin-log reading is preserved INSIDE the wrapper for forensics only.
760
+ // The stream door is the same kind of refusal one layer out: individually valid
761
+ // lines whose per-writer order cannot be trusted. `tsMs`/`seq` ARE the ordering
762
+ // evidence, so a log that cannot vouch for them cannot answer §11-7 either.
763
+ if (malformed.length > 0 || sequenceViolations.length > 0) {
764
+ if (malformed.length > 0) writeFileSync(join(artifactDir, "malformed-lines.txt"), `${malformed.join("\n")}\n`);
765
+ if (sequenceViolations.length > 0) {
766
+ writeFileSync(join(artifactDir, "sequence-violations.txt"), `${sequenceViolations.join("\n")}\n`);
767
+ }
768
+ const reason = malformed.length > 0 ? "malformed-event-log" : "event-log-order-violation";
769
+ writeFileSync(
770
+ join(artifactDir, "classification.json"),
771
+ `${JSON.stringify(
772
+ {
773
+ verdict: "INVALIDATED",
774
+ reason,
775
+ malformedLines: malformed.length,
776
+ sequenceViolations,
777
+ thinLogClassificationForForensicsOnly: classification,
778
+ },
779
+ null,
780
+ 2,
781
+ )}\n`,
782
+ );
783
+ fail(
784
+ `${malformed.length} malformed line(s) and ${sequenceViolations.length} per-writer order violation(s) — ` +
785
+ `the log is not a judgeable record; run INVALIDATED, artifact preserved at ${artifactDir}`,
786
+ );
787
+ }
788
+ writeFileSync(join(artifactDir, "classification.json"), `${JSON.stringify(classification, null, 2)}\n`);
789
+
790
+ console.log(`[smoke-acp-ordering-probe-live] validity: ${classification.status.validity}`);
791
+ console.log(
792
+ ` (a) ordering measurement: ${classification.status.orderingMeasurement} — ${classification.ordering.summary}`,
793
+ );
794
+ console.log(` (b) failure verdict: ${classification.status.failureVerdict}`);
795
+ console.log(` composite verdict: ${classification.verdict}`);
796
+ console.log(` ${classification.detail}`);
797
+ console.log(` expectedProviderToolId: ${classification.expectedProviderToolId ?? "(unmeasured)"}`);
798
+ for (const r of classification.interventions) {
799
+ console.log(
800
+ ` ${r.runId} D=${r.delayMs}ms → ${r.kind}${r.promotable ? " [PROMOTABLE]" : ""}` +
801
+ `${r.newSessionExcessMs !== undefined ? ` (newSession excess ${r.newSessionExcessMs}ms)` : ""}: ${r.evidence}`,
802
+ );
803
+ }
804
+ console.log(` artifact preserved: ${artifactDir}`);
805
+
806
+ // EXIT CONTRACT — three separate questions, not one (GPT review 2026-07-29).
807
+ // The old contract failed the run whenever the composite verdict was not one of
808
+ // the judgeable labels, which meant a pair that successfully MEASURED its
809
+ // ordering axis was reported as a failed run because the callability axis had
810
+ // no marker. That is the same conflation the two axes exist to undo.
811
+ //
812
+ // 1. fatal validity — under P0 / I0 / INVALIDATED nothing was measured;
813
+ // 2. axis (a) — did an ordering comparison get made at all;
814
+ // 3. axis (b) — the callability verdict, which may legitimately be
815
+ // inconclusive without the run having failed.
816
+ //
817
+ // A run exits non-zero only when it produced NOTHING: fatal validity, or both
818
+ // axes empty. `measured` on axis (a) is NOT a claim that the server waited or
819
+ // did not — it says the comparison exists and is recorded.
820
+ if (classification.status.validity !== "valid" && classification.status.validity !== "partial") {
821
+ fail(
822
+ `validity ${classification.status.validity} — nothing was measured (artifact preserved for classification): ${classification.detail}`,
823
+ );
824
+ }
825
+ if (classification.status.invalidRuns.length > 0) {
826
+ // A pair that lost a delay point still reports what it measured, but it may
827
+ // never be read as a complete series — A in particular needs every point.
828
+ console.log(
829
+ ` PARTIAL: ${classification.status.invalidRuns.map((r) => `${r.runId}=${r.reason}`).join(", ")} — ` +
830
+ "this pair is missing delay point(s); do not read it as a complete series",
831
+ );
832
+ }
833
+ const judgeableFailure = ["B", "B-name-snapshot", "C", "callable"];
834
+ if (
835
+ classification.status.orderingMeasurement !== "measured" &&
836
+ !judgeableFailure.includes(classification.status.failureVerdict)
837
+ ) {
838
+ fail(
839
+ `neither axis produced a measurement (ordering=${classification.status.orderingMeasurement}, ` +
840
+ `failure=${classification.status.failureVerdict}) — artifact preserved for classification`,
841
+ );
842
+ }
843
+ console.log(
844
+ "[smoke-acp-ordering-probe-live] run completed with a recorded measurement — this is NOT a claim about server wait behavior",
845
+ );
846
+ }
847
+
848
+ await main();