@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,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
|
+
}
|