@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,165 @@
1
+ // §11-7-c CLI-target precondition seam (docs/acp-backend-rail.md).
2
+ //
3
+ // The B-name-snapshot oracle rides a probe-only shim at CLAUDE_CODE_EXECUTABLE,
4
+ // and the ONE way that override seam stays closed without duplicating upstream
5
+ // launch semantics is to narrow the precondition honestly instead of proving
6
+ // "operator override meaning is preserved" in general. Measured facts the
7
+ // narrowing rests on (installed dists, pinned by check-probe-ordering):
8
+ //
9
+ // - `claudeCliPath()` returns an ambient CLAUDE_CODE_EXECUTABLE VERBATIM —
10
+ // no resolution, no validation (acp-agent.js:204-207). Only with the env
11
+ // unset does it resolve the platform native binary to an ABSOLUTE path.
12
+ // - The SDK picks its launch branch off a pure suffix test: a path ending in
13
+ // one of SDK_SCRIPT_SUFFIXES is run as `node|bun <path> <flags>`, anything
14
+ // else is spawned DIRECTLY (sdk.mjs, claude-agent-sdk 0.3.219). The list
15
+ // has live sharp edges (`.cjs` is absent), which is exactly why this repo
16
+ // asserts against it instead of re-implementing it: a second copy of
17
+ // upstream semantics is a drift channel, not a contract.
18
+ //
19
+ // So the probe REFUSES to run under an ambient override (a named precondition
20
+ // failure, not a fallback), resolves the target once in that ambient-clean
21
+ // state, and then only ASSERTS the result is the shape the native branch
22
+ // spawns directly. The asserts are a gate, never launch logic.
23
+
24
+ import { createHash } from "node:crypto";
25
+ import { accessSync, constants as fsConstants, readFileSync, statSync } from "node:fs";
26
+ import { isAbsolute } from "node:path";
27
+
28
+ /** The upstream executable-override env var this seam refuses to run under. */
29
+ export const AMBIENT_OVERRIDE_ENV = "CLAUDE_CODE_EXECUTABLE";
30
+
31
+ /** The SDK's script-branch suffix list — a path ending in one of these is run
32
+ * as `node|bun <path>`, anything else is spawned directly. PINNED against the
33
+ * installed sdk.mjs by check-probe-ordering; asserted here, never implemented:
34
+ * the probe refuses a script-suffixed target rather than reproducing the
35
+ * interpreter choice. */
36
+ export const SDK_SCRIPT_SUFFIXES = [".js", ".mjs", ".tsx", ".ts", ".jsx"] as const;
37
+
38
+ /** Env names the probe runner sets for the shim on the ACP child (the SDK's
39
+ * `{...process.env}` spread carries them into the CLI child's env, which is
40
+ * where the shim reads them). Single source — the shim, the runner, and the
41
+ * scrub list below must agree exactly. */
42
+ export const PROBE_SHIM_ENV = {
43
+ /** Absolute native CLI path the shim must exec — resolved HERE, never by the shim. */
44
+ target: "PROBE_SHIM_TARGET",
45
+ /** The shared NDJSON event log path (same file every other writer appends to). */
46
+ eventLog: "PROBE_SHIM_EVENT_LOG",
47
+ /** The §11-7 runId the shim stamps on every event it writes. */
48
+ runId: "PROBE_SHIM_RUN_ID",
49
+ } as const;
50
+
51
+ /** The EXACT allowlist of env vars the shim removes from the real CLI child's
52
+ * env before exec — the override itself plus every probe-private var, each by
53
+ * its literal name. Deliberately NOT a prefix/wildcard scrub: a pattern like
54
+ * `PROBE_*` would also delete operator env this probe has no claim on (GPT
55
+ * review 2026-07-29). Under the ambient-override refusal above, deletion is
56
+ * exact preservation — there is no prior operator value to restore. */
57
+ export const SHIM_SCRUB_ENV_VARS: ReadonlyArray<string> = [
58
+ AMBIENT_OVERRIDE_ENV,
59
+ PROBE_SHIM_ENV.target,
60
+ PROBE_SHIM_ENV.eventLog,
61
+ PROBE_SHIM_ENV.runId,
62
+ ];
63
+
64
+ export type ProbeCliPreconditionReason =
65
+ | "ambient-override-present"
66
+ | "target-not-absolute"
67
+ | "target-script-suffix"
68
+ | "target-missing"
69
+ | "target-not-regular-file"
70
+ | "target-not-executable";
71
+
72
+ /** A named precondition failure — the P0-style refusal §11-7-c condition 1
73
+ * requires. Callers must surface `reason` on the artifact, never soften it
74
+ * into a fallback. */
75
+ export class ProbeCliPreconditionError extends Error {
76
+ readonly reason: ProbeCliPreconditionReason;
77
+ constructor(reason: ProbeCliPreconditionReason, message: string) {
78
+ super(message);
79
+ this.name = "ProbeCliPreconditionError";
80
+ this.reason = reason;
81
+ }
82
+ }
83
+
84
+ export interface ResolvedProbeCliTarget {
85
+ path: string;
86
+ sha256: string;
87
+ }
88
+
89
+ export function hashFileSha256(path: string): string {
90
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
91
+ }
92
+
93
+ /** Refuse if the given env carries the upstream override — KEY PRESENCE is the
94
+ * predicate, an empty string included. Upstream consumers disagree about
95
+ * empty (`??` at acp-agent.js:4083 treats "" as set and passes it on; a
96
+ * truthy check treats it as unset), and the probe refuses the ambiguity
97
+ * instead of picking a side. Used twice: on the runner's own process.env
98
+ * before resolving, and on the COMPOSED spawn env of every ACP child (adapter
99
+ * launch defaults / overlay overrides could inject what process.env did not
100
+ * carry). */
101
+ export function assertNoAmbientOverride(env: Record<string, string | undefined>, context: string): void {
102
+ if (env[AMBIENT_OVERRIDE_ENV] !== undefined) {
103
+ throw new ProbeCliPreconditionError(
104
+ "ambient-override-present",
105
+ `${AMBIENT_OVERRIDE_ENV}=${JSON.stringify(env[AMBIENT_OVERRIDE_ENV])} is present (${context}) — §11-7-c ` +
106
+ "refuses to run under an ambient executable override, empty included: claudeCliPath() would return it " +
107
+ "VERBATIM (relative paths, bare PATH commands and script overrides all change launch semantics), and " +
108
+ "preserving arbitrary operator override shapes is explicitly out of probe scope. Unset it and re-run.",
109
+ );
110
+ }
111
+ }
112
+
113
+ /** Resolve the CLI target for the pair, under the §11-7-c precondition gate:
114
+ * ambient-clean env → upstream resolution → assert absolute ∧ native-branch ∧
115
+ * present → content hash. The resolver is injected so the deterministic gate
116
+ * can drive every refusal without touching the installed dist; the LIVE runner
117
+ * passes upstream `claudeCliPath` (a version-pinned deep import whose
118
+ * disappearance breaks check-probe-ordering, not a LIVE run). */
119
+ export async function resolveProbeCliTarget(opts: {
120
+ env: Record<string, string | undefined>;
121
+ resolveNative: () => Promise<string>;
122
+ }): Promise<ResolvedProbeCliTarget> {
123
+ assertNoAmbientOverride(opts.env, "probe runner env");
124
+ const path = await opts.resolveNative();
125
+ if (!isAbsolute(path)) {
126
+ throw new ProbeCliPreconditionError(
127
+ "target-not-absolute",
128
+ `resolved CLI target ${JSON.stringify(path)} is not an absolute path — a bare PATH command or relative ` +
129
+ "path resolves against the SESSION cwd at spawn time (child_process.spawn semantics), which is " +
130
+ "stimulus drift, not the pinned native binary",
131
+ );
132
+ }
133
+ if (SDK_SCRIPT_SUFFIXES.some((s) => path.endsWith(s))) {
134
+ throw new ProbeCliPreconditionError(
135
+ "target-script-suffix",
136
+ `resolved CLI target ${path} ends in a script suffix — the SDK would run it as \`node|bun <path>\`, a ` +
137
+ "different launch branch than the direct spawn this seam is specified against; the probe asserts the " +
138
+ "native branch instead of reproducing the interpreter choice",
139
+ );
140
+ }
141
+ let stat: ReturnType<typeof statSync>;
142
+ try {
143
+ stat = statSync(path);
144
+ } catch {
145
+ throw new ProbeCliPreconditionError(
146
+ "target-missing",
147
+ `resolved CLI target ${path} does not exist — refusing before a LIVE turn spends money on a spawn error`,
148
+ );
149
+ }
150
+ if (!stat.isFile()) {
151
+ throw new ProbeCliPreconditionError(
152
+ "target-not-regular-file",
153
+ `resolved CLI target ${path} is not a regular file — a directory or special file cannot be the native binary`,
154
+ );
155
+ }
156
+ try {
157
+ accessSync(path, fsConstants.X_OK);
158
+ } catch {
159
+ throw new ProbeCliPreconditionError(
160
+ "target-not-executable",
161
+ `resolved CLI target ${path} is not executable (X_OK) — spawning it would fail after the pair started`,
162
+ );
163
+ }
164
+ return { path, sha256: hashFileSha256(path) };
165
+ }
@@ -0,0 +1,383 @@
1
+ // §11-7 ordering probe — the ONE shared NDJSON event log (docs/acp-backend-rail.md).
2
+ //
3
+ // Every participating process (the ACP-side probe runner AND the MCP fixture the
4
+ // ACP child spawns) appends single-line JSON events to the SAME file, so ordering
5
+ // is read off a common wall-clock axis instead of being reconstructed from
6
+ // separate logs with drifting clocks. Contract per §11-7:
7
+ // - append-only; one event per line;
8
+ // - each line carries the runId, a shared wall-clock stamp, the writing pid,
9
+ // and a monotonic per-process counter (seq) so same-ms events keep their
10
+ // in-process order. That counter is now VERIFIED, not merely produced:
11
+ // `readProbeEvents` walks the raw append order and refuses a log whose per-pid
12
+ // seq repeats or whose per-pid clock runs backwards (GPT review 2026-07-29 —
13
+ // the door checked `seq` was a safe integer and nothing more, so the stated
14
+ // "monotonic" property was a convention the evidence never had to keep).
15
+ //
16
+ // Writes use appendFileSync (O_APPEND) with exactly ONE write per line: on a
17
+ // POSIX local filesystem the O_APPEND offset update + write is atomic per call,
18
+ // so concurrent writers interleave at line granularity, never inside a line.
19
+ // (PIPE_BUF is irrelevant here — that bound governs pipes/FIFOs, not
20
+ // regular-file appends.)
21
+
22
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Event vocabulary — shared by fixture, runner, and classifier. A name used by
26
+ // a writer that the classifier does not know is a contract bug, so both sides
27
+ // import from here instead of retyping strings.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const PROBE_EVENTS = {
31
+ // fixture-side (MCP wire)
32
+ fixtureProcessStart: "fixture_process_start",
33
+ fixtureDelayStart: "fixture_delay_start",
34
+ fixtureDelayEnd: "fixture_delay_end",
35
+ fixtureTransportConnected: "fixture_transport_connected",
36
+ fixtureInitializeReceived: "fixture_initialize_received",
37
+ fixtureToolsListReceived: "fixture_tools_list_received",
38
+ // THE wire-availability proxy (§11-7): the ENTIRE expected-tool tools/list
39
+ // response frame was write()-n to downstream stdio AND the write callback
40
+ // fired. Never a readiness claim — the client may not have parsed it yet.
41
+ toolsListResponseForwarded: "tools_list_response_forwarded",
42
+ fixtureToolsCallReceived: "fixture_tools_call_received",
43
+ fixtureToolsCallReplied: "fixture_tools_call_replied",
44
+ // ACP-side (probe runner)
45
+ runStart: "run_start",
46
+ initializeStart: "initialize_start",
47
+ initializeEnd: "initialize_end",
48
+ newSessionStart: "new_session_start",
49
+ newSessionEnd: "new_session_end",
50
+ setModelStart: "set_model_start",
51
+ setModelEnd: "set_model_end",
52
+ promptStart: "prompt_start",
53
+ promptEnd: "prompt_end",
54
+ acpToolCallObserved: "acp_tool_call_observed",
55
+ /** Forensic-only: EVERY raw tool_call / tool_call_update frame (capped). The
56
+ * classifier ignores it — it exists so a failed extraction is diagnosable
57
+ * from the artifact instead of leaving the observed event silently absent. */
58
+ acpToolCallRaw: "acp_tool_call_raw",
59
+ acpNoSuchTool: "acp_no_such_tool",
60
+ promptReply: "prompt_reply",
61
+ /** The OBSERVATION WINDOW's close, stamped by the runner BEFORE teardown.
62
+ * §11-7 measured why this must exist: the first LIVE pair tore the child down
63
+ * at turn end while the fixture was still inside its injected delay, so the
64
+ * wire marker could never land — and the classifier read that self-inflicted
65
+ * absence as an MCP handshake / fixture / config candidate. Absence is only
66
+ * an observation when WE kept the window open long enough to see it, so the
67
+ * runner records how the window closed and whether the marker was seen. */
68
+ observationWindowEnd: "probe_observation_window_end",
69
+ runEnd: "run_end",
70
+ // CLI-shim side (§11-7-c B-name-snapshot seam). Both halves are built and the
71
+ // LIVE runner ARMS the channel: classifier + doors judge these, and the shim
72
+ // (scripts/lib/probe-cli-shim.ts) emits them. check-probe-ordering exercises
73
+ // the consumer side through synthetic logs and check-probe-cli-shim drives the
74
+ // producer against fake CLIs. A run's roster entry still says whether the
75
+ // channel was armed (`snapshotInstrumented`) — evidence without that
76
+ // declaration never promotes, and that stays the contract whatever the runner
77
+ // currently declares.
78
+ /** The shim process is up and knows its exec target — payload carries the
79
+ * resolved target path + sha256 so a silently REPLACED override (e.g. the
80
+ * adapter's managed-policy env application overwriting process.env inside
81
+ * the ACP child) is a NAMED absence instead of an anonymous no-snapshot. */
82
+ shimBoot: "shim_boot",
83
+ /** The serialized prompt input frame for ordinal N was fully written to the
84
+ * real CLI child's stdin. The §11-7-c ordinal binding anchor: only init
85
+ * snapshots observed AFTER this marker are candidates for this prompt. */
86
+ shimPromptForwarded: "shim_prompt_forwarded",
87
+ /** One complete `system`/`init` NDJSON line observed on the real CLI's
88
+ * stdout. The snapshot timestamp is an INTERVAL, not a point: payload
89
+ * carries `receivedAtMs` (full line received) and the event's own envelope
90
+ * `tsMs` is the interval END — the shim MUST append this event inside the
91
+ * downstream write callback, so the one clock read that stamps the line IS
92
+ * the callback moment (a separate payload end-field would be a second SSOT
93
+ * the door could not hold coherent; GPT review 2026-07-29). A wire marker
94
+ * inside [receivedAtMs, tsMs] is unordered. */
95
+ shimInitSnapshot: "shim_init_snapshot",
96
+ } as const;
97
+
98
+ export type ProbeEventName = (typeof PROBE_EVENTS)[keyof typeof PROBE_EVENTS];
99
+
100
+ /** How the observation window closed. The reason is NOT decoration — it decides
101
+ * whether a missing wire marker is evidence or an artifact of our own teardown:
102
+ * - `wire-marker` the marker landed; the window closed because it did its job.
103
+ * - `deadline` we waited past the fixture's own delay end plus slack and the
104
+ * marker never came. The window WAS sufficient, so absence is a
105
+ * real reading (handshake / fixture / config candidate).
106
+ * - `child-exit` the ACP child ended before either. The window closed for a
107
+ * reason outside the marker → CENSORED, never an attribution.
108
+ * - `run-failed` the turn failed at a phase; the window question is moot and
109
+ * the phase reading (D-*) owns the run. Stamped anyway so the
110
+ * exactly-once topology holds on the failure path too. */
111
+ export const PROBE_WINDOW_REASONS = ["wire-marker", "deadline", "child-exit", "run-failed"] as const;
112
+ export type ProbeWindowReason = (typeof PROBE_WINDOW_REASONS)[number];
113
+ const WINDOW_REASONS: ReadonlySet<string> = new Set(PROBE_WINDOW_REASONS);
114
+
115
+ /** The one tool the probe expects to find in the schema. SSOT for all three
116
+ * layers — fixture (serves it), parser (the wire marker must NAME it), and
117
+ * classifier (the callability marker must be it). Three private copies of this
118
+ * string could drift apart silently, which is the exact drift this seam exists
119
+ * to make loud. */
120
+ export const PROBE_EXPECTED_TOOL = "probe_nonce";
121
+
122
+ const KNOWN_EVENT_NAMES: ReadonlySet<string> = new Set(Object.values(PROBE_EVENTS));
123
+
124
+ /** Vocabulary membership — the contract every writer shares with the classifier. */
125
+ export function isProbeEventName(name: unknown): name is ProbeEventName {
126
+ return typeof name === "string" && KNOWN_EVENT_NAMES.has(name);
127
+ }
128
+
129
+ export interface ProbeEvent {
130
+ seq: number;
131
+ pid: number;
132
+ ts: string;
133
+ tsMs: number;
134
+ runId: string;
135
+ event: ProbeEventName;
136
+ [key: string]: unknown;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Envelope contract — the log IS the evidence, so its door is a gate.
141
+ //
142
+ // Every line is the six-key envelope {seq,pid,ts,tsMs,runId,event} plus free
143
+ // payload. The envelope belongs to the writer, and it is exactly what §11-7
144
+ // judges on, so a JSON-valid line that breaks it is NOT a tolerable oddity:
145
+ // - `event` is the marker vocabulary. An unknown name is not a new event, it
146
+ // is a marker that silently went MISSING — and absence is precisely what
147
+ // the classifier reads as evidence (the B / candidate branches).
148
+ // - `tsMs` (with `pid`/`seq` for ties) is the shared sort axis. A missing or
149
+ // non-numeric stamp makes the comparator NaN, un-orders the whole log, and
150
+ // turns the ordering reads (ran-ahead / ordering-kept) into file order.
151
+ // - `ts` is the readable twin of `tsMs` and must agree with it EXACTLY; the
152
+ // writer derives one from the other, so disagreement means a rewritten line.
153
+ // Such a line leaves through the same door as a truncated one — `malformed`,
154
+ // which the LIVE runner turns into an INVALIDATED run instead of a verdict.
155
+ //
156
+ // The envelope alone is not enough. The classifier judges on PAYLOAD fields, so
157
+ // a line with a valid envelope and a known marker name can still be a lie:
158
+ // `tools_list_response_forwarded` whose `tools` does not name the expected tool
159
+ // is not wire-availability at all, and a phase end whose `ok` is not a boolean
160
+ // reads as a phase FAILURE (`ok === true` is false for "true", 1, null). Those
161
+ // silently move the verdict instead of invalidating the run — the same
162
+ // "healthy-looking corruption" the envelope check closes one layer up. So every
163
+ // event the classifier reads payload off carries a rule below, and a line that
164
+ // breaks its rule is malformed too. Events the classifier does not judge on
165
+ // (forensics: raw frames, delays, jsonrpc ids) deliberately have no rule —
166
+ // PAYLOAD_CONTRACT_EVENTS is exactly the judged set, and the gate pins it
167
+ // against a hand-written literal so a new classifier read cannot quietly land
168
+ // without a door rule.
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** The six keys the WRITER owns. A payload carrying one of them could rewrite
172
+ * the run id, the marker name, or the sort axis OF ITS OWN evidence line, so
173
+ * the write is refused rather than resolved by key order. */
174
+ export const RESERVED_EVENT_KEYS = ["seq", "pid", "ts", "tsMs", "runId", "event"] as const;
175
+
176
+ function isWellFormedEnvelope(value: unknown): value is ProbeEvent {
177
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
178
+ const e = value as Record<string, unknown>;
179
+ if (!isProbeEventName(e.event)) return false;
180
+ if (typeof e.runId !== "string" || e.runId.length === 0) return false;
181
+ if (!Number.isSafeInteger(e.seq) || (e.seq as number) < 0) return false;
182
+ if (!Number.isSafeInteger(e.pid) || (e.pid as number) <= 0) return false;
183
+ if (!Number.isSafeInteger(e.tsMs) || (e.tsMs as number) <= 0) return false;
184
+ if (typeof e.ts !== "string" || Date.parse(e.ts) !== e.tsMs) return false;
185
+ return true;
186
+ }
187
+
188
+ type PayloadRule = (e: ProbeEvent) => boolean;
189
+
190
+ const isString = (v: unknown): boolean => typeof v === "string";
191
+ const isBoolean = (v: unknown): boolean => typeof v === "boolean";
192
+ /** Absent is allowed; present must hold. Used where the WRITER legitimately has
193
+ * nothing to record — never as a softener for a field it always emits. */
194
+ const optional = (v: unknown, holds: (x: unknown) => boolean): boolean => v === undefined || holds(v);
195
+
196
+ // A phase end is the D/P0/I0 axis: `ok` decides whether the phase passed, so a
197
+ // non-boolean must never be read (it would silently mean "failed").
198
+ const phaseEndRule: PayloadRule = (e) =>
199
+ isBoolean(e.ok) && optional(e.timedOut, isBoolean) && optional(e.error, isString);
200
+
201
+ const PAYLOAD_RULES: Partial<Record<ProbeEventName, PayloadRule>> = {
202
+ [PROBE_EVENTS.initializeEnd]: phaseEndRule,
203
+ [PROBE_EVENTS.newSessionEnd]: phaseEndRule,
204
+ [PROBE_EVENTS.setModelEnd]: phaseEndRule,
205
+ [PROBE_EVENTS.promptEnd]: phaseEndRule,
206
+ // THE wire-availability proxy. Its meaning is "the expected tool's tools/list
207
+ // frame reached the pipe" — a marker not naming that tool is not that event.
208
+ [PROBE_EVENTS.toolsListResponseForwarded]: (e) =>
209
+ Array.isArray(e.tools) && e.tools.every(isString) && e.tools.includes(PROBE_EXPECTED_TOOL),
210
+ // Callability marker. BOTH fields are optional by observation: the fixture
211
+ // stamps the inbound call BEFORE validating it, so a call carrying neither a
212
+ // tool name nor a join key is a real reading (the absence branch) and that
213
+ // line is kept. What typed-if-present refuses is a field that is PRESENT and
214
+ // unusable — e.g. a numeric probeRunId, which would fail correlation silently
215
+ // and push the run toward absence.
216
+ [PROBE_EVENTS.fixtureToolsCallReceived]: (e) => optional(e.tool, isString) && optional(e.probeRunId, isString),
217
+ // ACP-side visibility. providerToolId is optional (a frame carrying neither
218
+ // name nor title yields no id — the classifier reads that as P0/unmeasured);
219
+ // probeRunId is REQUIRED because the runner only emits after it is a string.
220
+ [PROBE_EVENTS.acpToolCallObserved]: (e) => optional(e.providerToolId, isString) && isString(e.probeRunId),
221
+ [PROBE_EVENTS.acpNoSuchTool]: (e) => isString(e.toolId),
222
+ [PROBE_EVENTS.promptReply]: (e) => isBoolean(e.carriesNonce),
223
+ // The window close. `reason` decides whether a missing wire marker is a
224
+ // reading or our own teardown, and `markerSeen` is the fact it is read
225
+ // against — an unknown reason string would silently fall through to the
226
+ // permissive branch of whatever consumes it, so the vocabulary is closed here.
227
+ [PROBE_EVENTS.observationWindowEnd]: (e) =>
228
+ typeof e.reason === "string" && WINDOW_REASONS.has(e.reason) && isBoolean(e.markerSeen),
229
+ // §11-7-c shim events — every field the classifier judges on is typed at the
230
+ // door, same bar as the rest. `shim_boot` names the exec target so target
231
+ // hijack/drift is a named finding; the snapshot's interval END is the
232
+ // event's own envelope tsMs (stamped in the downstream write callback — one
233
+ // SSOT), so the door holds `receivedAtMs ≤ tsMs` or the "after the wire"
234
+ // read would be built on a corrupt interval while looking healthy.
235
+ [PROBE_EVENTS.shimBoot]: (e) => isString(e.targetPath) && isString(e.targetSha256),
236
+ [PROBE_EVENTS.shimPromptForwarded]: (e) => Number.isSafeInteger(e.ordinal) && (e.ordinal as number) >= 1,
237
+ [PROBE_EVENTS.shimInitSnapshot]: (e) =>
238
+ Array.isArray(e.tools) &&
239
+ e.tools.every(isString) &&
240
+ Number.isSafeInteger(e.receivedAtMs) &&
241
+ (e.receivedAtMs as number) > 0 &&
242
+ (e.receivedAtMs as number) <= e.tsMs,
243
+ };
244
+
245
+ /** Exactly the events the classifier judges payload on — the gate pins this
246
+ * against a hand-written literal, so a new read without a rule turns red. */
247
+ export const PAYLOAD_CONTRACT_EVENTS = Object.keys(PAYLOAD_RULES) as ProbeEventName[];
248
+
249
+ function payloadHoldsContract(e: ProbeEvent): boolean {
250
+ const rule = PAYLOAD_RULES[e.event];
251
+ return rule === undefined || rule(e);
252
+ }
253
+
254
+ // Env names the runner sets on the fixture's mcpServers entry. The fixture and
255
+ // the runner must agree on these exactly; single source here.
256
+ export const PROBE_ENV = {
257
+ eventLog: "PROBE_MCP_EVENT_LOG",
258
+ startupDelayMs: "PROBE_MCP_STARTUP_DELAY_MS",
259
+ runId: "PROBE_RUN_ID",
260
+ nonce: "PROBE_NONCE",
261
+ } as const;
262
+
263
+ let seqCounter = 0;
264
+
265
+ /** Append one event line. Synchronous on purpose — an async write could reorder
266
+ * against the very marker semantics this log exists to pin down. */
267
+ export function appendProbeEvent(
268
+ logPath: string,
269
+ runId: string,
270
+ event: ProbeEventName,
271
+ payload: Record<string, unknown> = {},
272
+ ): void {
273
+ for (const key of RESERVED_EVENT_KEYS) {
274
+ if (Object.hasOwn(payload, key)) {
275
+ throw new Error(
276
+ `probe event payload may not carry the reserved envelope key "${key}" (event=${event}) — ` +
277
+ "the writer owns seq/pid/ts/tsMs/runId/event",
278
+ );
279
+ }
280
+ }
281
+ // ONE clock read: `ts` is DERIVED from `tsMs`, so the readable stamp and the
282
+ // sort axis can never straddle a millisecond boundary (two separate reads
283
+ // can) — which is what lets the parser demand exact agreement between them.
284
+ const tsMs = Date.now();
285
+ const line: ProbeEvent = {
286
+ ...payload,
287
+ seq: seqCounter++,
288
+ pid: process.pid,
289
+ ts: new Date(tsMs).toISOString(),
290
+ tsMs,
291
+ runId,
292
+ event,
293
+ };
294
+ appendFileSync(logPath, `${JSON.stringify(line)}\n`, "utf8");
295
+ }
296
+
297
+ /** Per-writer stream integrity, checked on the RAW APPEND ORDER — before the
298
+ * sort, on purpose. `seq` and `tsMs` are what the sort trusts, so validating
299
+ * them after sorting would be circular: the comparator would have already
300
+ * rewritten the very order being checked, and a writer whose clock went
301
+ * backwards would come out looking perfectly ordered.
302
+ *
303
+ * Per (runId, pid) writer, walking the file top to bottom:
304
+ * - `seq` must STRICTLY increase. It is a per-process counter, so a repeat is
305
+ * two lines claiming one slot (interleaved writers, a re-entered writer, a
306
+ * spliced log) and the pair is unorderable within its millisecond. GAPS are
307
+ * fine — a process may write to more than one log, and the counter is
308
+ * process-wide, not file-wide.
309
+ * - `tsMs` must not go BACKWARDS. One process reads one clock; a regression
310
+ * means the stamps were rewritten or the clock stepped, and the shared axis
311
+ * is exactly what §11-7's ordering reads are made of.
312
+ * Either violation invalidates the LOG, not a line — the bytes are individually
313
+ * well-formed, so returning them as `malformed` would misname the defect. */
314
+ function findSequenceViolations(events: ProbeEvent[]): string[] {
315
+ const violations: string[] = [];
316
+ const lastSeq = new Map<string, number>();
317
+ const lastTsMs = new Map<string, number>();
318
+ for (const e of events) {
319
+ // WRITER KEY is (runId, pid), not pid alone. The fixture is a fresh child
320
+ // per run and the OS reuses pids: a later run's fixture can be handed the
321
+ // same pid as an earlier one and legitimately start its counter at 0 again.
322
+ // Keying on pid alone would call that healthy log corrupt, and cross-run
323
+ // ordering is not something any §11-7 verdict reads (GPT review 2026-07-29).
324
+ const key = `${e.runId}\u0000${e.pid}`;
325
+ const priorSeq = lastSeq.get(key);
326
+ if (priorSeq !== undefined && e.seq <= priorSeq) {
327
+ violations.push(
328
+ `pid ${e.pid}: seq ${e.seq} does not exceed the preceding ${priorSeq} in append order (event=${e.event}, runId=${e.runId})`,
329
+ );
330
+ }
331
+ const priorTsMs = lastTsMs.get(key);
332
+ if (priorTsMs !== undefined && e.tsMs < priorTsMs) {
333
+ violations.push(
334
+ `pid ${e.pid}: tsMs ${e.tsMs} runs BACKWARDS from the preceding ${priorTsMs} in append order (event=${e.event}, runId=${e.runId})`,
335
+ );
336
+ }
337
+ if (priorSeq === undefined || e.seq > priorSeq) lastSeq.set(key, e.seq);
338
+ if (priorTsMs === undefined || e.tsMs > priorTsMs) lastTsMs.set(key, e.tsMs);
339
+ }
340
+ return violations;
341
+ }
342
+
343
+ /** Parse the shared log. A line is an event only if it clears the envelope
344
+ * contract above; everything else — unparseable OR JSON-valid-but-broken — is
345
+ * returned as malformed, so the caller can invalidate the run. Nothing is ever
346
+ * silently dropped, and nothing broken is ever silently judged.
347
+ *
348
+ * `sequenceViolations` is the second, STREAM-level door: individually valid
349
+ * lines whose per-writer order cannot be trusted. Both lists feed the same
350
+ * INVALIDATED path; they are separate so the artifact says which door refused. */
351
+ export function readProbeEvents(logPath: string): {
352
+ events: ProbeEvent[];
353
+ malformed: string[];
354
+ sequenceViolations: string[];
355
+ } {
356
+ if (!existsSync(logPath)) return { events: [], malformed: [], sequenceViolations: [] };
357
+ const events: ProbeEvent[] = [];
358
+ const malformed: string[] = [];
359
+ for (const line of readFileSync(logPath, "utf8").split("\n")) {
360
+ const trimmed = line.trim();
361
+ if (trimmed.length === 0) continue;
362
+ try {
363
+ const parsed: unknown = JSON.parse(trimmed);
364
+ if (!isWellFormedEnvelope(parsed) || !payloadHoldsContract(parsed)) {
365
+ malformed.push(trimmed);
366
+ continue;
367
+ }
368
+ events.push(parsed);
369
+ } catch {
370
+ malformed.push(trimmed);
371
+ }
372
+ }
373
+ // RAW append order — must run before the sort below (see the function's note).
374
+ const sequenceViolations = findSequenceViolations(events);
375
+ // One shared axis: wall-clock stamp, then per-process seq for same-ms
376
+ // stability. Cross-process same-ms ties are resolved by pid only to keep the
377
+ // sort total — the classifier never reads meaning into a same-ms cross-pid
378
+ // tie. Totality holds because the envelope check already guaranteed all three
379
+ // fields are finite integers; an unvalidated line would make this comparator
380
+ // return NaN and leave the order implementation-defined.
381
+ events.sort((a, b) => a.tsMs - b.tsMs || a.pid - b.pid || a.seq - b.seq);
382
+ return { events, malformed, sequenceViolations };
383
+ }