@junghanacs/entwurf 0.14.0 → 0.14.2

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 (54) hide show
  1. package/AGENTS.md +13 -2
  2. package/CHANGELOG.md +63 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +16 -7
  5. package/VERIFY.md +4 -4
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/external-mcp-host.md +1 -1
  11. package/docs/setup-clean-host.md +3 -3
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
  16. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  17. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  18. package/mcp/entwurf-bridge/src/index.ts +14 -5
  19. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  20. package/package.json +9 -9
  21. package/pi-extensions/entwurf-control.ts +13 -4
  22. package/pi-extensions/lib/acp/backend.ts +229 -9
  23. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  24. package/pi-extensions/lib/mux-fresh-call.ts +57 -4
  25. package/pi-extensions/lib/mux-resume-call.ts +21 -53
  26. package/run.sh +70 -25
  27. package/scripts/agy-bridge-config.py +47 -13
  28. package/scripts/agy-bridge.sh +73 -23
  29. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  30. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  31. package/scripts/check-gate-qualification.ts +5 -3
  32. package/scripts/check-mux-resume-call.ts +11 -10
  33. package/scripts/check-probe-bridge-command.ts +201 -0
  34. package/scripts/check-release-gate-outcomes.ts +54 -1
  35. package/scripts/doctor-pi-provider.ts +155 -51
  36. package/scripts/meta-bridge-state.py +75 -1
  37. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  38. package/scripts/mutants/bridge-command-boot.json +107 -0
  39. package/scripts/mutants/meta-retire.json +47 -0
  40. package/scripts/mutants/mux-fresh-call.json +48 -4
  41. package/scripts/mutants/mux-resume-call.json +3 -3
  42. package/scripts/mutants/release-gate.json +13 -0
  43. package/scripts/probe-bridge-command.ts +330 -0
  44. package/scripts/raw-async-delivery/README.md +158 -1
  45. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  46. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  47. package/scripts/smoke-agy-install-state.sh +76 -2
  48. package/scripts/smoke-entwurf-chain-live.ts +12 -4
  49. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +169 -3
  51. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  52. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  53. package/scripts/smoke-pi-provider-state.sh +135 -6
  54. package/scripts/smoke-resident-garden-guard.sh +2 -2
@@ -0,0 +1,47 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "meta-retire",
4
+ "mutants": [
5
+ {
6
+ "claim": "META-RETIRE-COMPARE-TYPE",
7
+ "title": "comparing the current value without its JSON scalar type lets a truthy 1 pass as the true entwurf wrote, so relinquishment overwrites a value that was never ours (#71)",
8
+ "subject": "scripts/meta-bridge-state.py",
9
+ "find": [
10
+ " existed, value = get_nested(settings, path)",
11
+ " if existed and type(value) is type(last_managed_value) and value == last_managed_value:"
12
+ ],
13
+ "replace": [" existed, value = get_nested(settings, path)", " if existed and value == last_managed_value:"],
14
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
15
+ "timeoutSeconds": 300,
16
+ "signature": "[QK:META-RETIRE-COMPARE-TYPE]",
17
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
18
+ },
19
+ {
20
+ "claim": "META-RETIRE-MALFORMED-SILENT",
21
+ "title": "skipping a malformed retired entry instead of failing discards the only evidence that entwurf owns the key, leaving a still-dangerous value looking operator-clean (#71)",
22
+ "subject": "scripts/meta-bridge-state.py",
23
+ "find": [
24
+ " die(f\"retired scalar state entry {name} is malformed; refusing to discard ownership evidence\")"
25
+ ],
26
+ "replace": [" return"],
27
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
28
+ "timeoutSeconds": 300,
29
+ "signature": "[QK:META-RETIRE-MALFORMED-SILENT]",
30
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
31
+ },
32
+ {
33
+ "claim": "META-RETIRE-FRESH-TOUCH",
34
+ "title": "synthesising provenance when install-state carries none lets relinquishment rewrite a value the operator set themselves, the exact ownership violation #71 exists to stop",
35
+ "subject": "scripts/meta-bridge-state.py",
36
+ "find": [" if entry is None:", " return"],
37
+ "replace": [
38
+ " if entry is None:",
39
+ " entry = {\"kind\": \"scalar\", \"path\": path, \"original\": {\"existed\": False, \"value\": None}}"
40
+ ],
41
+ "gate": ["bash", "run.sh", "smoke-meta-install-state"],
42
+ "timeoutSeconds": 300,
43
+ "signature": "[QK:META-RETIRE-FRESH-TOUCH]",
44
+ "signatureSource": "scripts/smoke-meta-install-state.sh"
45
+ }
46
+ ]
47
+ }
@@ -78,15 +78,59 @@
78
78
  "claim": "FRESHCALL-RECEIPT-WITHOUT-CORRELATION",
79
79
  "title": "the launch receipt grows a sibling garden-id field, making an async correlation look like a synchronous return",
80
80
  "subject": "pi-extensions/lib/mux-fresh-call.ts",
81
- "find": ["\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n}"],
82
- "replace": [
83
- "\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"
84
- ],
81
+ "find": ["\truntimePath: string;\n\tnonce: string;\n}"],
82
+ "replace": ["\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"],
85
83
  "gate": ["bash", "run.sh", "check-mux-fresh-call"],
86
84
  "timeoutSeconds": 120,
87
85
  "signature": "[QK:FRESHCALL-RECEIPT-WITHOUT-CORRELATION]",
88
86
  "signatureSource": "test/mux-fresh-call.test.ts"
89
87
  },
88
+ {
89
+ "claim": "FRESHCALL-CWD-ARGV",
90
+ "title": "the requested cwd stops reaching tmux, so a cross-repo fresh sibling silently opens in the CALLER's repo and the launch looks successful",
91
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
92
+ "find": ["\t\t...(cwd === undefined ? [] : [\"-c\", cwd]),\n"],
93
+ "replace": [""],
94
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
95
+ "timeoutSeconds": 120,
96
+ "signature": "[QK:FRESHCALL-CWD-ARGV]",
97
+ "signatureSource": "test/mux-fresh-call.test.ts"
98
+ },
99
+ {
100
+ "claim": "FRESHCALL-CWD-REFUSED-PREMUTATION",
101
+ "title": "the pre-mutation cwd classification is ignored, so a relative/'#'/deleted/file path rides on toward tmux instead of refusing by name",
102
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
103
+ "find": ["\t\tif (badCwd) return { ok: false, reason: badCwd };"],
104
+ "replace": ["\t\tvoid badCwd;"],
105
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
106
+ "timeoutSeconds": 120,
107
+ "signature": "[QK:FRESHCALL-CWD-REFUSED-PREMUTATION]",
108
+ "signatureSource": "test/mux-fresh-call.test.ts"
109
+ },
110
+ {
111
+ "claim": "FRESHCALL-CWD-RECEIPT-REQUESTED",
112
+ "title": "production freshCall stops assembling the requested cwd into its receipt, so the operator-visible answer silently loses the one placement fact the caller asked for",
113
+ "subject": "pi-extensions/lib/mux-fresh-call.ts",
114
+ "find": ["\t\t\t...(cwd === undefined ? {} : { cwd }),\n"],
115
+ "replace": [""],
116
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
117
+ "timeoutSeconds": 120,
118
+ "signature": "[QK:FRESHCALL-CWD-RECEIPT-REQUESTED]",
119
+ "signatureSource": "test/mux-fresh-call.test.ts"
120
+ },
121
+ {
122
+ "claim": "FRESHCALL-CWD-SURFACE-PARITY",
123
+ "title": "one surface drops the optional cwd from its schema, so cross-repo fresh works from pi and silently cannot exist from the MCP bridge",
124
+ "subject": "mcp/entwurf-bridge/src/index.ts",
125
+ "find": [
126
+ "\t\tcwd: z\n\t\t\t.string()\n\t\t\t.optional()\n\t\t\t.describe(\n\t\t\t\t\"Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \\\"\\\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation.\",\n\t\t\t),\n"
127
+ ],
128
+ "replace": [""],
129
+ "gate": ["bash", "run.sh", "check-mux-fresh-call"],
130
+ "timeoutSeconds": 120,
131
+ "signature": "[QK:FRESHCALL-CWD-SURFACE-PARITY]",
132
+ "signatureSource": "test/fresh-call-surfaces.contract.test.ts"
133
+ },
90
134
  {
91
135
  "claim": "FRESHCALL-PI-SURFACE-IDENTITY",
92
136
  "title": "native pi takes the caller garden id from the environment instead of its own resident closure — the uuidv7 confusion, reintroduced",
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "claim": "MUXRESUME-CWD-MISSING-REFUSED",
7
7
  "title": "a deleted recorded cwd is accepted — tmux then opens the window in $HOME and the wrong-project resume looks successful",
8
- "subject": "pi-extensions/lib/mux-resume-call.ts",
8
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
9
9
  "find": ["\t\treturn \"cwd-missing\";"],
10
10
  "replace": ["\t\treturn null;"],
11
11
  "gate": ["bash", "run.sh", "check-mux-resume-call"],
@@ -16,7 +16,7 @@
16
16
  {
17
17
  "claim": "MUXRESUME-CWD-FORMAT-REFUSED",
18
18
  "title": "the '#' refusal stops matching, so tmux format-expands the path it was handed",
19
- "subject": "pi-extensions/lib/mux-resume-call.ts",
19
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
20
20
  "find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
21
21
  "replace": ["\tif (cwd.includes(\"\\u0000\")) return \"cwd-format-token\";"],
22
22
  "gate": ["bash", "run.sh", "check-mux-resume-call"],
@@ -27,7 +27,7 @@
27
27
  {
28
28
  "claim": "MUXRESUME-CWD-WHITESPACE-OK",
29
29
  "title": "a quoting fear is added back as a whitespace refusal, which would reject real project directories tmux handles fine",
30
- "subject": "pi-extensions/lib/mux-resume-call.ts",
30
+ "subject": "pi-extensions/lib/classify-tmux-cwd.ts",
31
31
  "find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
32
32
  "replace": ["\tif (cwd.includes(\"#\") || /\\s/.test(cwd)) return \"cwd-format-token\";"],
33
33
  "gate": ["bash", "run.sh", "check-mux-resume-call"],
@@ -135,6 +135,19 @@
135
135
  "timeoutSeconds": 180,
136
136
  "signature": "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST]",
137
137
  "signatureSource": "scripts/check-release-gate-outcomes.ts"
138
+ },
139
+ {
140
+ "claim": "PI-DOCTOR-IS-RELEASE-MUST",
141
+ "title": "the pi-provider boot doctor is quietly dropped from the gate — the operator's CONFIGURED bridge invocation is never booted, so a 127 launcher (the #81 relocated cmd-shim class) stays invisible until smoke-acp-bundled-mcp-live sixteen LIVE steps and real model spend later",
142
+ "subject": "run.sh",
143
+ "find": [
144
+ " run_step \"doctor-pi-provider (#81: the operator's CONFIGURED bridge invocation actually boots)\" gate bash \"$self\" doctor-pi-provider"
145
+ ],
146
+ "replace": [" : # pi doctor quietly dropped — the configured invocation is never booted"],
147
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
148
+ "timeoutSeconds": 180,
149
+ "signature": "[QK:PI-DOCTOR-IS-RELEASE-MUST]",
150
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
138
151
  }
139
152
  ]
140
153
  }
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ // probe-bridge-command — does the EFFECTIVE bridge command actually BOOT and serve MCP?
3
+ //
4
+ // WHY THIS EXISTS (#81): every doctor that judged the bridge wiring asked only whether the
5
+ // configured command RESOLVES — `command -v <bare name>` for pi (doctor-pi-provider) and agy
6
+ // (agy-bridge.sh). Resolvability is not bootability, and the gap is not theoretical: on the
7
+ // reference host `~/.local/bin/entwurf-bridge` was a symlink to the canonical pnpm shim, and
8
+ // that shim derives its target from `$0`. Invoked through the relocated link the derived
9
+ // `../global/...` path does not exist, so the launcher exits 127 and the MCP child never boots
10
+ // — while `command -v` kept answering yes and both doctors kept printing ok. The model in a
11
+ // Pi-hosted ACP turn then had no `mcp__entwurf-bridge__*` tool at all, which is a direct
12
+ // violation of #81's minimum core value (explicit Entwurf bridge connectivity).
13
+ //
14
+ // So this leaf asks the only question that carries the claim: run the command, speak MCP to it,
15
+ // and require the entwurf tool surface back. A green here is about the command the harness
16
+ // actually execs — never a recomputed desired one (the same rule meta-bridge-doctor's delivery
17
+ // self-diagnostic already states for the Claude lane).
18
+ //
19
+ // SIDE EFFECTS: none on operator state. Only `initialize` + `tools/list` are sent — never a
20
+ // `tools/call` — so no lock is taken, no record is written, and no message is delivered. The
21
+ // child is killed as soon as the frame arrives or the deadline passes. This is what makes the
22
+ // probe affordable inside a doctor an operator runs repeatedly.
23
+ //
24
+ // USAGE
25
+ // library: const r = await probeBridgeCommand({ command, args }); // doctor-pi-provider.ts
26
+ // CLI: node probe-bridge-command.ts <command> [args...]
27
+ // node probe-bridge-command.ts --invocation-json '{"command":"…","args":[],"env":{}}'
28
+ // exit 0 = booted and served the entwurf tool surface; 1 = did not (reason on stdout).
29
+ import { spawn } from "node:child_process";
30
+ import { basename } from "node:path";
31
+
32
+ /** Why a probe failed. Each value is a DISTINCT operator situation, never a generic "broken". */
33
+ export type BridgeProbeReason =
34
+ | "ok"
35
+ | "spawn-failed" // the command could not be executed at all (ENOENT / EACCES)
36
+ | "exited-before-tools-list" // it ran and DIED — the relocated-shim 127 class lands here
37
+ | "initialize-failed" // it did not complete the MCP initialize handshake before tools/list
38
+ | "timeout" // it stayed up but never answered — a hung/wrong binary
39
+ | "no-tools-array" // it answered id:2 without result.tools — not an MCP server
40
+ | "tool-set-mismatch"; // an MCP server, but not THIS bridge (foreign binary, or a stale build)
41
+
42
+ export interface BridgeProbeResult {
43
+ ok: boolean;
44
+ reason: BridgeProbeReason;
45
+ /** One line, safe to print in a doctor verdict. Carries rc/signal/stderr head when relevant. */
46
+ detail: string;
47
+ }
48
+
49
+ export interface BridgeProbeOptions {
50
+ command: string;
51
+ args?: string[];
52
+ env?: NodeJS.ProcessEnv;
53
+ timeoutMs?: number;
54
+ }
55
+
56
+ // The EXACT public verb set of the current bridge. Identity is the whole set, not one member:
57
+ // the #81 symptom was a session whose schema lacked `entwurf_self` specifically, and a probe that
58
+ // only looked for `entwurf_v2` would have called that host healthy. Exactness also catches the
59
+ // other direction — a STALE build still serving a retired verb, or a foreign binary that happens
60
+ // to expose an `entwurf_v2`. check-entwurf-bridge-boot binds this list to the real runtime
61
+ // tools/list, so a verb added or retired without updating it turns that gate red.
62
+ export const EXPECTED_TOOLS = [
63
+ "entwurf_v2",
64
+ "entwurf_self",
65
+ "entwurf_peers",
66
+ "entwurf_inbox_read",
67
+ "entwurf_register_native",
68
+ "entwurf_fresh_call",
69
+ "entwurf_resume_call",
70
+ ] as const;
71
+ const DEFAULT_TIMEOUT_MS = 10_000;
72
+ /** How long a probed child gets to die politely before it is killed outright. */
73
+ const CLEANUP_GRACE_MS = 500;
74
+
75
+ /** Collapse captured stderr into one printable line — a doctor verdict must stay one line. */
76
+ function head(text: string, limit = 200): string {
77
+ const flat = text.replace(/\s+/g, " ").trim();
78
+ return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
79
+ }
80
+
81
+ export function probeBridgeCommand(opts: BridgeProbeOptions): Promise<BridgeProbeResult> {
82
+ const { command, args = [], env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
83
+ return new Promise((resolve) => {
84
+ // No shell: a bare name goes through the normal PATH lookup the harness itself does,
85
+ // and an argument can never be reinterpreted as shell syntax.
86
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env });
87
+ // `write()` reports a synchronous closed pipe by throwing, but a launcher that dies on exec
88
+ // closes stdin under us and the resulting EPIPE arrives ASYNCHRONOUSLY, as an 'error' event on
89
+ // the stream. Unlistened, Node turns that into an uncaught exception, so the probe dies
90
+ // printing its own stack trace INSTEAD of the launcher's stderr: precisely the diagnosis #81
91
+ // exists to surface, destroyed in the one cell that needs it. The window is real, not
92
+ // theoretical — under CPU contention the child can exec, write its stderr and exit between
93
+ // uv_spawn and our first write, which is how CI saw a doctor verdict with no launcher stderr
94
+ // in it. The `close` handler owns the verdict for that child (with captured stderr), so this
95
+ // listener is installed before anything can write and stays deliberately silent — one reason
96
+ // per failure, never an unhandled probe crash.
97
+ child.stdin.on("error", () => {});
98
+ let stdoutPending = "";
99
+ let stderr = "";
100
+ let settled = false;
101
+ let initialized = false;
102
+ let exited: { code: number | null; signal: NodeJS.Signals | null } | undefined;
103
+ let stderrEnded = false;
104
+
105
+ // This probe deliberately runs whatever command the operator configured — including a
106
+ // foreign or broken one — so it owns closing what it opened. Escalate SIGTERM → SIGKILL
107
+ // once, bounded: cleanup for THIS child, not a supervisor (no retries, no watching, no
108
+ // process-tree walking).
109
+ //
110
+ // The reap is AWAITED before the result resolves, and that ordering is load-bearing: the
111
+ // callers are doctors that print a verdict and `process.exit()` immediately, which tears
112
+ // down any still-pending timer. A fire-and-forget SIGTERM plus a deferred SIGKILL would
113
+ // therefore leave a TERM-ignoring launcher running after the doctor is gone — the probe
114
+ // would leak exactly the kind of broken process it exists to detect.
115
+ const reap = (): Promise<void> =>
116
+ new Promise((finished) => {
117
+ if (child.exitCode !== null || child.signalCode !== null) return finished();
118
+ let reaped = false;
119
+ let hard: ReturnType<typeof setTimeout> | undefined;
120
+ const finish = (): void => {
121
+ if (reaped) return;
122
+ reaped = true;
123
+ if (hard) clearTimeout(hard);
124
+ finished();
125
+ };
126
+ child.once("close", finish);
127
+ try {
128
+ child.kill("SIGTERM");
129
+ } catch {
130
+ return finish(); // already gone
131
+ }
132
+ hard = setTimeout(() => {
133
+ try {
134
+ child.kill("SIGKILL");
135
+ } catch {}
136
+ // SIGKILL cannot be trapped, so `close` follows. The outer bound exists only so a
137
+ // child already reaped by someone else can never hang this promise forever.
138
+ setTimeout(finish, CLEANUP_GRACE_MS);
139
+ }, CLEANUP_GRACE_MS);
140
+ });
141
+
142
+ const done = (reason: BridgeProbeReason, detail: string): void => {
143
+ if (settled) return;
144
+ settled = true;
145
+ clearTimeout(timer);
146
+ void reap().then(() => resolve({ ok: reason === "ok", reason, detail }));
147
+ };
148
+
149
+ const timer = setTimeout(() => {
150
+ const waitingFor = initialized ? "tools/list" : "initialize";
151
+ done(
152
+ "timeout",
153
+ `'${command}' stayed up but never answered ${waitingFor} within ${timeoutMs}ms` +
154
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : ""),
155
+ );
156
+ }, timeoutMs);
157
+
158
+ child.on("error", (err) => {
159
+ done("spawn-failed", `'${command}' could not be executed: ${String(err)}`);
160
+ });
161
+
162
+ // `ChildProcess` close is normally after the stdio pipes close, but under nested shell
163
+ // capture that ordering has raced on this host. Do not mint a no-stderr diagnosis until the
164
+ // stderr reader ended: the launcher's own final diagnostic is part of the operator verdict.
165
+ const reportExited = (): void => {
166
+ if (!exited || !stderrEnded) return;
167
+ // Reaching here un-settled means the process died before answering id:2. This is the
168
+ // cell the relocated pnpm shim lands in (exit 127), and the stderr head is what tells
169
+ // the operator WHICH launcher hop broke.
170
+ done(
171
+ "exited-before-tools-list",
172
+ `'${command}' exited before answering tools/list (code=${exited.code} signal=${String(exited.signal)})` +
173
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : " — no stderr"),
174
+ );
175
+ };
176
+ child.on("close", (code, signal) => {
177
+ exited = { code, signal };
178
+ reportExited();
179
+ });
180
+
181
+ child.stderr.on("data", (d) => {
182
+ stderr += String(d);
183
+ });
184
+ child.stderr.on("end", () => {
185
+ stderrEnded = true;
186
+ reportExited();
187
+ });
188
+ child.stdout.on("data", (d) => {
189
+ const chunk = String(d);
190
+ stdoutPending += chunk;
191
+ if (settled) return;
192
+ // MCP frames are newline-delimited JSON-RPC. Consume each complete frame exactly once:
193
+ // replaying the accumulated id:1 response on a later data event would send a second
194
+ // tools/list before the first reply and stop being a sequential handshake.
195
+ const frames = stdoutPending.split("\n");
196
+ stdoutPending = frames.pop() ?? "";
197
+ for (const line of frames) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed) continue;
200
+ let msg: {
201
+ id?: unknown;
202
+ error?: unknown;
203
+ result?: { protocolVersion?: unknown; capabilities?: unknown; serverInfo?: unknown; tools?: unknown };
204
+ };
205
+ try {
206
+ msg = JSON.parse(trimmed);
207
+ } catch {
208
+ continue;
209
+ }
210
+ if (msg?.id === 1) {
211
+ const result = msg.result;
212
+ const valid =
213
+ msg.error === undefined &&
214
+ result?.protocolVersion === "2024-11-05" &&
215
+ typeof result.capabilities === "object" &&
216
+ result.capabilities !== null &&
217
+ typeof result.serverInfo === "object" &&
218
+ result.serverInfo !== null;
219
+ if (!valid) {
220
+ done("initialize-failed", `'${command}' returned an invalid MCP initialize response`);
221
+ return;
222
+ }
223
+ initialized = true;
224
+ sendReadyFrames();
225
+ continue;
226
+ }
227
+ if (msg?.id !== 2) continue;
228
+ if (!initialized) {
229
+ done("initialize-failed", `'${command}' answered tools/list before MCP initialize completed`);
230
+ return;
231
+ }
232
+ const tools = msg?.result?.tools;
233
+ if (!Array.isArray(tools)) {
234
+ done("no-tools-array", `'${command}' answered tools/list without result.tools — not an MCP server`);
235
+ return;
236
+ }
237
+ const names = tools
238
+ .map((t) => (t as { name?: unknown })?.name)
239
+ .filter((n): n is string => typeof n === "string");
240
+ const served = new Set(names);
241
+ const missing = EXPECTED_TOOLS.filter((t) => !served.has(t));
242
+ const unexpected = names.filter((n) => !(EXPECTED_TOOLS as readonly string[]).includes(n));
243
+ if (missing.length > 0 || unexpected.length > 0) {
244
+ // Name BOTH directions: a missing verb is the #81 symptom (the model had no
245
+ // entwurf_self), an unexpected one means this is not the bridge we ship.
246
+ const parts: string[] = [];
247
+ if (missing.length > 0) parts.push(`missing ${missing.join(", ")}`);
248
+ if (unexpected.length > 0) parts.push(`unexpected ${unexpected.join(", ")}`);
249
+ done(
250
+ "tool-set-mismatch",
251
+ `'${command}' is an MCP server but does not serve this bridge's verb set — ${parts.join("; ")} (served: ${names.join(", ") || "none"})`,
252
+ );
253
+ return;
254
+ }
255
+ done("ok", `'${command}' booted and served the exact entwurf verb set (${EXPECTED_TOOLS.length} tools)`);
256
+ return;
257
+ }
258
+ });
259
+
260
+ const send = (obj: unknown): void => {
261
+ try {
262
+ child.stdin.write(`${JSON.stringify(obj)}\n`);
263
+ } catch {
264
+ // Synchronous throws (a destroyed stream) land here; async EPIPE lands on the
265
+ // listener above. Both are the same verdict-free silence.
266
+ }
267
+ };
268
+ const sendReadyFrames = (): void => {
269
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
270
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
271
+ };
272
+ send({
273
+ jsonrpc: "2.0",
274
+ id: 1,
275
+ method: "initialize",
276
+ params: {
277
+ protocolVersion: "2024-11-05",
278
+ capabilities: {},
279
+ clientInfo: { name: "probe-bridge-command", version: "0" },
280
+ },
281
+ });
282
+ });
283
+ }
284
+
285
+ // CLI entrypoint. Kept argv-driven (no env carrier) so `./run.sh probe-bridge-command entwurf-bridge`
286
+ // asks about exactly the name an operator typed, and a doctor can shell out to the same verdict.
287
+ // Match the BASENAME exactly. A suffix test would also fire for `check-probe-bridge-command.ts`,
288
+ // which imports this module — the CLI arm would then hijack that gate's argv and exit 2.
289
+ if (process.argv[1] && /^probe-bridge-command\.(ts|js)$/.test(basename(process.argv[1]))) {
290
+ const argv = process.argv.slice(2);
291
+ let options: BridgeProbeOptions;
292
+ if (argv[0] === "--invocation-json") {
293
+ let raw: unknown;
294
+ try {
295
+ raw = JSON.parse(argv[1] ?? "");
296
+ } catch {
297
+ console.error("probe-bridge-command: --invocation-json must be valid JSON");
298
+ process.exit(2);
299
+ }
300
+ const obj = raw as { command?: unknown; args?: unknown; env?: unknown };
301
+ if (
302
+ typeof obj?.command !== "string" ||
303
+ !obj.command ||
304
+ !Array.isArray(obj.args) ||
305
+ !obj.args.every((arg) => typeof arg === "string") ||
306
+ typeof obj.env !== "object" ||
307
+ obj.env === null ||
308
+ Array.isArray(obj.env) ||
309
+ !Object.values(obj.env).every((value) => typeof value === "string")
310
+ ) {
311
+ console.error("probe-bridge-command: invocation JSON requires string command, string[] args, string-map env");
312
+ process.exit(2);
313
+ }
314
+ options = {
315
+ command: obj.command,
316
+ args: obj.args as string[],
317
+ env: { ...process.env, ...(obj.env as Record<string, string>) },
318
+ };
319
+ } else {
320
+ const [command, ...args] = argv;
321
+ if (!command) {
322
+ console.error("usage: probe-bridge-command <command> [args...]");
323
+ process.exit(2);
324
+ }
325
+ options = { command, args };
326
+ }
327
+ const result = await probeBridgeCommand(options);
328
+ console.log(`[probe-bridge-command] ${result.reason}: ${result.detail}`);
329
+ process.exit(result.ok ? 0 : 1);
330
+ }