@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.
- package/AGENTS.md +135 -248
- package/BASELINE.md +46 -7
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +4 -2
- package/DELIVERY.md +5 -4
- package/README.md +46 -18
- package/VERIFY.md +10 -6
- package/demo/demo.sh +7 -1
- package/docs/setup-clean-host.md +10 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +159 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +26 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +10 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +15 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +13 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +31 -41
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +8 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +32 -25
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +5 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +9 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +17 -20
- package/mcp/entwurf-bridge/src/index.ts +190 -82
- package/package.json +7 -3
- package/pi/settings.reference.json +1 -1
- package/pi-extensions/acp-provider.ts +20 -10
- package/pi-extensions/entwurf-control.ts +69 -27
- package/pi-extensions/lib/acp/augment.ts +60 -2
- package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
- package/pi-extensions/lib/acp/backend.ts +5 -1
- package/pi-extensions/lib/acp/config.ts +19 -5
- package/pi-extensions/lib/acp/engraving.ts +3 -1
- package/pi-extensions/lib/acp/event-mapper.ts +10 -3
- package/pi-extensions/lib/acp/models.ts +69 -7
- package/pi-extensions/lib/acp/overlay.ts +234 -3
- package/pi-extensions/lib/acp/tool-surface.ts +12 -4
- package/pi-extensions/lib/entwurf-core.ts +26 -9
- package/pi-extensions/lib/entwurf-deliverability.ts +10 -6
- package/pi-extensions/lib/entwurf-fact-provider.ts +15 -17
- package/pi-extensions/lib/entwurf-facts.ts +13 -13
- package/pi-extensions/lib/entwurf-resume-args.ts +40 -46
- package/pi-extensions/lib/entwurf-self-address.ts +8 -9
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +7 -2
- package/pi-extensions/lib/entwurf-v2-contract.ts +32 -25
- package/pi-extensions/lib/entwurf-v2-decider.ts +8 -8
- package/pi-extensions/lib/entwurf-v2-lock.ts +5 -4
- package/pi-extensions/lib/entwurf-v2-production.ts +14 -11
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +3 -1
- package/pi-extensions/lib/meta-session.ts +17 -20
- package/run.sh +200 -62
- package/scripts/agy-bridge-config.py +249 -49
- package/scripts/agy-bridge.sh +59 -14
- package/scripts/check-acp-carrier-augment.ts +34 -2
- package/scripts/check-acp-cortex.ts +668 -0
- package/scripts/check-acp-provider-surface.ts +50 -6
- package/scripts/check-acp-sdk-surface.ts +22 -11
- package/scripts/check-acp-session-reuse.ts +64 -1
- package/scripts/check-agy-permission-matrix.py +655 -0
- package/scripts/check-entwurf-deliverability.ts +9 -6
- package/scripts/check-entwurf-fact-provider.ts +7 -7
- package/scripts/check-entwurf-facts.ts +12 -12
- package/scripts/check-entwurf-resume-args.ts +22 -60
- package/scripts/check-entwurf-self-address.ts +187 -4
- package/scripts/check-entwurf-v2-contract.ts +2 -2
- package/scripts/check-entwurf-v2-production.ts +5 -5
- package/scripts/check-entwurf-v2-spawn-production.ts +6 -2
- package/scripts/check-entwurf-v2-surface.ts +327 -14
- package/scripts/check-gate-qualification.ts +787 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-probe-cli-shim.ts +879 -0
- package/scripts/check-probe-ordering.ts +2450 -0
- package/scripts/check-shell-quote.ts +5 -4
- package/scripts/fixtures/probe-cli-shim +20 -0
- package/scripts/fixtures/probe-mcp-server.ts +168 -12
- package/scripts/lib/mutation-qualify.ts +794 -0
- package/scripts/lib/probe-acp-turn.ts +207 -0
- package/scripts/lib/probe-cli-shim.ts +464 -0
- package/scripts/lib/probe-cli-target.ts +165 -0
- package/scripts/lib/probe-event-log.ts +383 -0
- package/scripts/lib/probe-verdict.ts +1213 -0
- package/scripts/mutants/acp-augment.json +30 -0
- package/scripts/mutants/acp-cortex.json +196 -0
- package/scripts/mutants/agy-permission.json +144 -0
- package/scripts/mutants/meta-identity.json +17 -0
- package/scripts/mutants/probe-ordering.json +1032 -0
- package/scripts/mutants/self-address.json +59 -0
- package/scripts/mutants/v2-surface.json +87 -0
- package/scripts/smoke-acp-cortex-live.ts +392 -0
- package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-v2-send-live.ts +33 -18
- package/scripts/smoke-agy-install-state.sh +205 -20
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -2
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +0 -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();
|