@junghanacs/entwurf 0.14.1 → 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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +1 -1
  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/setup-clean-host.md +3 -3
  11. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  12. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  13. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  14. package/package.json +9 -9
  15. package/pi-extensions/lib/acp/backend.ts +229 -9
  16. package/run.sh +70 -25
  17. package/scripts/agy-bridge-config.py +47 -13
  18. package/scripts/agy-bridge.sh +73 -23
  19. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  20. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  21. package/scripts/check-gate-qualification.ts +3 -2
  22. package/scripts/check-probe-bridge-command.ts +201 -0
  23. package/scripts/check-release-gate-outcomes.ts +54 -1
  24. package/scripts/doctor-pi-provider.ts +155 -51
  25. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  26. package/scripts/mutants/bridge-command-boot.json +107 -0
  27. package/scripts/mutants/release-gate.json +13 -0
  28. package/scripts/probe-bridge-command.ts +330 -0
  29. package/scripts/raw-async-delivery/README.md +158 -1
  30. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  31. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  32. package/scripts/smoke-agy-install-state.sh +76 -2
  33. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  34. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  35. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  36. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  37. package/scripts/smoke-pi-provider-state.sh +135 -6
  38. package/scripts/smoke-resident-garden-guard.sh +2 -2
@@ -1,10 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  // doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
3
- // entwurf-bridge) ownership (#46 Task 2). Side-effect FREE (read-only). Uses the config.ts SSOT
3
+ // entwurf-bridge) ownership (#46 Task 2). A BOUNDED BOOT PROBE, not a static inspector: it writes
4
+ // no operator state, but it does exec the configured command on this host (#81). Uses the config.ts SSOT
4
5
  // `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
5
6
  // loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
6
7
  // red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
7
- // resolveProviderConfig rule), plus install-state ownership, and gates on stable-bin resolvability.
8
+ // resolveProviderConfig rule), plus install-state ownership, and gates on the stable bin actually
9
+ // BOOTING (#81) — not merely resolving. `command -v` answering yes is not evidence that pi gets a
10
+ // bridge: on the reference host the bare name resolved through a relocated pnpm shim whose $0-derived
11
+ // target did not exist, so the launcher exited 127, the ACP turn had no mcp__entwurf-bridge__* tool,
12
+ // and THIS doctor still printed ok. Resolvability is kept as the first, cheaper cell so the two
13
+ // failures stay distinguishable (nothing on PATH vs. on PATH but dead).
14
+ //
15
+ // The probe sends `initialize` + `tools/list` only — never a `tools/call` — so it takes no lock,
16
+ // writes no record and delivers nothing. That bounds OUR bridge tightly (start.sh is an `exec node`,
17
+ // so there is no grandchild). It does not make the doctor side-effect free in general: whatever the
18
+ // operator configured is what gets executed, and a foreign launcher's own startup is its own
19
+ // business. Running this doctor is therefore a decision to run that command. See
20
+ // probe-bridge-command.ts, which owns the bounded reap of the child it spawns.
8
21
  //
9
22
  // Env overrides (for the hermetic smoke):
10
23
  // PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
@@ -12,12 +25,13 @@
12
25
  // PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
13
26
  //
14
27
  // Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
15
- // state-owned-but-drifted / stable bin dangling).
16
- import { execSync } from "node:child_process";
17
- import { existsSync, constants as FS, readFileSync, statSync } from "node:fs";
28
+ // state-owned-but-drifted / stable bin dangling / stable bin present but does NOT boot).
29
+ import { execFileSync } from "node:child_process";
30
+ import { accessSync, existsSync, constants as FS, readFileSync } from "node:fs";
18
31
  import { homedir } from "node:os";
19
32
  import { join } from "node:path";
20
- import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.js";
33
+ import { normalizeMcpServers, readProviderSettingsFile } from "../pi-extensions/lib/acp/config.js";
34
+ import { probeBridgeCommand } from "./probe-bridge-command.js";
21
35
  const BARE = "entwurf-bridge";
22
36
  const KEY = "entwurf-bridge";
23
37
  const home = homedir();
@@ -28,29 +42,64 @@ const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
28
42
  const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-provider", "install-state.json");
29
43
  let hardFail = 0;
30
44
  const log = (s) => process.stdout.write(s + "\n");
31
- function commandOf(settings) {
32
- const entry = settings.mcpServers?.[KEY];
33
- if (entry && typeof entry === "object" && typeof entry.command === "string") {
34
- return entry.command;
35
- }
36
- return undefined;
45
+ // Build the effective entry EXACTLY the way production does (config.ts `resolveProviderConfig`):
46
+ // a shallow PER-NAME merge of the two `mcpServers` maps, then ONE `normalizeMcpServers` over the
47
+ // merged map. Hand-parsing command/args (this doctor's first shape) dropped a malformed arg
48
+ // silently and probed an invocation production never runs.
49
+ //
50
+ // The ORDER is load-bearing too. Normalizing each file separately validates entries production
51
+ // never sees: a malformed server in the global map that the project map shadows is gone by the time
52
+ // pi normalizes, yet a per-file doctor throws on it and calls a session red that in fact starts
53
+ // fine. That is the same "the doctor's subject differs from the runtime's" defect as the silent
54
+ // arg-drop, pointed the other way — and it is not confined to our key, since any unrelated global
55
+ // server could trip it.
56
+ //
57
+ // THROWS (McpServerConfigError) when the MERGED map is malformed — the fail-loud path, caught by
58
+ // the caller. That error names the offending SERVER and reason, not a file: after the merge an
59
+ // entry no longer belongs to one scope, so claiming a filename here would be a guess. A non-stdio
60
+ // (http/sse) entry is returned as `null` so the caller can say WHY it cannot be probed instead of
61
+ // pretending it is absent.
62
+ function mergedBridgeEntry(globalSettings, projectSettings) {
63
+ const mergedRaw = { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) };
64
+ const { servers } = normalizeMcpServers(mergedRaw);
65
+ const entry = servers.find((srv) => srv.name === KEY);
66
+ if (entry === undefined)
67
+ return undefined; // not configured in either scope
68
+ if ("url" in entry)
69
+ return null; // http/sse — a real config, but nothing to spawn
70
+ return {
71
+ command: entry.command,
72
+ args: entry.args,
73
+ // The ACP wire shape is a name/value list; collapse it to the env map a spawn wants.
74
+ env: Object.fromEntries(entry.env.map((kv) => [kv.name, kv.value])),
75
+ };
76
+ }
77
+ // Per-scope DISPLAY only — never validation. The scope lines exist so an operator can see which
78
+ // file supplied the effective entry; judging shape here would re-introduce the per-file validation
79
+ // the merge rule above exists to avoid.
80
+ function describeScope(settings) {
81
+ const raw = settings.mcpServers?.[KEY];
82
+ if (raw === undefined)
83
+ return "entwurf-bridge NOT configured";
84
+ const cmd = raw?.command;
85
+ return typeof cmd === "string" ? `'${cmd}'` : "configured (shape judged in the merged view)";
37
86
  }
38
87
  // Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
39
88
  // A bare name is looked up on PATH; a path must be an executable file.
40
89
  function resolvable(cmd) {
41
90
  if (cmd.includes("/")) {
42
91
  try {
43
- statSync(cmd);
44
- // eslint-disable-next-line no-bitwise
45
- return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
92
+ accessSync(cmd, FS.X_OK);
93
+ return true;
46
94
  }
47
95
  catch {
48
96
  return false;
49
97
  }
50
98
  }
51
99
  try {
52
- // `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
53
- execSync(`command -v ${cmd}`, { stdio: "ignore" });
100
+ // `command -v` is a POSIX sh builtin. Pass the configured name as argv, never shell text:
101
+ // an unowned override is still operator data and must not become a doctor injection surface.
102
+ execFileSync("sh", ["-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", cmd], { stdio: "ignore" });
54
103
  return true;
55
104
  }
56
105
  catch {
@@ -59,23 +108,39 @@ function resolvable(cmd) {
59
108
  }
60
109
  log("[pi-provider doctor]");
61
110
  // Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
62
- let userCmd;
63
- let projCmd;
111
+ let effectiveEntry;
112
+ let userScopeDesc = "";
113
+ let projScopeDesc = "";
114
+ let effectiveScope = "none";
64
115
  try {
65
- userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
66
- projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
116
+ const userSettings = readProviderSettingsFile(globalPath).settings;
117
+ const projSettings = readProviderSettingsFile(projectPath).settings;
118
+ userScopeDesc = describeScope(userSettings);
119
+ projScopeDesc = describeScope(projSettings);
120
+ // EFFECTIVE = project shadows user per-NAME. The whole ENTRY shadows, not a field of it, so
121
+ // command, args and env always come from one scope; the scope label is decided by which map
122
+ // owns the key, which is exactly what the merge spread resolves.
123
+ effectiveEntry = mergedBridgeEntry(userSettings, projSettings);
124
+ const ownsKey = (m) => m !== undefined && Object.hasOwn(m, KEY);
125
+ effectiveScope = ownsKey(projSettings.mcpServers)
126
+ ? "project"
127
+ : ownsKey(userSettings.mcpServers)
128
+ ? "user(global)"
129
+ : "none";
67
130
  }
68
131
  catch (err) {
69
132
  log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
70
133
  process.exit(1);
71
134
  }
72
- // EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
73
- const effectiveCmd = projCmd ?? userCmd;
74
- const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
135
+ const effectiveDesc = effectiveEntry === undefined
136
+ ? "entwurf-bridge NOT configured"
137
+ : effectiveEntry === null
138
+ ? "configured as an http/sse server"
139
+ : `'${effectiveEntry.command}'`;
75
140
  log("── scopes (project shadows user per-name)");
76
- log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
77
- log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
78
- log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
141
+ log(` user(global) ${globalPath}: ${userScopeDesc}`);
142
+ log(` project ${projectPath}: ${projScopeDesc}`);
143
+ log(` EFFECTIVE (${effectiveScope}): ${effectiveDesc}`);
79
144
  // install-state ownership (user scope). absent state on a configured effective is either a
80
145
  // pre-Task-2 install or a user-override we deliberately did not own.
81
146
  let ownership;
@@ -94,33 +159,60 @@ else {
94
159
  log(" state: no user-scope install-state.");
95
160
  }
96
161
  log("── verdict");
97
- if (effectiveCmd === undefined) {
162
+ if (effectiveEntry === undefined) {
98
163
  log(" note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).");
99
164
  }
100
- else if (effectiveCmd === BARE) {
101
- if (resolvable(effectiveCmd)) {
102
- log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
103
- }
104
- else {
105
- log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
106
- hardFail = 1;
107
- }
165
+ else if (effectiveEntry === null) {
166
+ // A real, well-formed config that this bridge cannot be: entwurf-bridge is a stdio server, so
167
+ // an http/sse entry under our key means pi would connect to something that is not us. Nothing
168
+ // here is spawnable, so there is no boot to prove — say that plainly rather than reporting the
169
+ // absence of a failure as ok.
170
+ log(` FAIL: entwurf-bridge is configured as an http/sse server in ${effectiveScope} scope. This bridge is a stdio server — pi would reach something that is not entwurf, and no boot evidence is possible. Restore a stdio entry with ./run.sh setup, or remove the key if the override is deliberate.`);
171
+ hardFail = 1;
108
172
  }
109
173
  else {
110
- // effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
111
- // the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
112
- // user override — say so distinctly so "run setup" is the clear next step. A truly foreign
113
- // command is an unowned override left as the operator's choice. Neither is a hard fail.
114
- const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
115
- if (ownership && ownership !== "user-override") {
116
- log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`);
174
+ const cmd = effectiveEntry.command;
175
+ const isBare = cmd === BARE;
176
+ const isLegacyManaged = cmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
177
+ // Runtime truth is independent of ownership truth. An unowned override remains the operator's
178
+ // choice, but it still shadows the stable bridge in production; calling a dead override green
179
+ // repeats #81 under a different spelling. Probe the exact normalized command + args + env for
180
+ // EVERY effective stdio entry, then classify who owns that entry separately below.
181
+ if (!resolvable(cmd)) {
182
+ log(` FAIL: effective command '${cmd}' does NOT resolve or is not executable.`);
117
183
  hardFail = 1;
118
184
  }
119
- else if (isLegacyManaged) {
120
- log(` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`);
121
- }
122
185
  else {
123
- log(` note: entwurf-bridge is an UNOWNED override ('${effectiveCmd}')effective is not the stable bin. Left as the operator's choice (run ./run.sh setup to adopt the bare bin).`);
186
+ log(` effective command '${cmd}' RESOLVES probing the exact configured invocation…`);
187
+ const probe = await probeBridgeCommand({
188
+ command: cmd,
189
+ args: effectiveEntry.args,
190
+ env: { ...process.env, ...effectiveEntry.env },
191
+ });
192
+ if (probe.ok) {
193
+ log(isBare
194
+ ? ` ok: effective command is the bare stable bin '${BARE}' and it BOOTS — ${probe.detail}`
195
+ : ` runtime: configured override BOOTS — ${probe.detail}`);
196
+ }
197
+ else {
198
+ log(` FAIL: effective invocation does NOT serve MCP [${probe.reason}] — ${probe.detail}`);
199
+ log(` Diagnose its launcher and configured args/env; entwurf will not overwrite an unowned command.`);
200
+ hardFail = 1;
201
+ }
202
+ }
203
+ if (!isBare) {
204
+ // Ownership classification never rounds a broken runtime up to green. It only says who may
205
+ // repair the non-canonical entry after the independent boot verdict above.
206
+ if (ownership && ownership !== "user-override") {
207
+ log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${cmd}'.`);
208
+ hardFail = 1;
209
+ }
210
+ else if (isLegacyManaged) {
211
+ log(` note: effective is our LEGACY managed repo path ('${cmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize.`);
212
+ }
213
+ else {
214
+ log(` note: entwurf-bridge is an UNOWNED override ('${cmd}'). Runtime was judged above; run ./run.sh setup only if you choose to adopt the bare stable bin.`);
215
+ }
124
216
  }
125
217
  }
126
218
  if (hardFail) {
@@ -0,0 +1,294 @@
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
+ // The EXACT public verb set of the current bridge. Identity is the whole set, not one member:
32
+ // the #81 symptom was a session whose schema lacked `entwurf_self` specifically, and a probe that
33
+ // only looked for `entwurf_v2` would have called that host healthy. Exactness also catches the
34
+ // other direction — a STALE build still serving a retired verb, or a foreign binary that happens
35
+ // to expose an `entwurf_v2`. check-entwurf-bridge-boot binds this list to the real runtime
36
+ // tools/list, so a verb added or retired without updating it turns that gate red.
37
+ export const EXPECTED_TOOLS = [
38
+ "entwurf_v2",
39
+ "entwurf_self",
40
+ "entwurf_peers",
41
+ "entwurf_inbox_read",
42
+ "entwurf_register_native",
43
+ "entwurf_fresh_call",
44
+ "entwurf_resume_call",
45
+ ];
46
+ const DEFAULT_TIMEOUT_MS = 10_000;
47
+ /** How long a probed child gets to die politely before it is killed outright. */
48
+ const CLEANUP_GRACE_MS = 500;
49
+ /** Collapse captured stderr into one printable line — a doctor verdict must stay one line. */
50
+ function head(text, limit = 200) {
51
+ const flat = text.replace(/\s+/g, " ").trim();
52
+ return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
53
+ }
54
+ export function probeBridgeCommand(opts) {
55
+ const { command, args = [], env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
56
+ return new Promise((resolve) => {
57
+ // No shell: a bare name goes through the normal PATH lookup the harness itself does,
58
+ // and an argument can never be reinterpreted as shell syntax.
59
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env });
60
+ // `write()` reports a synchronous closed pipe by throwing, but a launcher that dies on exec
61
+ // closes stdin under us and the resulting EPIPE arrives ASYNCHRONOUSLY, as an 'error' event on
62
+ // the stream. Unlistened, Node turns that into an uncaught exception, so the probe dies
63
+ // printing its own stack trace INSTEAD of the launcher's stderr: precisely the diagnosis #81
64
+ // exists to surface, destroyed in the one cell that needs it. The window is real, not
65
+ // theoretical — under CPU contention the child can exec, write its stderr and exit between
66
+ // uv_spawn and our first write, which is how CI saw a doctor verdict with no launcher stderr
67
+ // in it. The `close` handler owns the verdict for that child (with captured stderr), so this
68
+ // listener is installed before anything can write and stays deliberately silent — one reason
69
+ // per failure, never an unhandled probe crash.
70
+ child.stdin.on("error", () => { });
71
+ let stdoutPending = "";
72
+ let stderr = "";
73
+ let settled = false;
74
+ let initialized = false;
75
+ let exited;
76
+ let stderrEnded = false;
77
+ // This probe deliberately runs whatever command the operator configured — including a
78
+ // foreign or broken one — so it owns closing what it opened. Escalate SIGTERM → SIGKILL
79
+ // once, bounded: cleanup for THIS child, not a supervisor (no retries, no watching, no
80
+ // process-tree walking).
81
+ //
82
+ // The reap is AWAITED before the result resolves, and that ordering is load-bearing: the
83
+ // callers are doctors that print a verdict and `process.exit()` immediately, which tears
84
+ // down any still-pending timer. A fire-and-forget SIGTERM plus a deferred SIGKILL would
85
+ // therefore leave a TERM-ignoring launcher running after the doctor is gone — the probe
86
+ // would leak exactly the kind of broken process it exists to detect.
87
+ const reap = () => new Promise((finished) => {
88
+ if (child.exitCode !== null || child.signalCode !== null)
89
+ return finished();
90
+ let reaped = false;
91
+ let hard;
92
+ const finish = () => {
93
+ if (reaped)
94
+ return;
95
+ reaped = true;
96
+ if (hard)
97
+ clearTimeout(hard);
98
+ finished();
99
+ };
100
+ child.once("close", finish);
101
+ try {
102
+ child.kill("SIGTERM");
103
+ }
104
+ catch {
105
+ return finish(); // already gone
106
+ }
107
+ hard = setTimeout(() => {
108
+ try {
109
+ child.kill("SIGKILL");
110
+ }
111
+ catch { }
112
+ // SIGKILL cannot be trapped, so `close` follows. The outer bound exists only so a
113
+ // child already reaped by someone else can never hang this promise forever.
114
+ setTimeout(finish, CLEANUP_GRACE_MS);
115
+ }, CLEANUP_GRACE_MS);
116
+ });
117
+ const done = (reason, detail) => {
118
+ if (settled)
119
+ return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ void reap().then(() => resolve({ ok: reason === "ok", reason, detail }));
123
+ };
124
+ const timer = setTimeout(() => {
125
+ const waitingFor = initialized ? "tools/list" : "initialize";
126
+ done("timeout", `'${command}' stayed up but never answered ${waitingFor} within ${timeoutMs}ms` +
127
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : ""));
128
+ }, timeoutMs);
129
+ child.on("error", (err) => {
130
+ done("spawn-failed", `'${command}' could not be executed: ${String(err)}`);
131
+ });
132
+ // `ChildProcess` close is normally after the stdio pipes close, but under nested shell
133
+ // capture that ordering has raced on this host. Do not mint a no-stderr diagnosis until the
134
+ // stderr reader ended: the launcher's own final diagnostic is part of the operator verdict.
135
+ const reportExited = () => {
136
+ if (!exited || !stderrEnded)
137
+ return;
138
+ // Reaching here un-settled means the process died before answering id:2. This is the
139
+ // cell the relocated pnpm shim lands in (exit 127), and the stderr head is what tells
140
+ // the operator WHICH launcher hop broke.
141
+ done("exited-before-tools-list", `'${command}' exited before answering tools/list (code=${exited.code} signal=${String(exited.signal)})` +
142
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : " — no stderr"));
143
+ };
144
+ child.on("close", (code, signal) => {
145
+ exited = { code, signal };
146
+ reportExited();
147
+ });
148
+ child.stderr.on("data", (d) => {
149
+ stderr += String(d);
150
+ });
151
+ child.stderr.on("end", () => {
152
+ stderrEnded = true;
153
+ reportExited();
154
+ });
155
+ child.stdout.on("data", (d) => {
156
+ const chunk = String(d);
157
+ stdoutPending += chunk;
158
+ if (settled)
159
+ return;
160
+ // MCP frames are newline-delimited JSON-RPC. Consume each complete frame exactly once:
161
+ // replaying the accumulated id:1 response on a later data event would send a second
162
+ // tools/list before the first reply and stop being a sequential handshake.
163
+ const frames = stdoutPending.split("\n");
164
+ stdoutPending = frames.pop() ?? "";
165
+ for (const line of frames) {
166
+ const trimmed = line.trim();
167
+ if (!trimmed)
168
+ continue;
169
+ let msg;
170
+ try {
171
+ msg = JSON.parse(trimmed);
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ if (msg?.id === 1) {
177
+ const result = msg.result;
178
+ const valid = msg.error === undefined &&
179
+ result?.protocolVersion === "2024-11-05" &&
180
+ typeof result.capabilities === "object" &&
181
+ result.capabilities !== null &&
182
+ typeof result.serverInfo === "object" &&
183
+ result.serverInfo !== null;
184
+ if (!valid) {
185
+ done("initialize-failed", `'${command}' returned an invalid MCP initialize response`);
186
+ return;
187
+ }
188
+ initialized = true;
189
+ sendReadyFrames();
190
+ continue;
191
+ }
192
+ if (msg?.id !== 2)
193
+ continue;
194
+ if (!initialized) {
195
+ done("initialize-failed", `'${command}' answered tools/list before MCP initialize completed`);
196
+ return;
197
+ }
198
+ const tools = msg?.result?.tools;
199
+ if (!Array.isArray(tools)) {
200
+ done("no-tools-array", `'${command}' answered tools/list without result.tools — not an MCP server`);
201
+ return;
202
+ }
203
+ const names = tools
204
+ .map((t) => t?.name)
205
+ .filter((n) => typeof n === "string");
206
+ const served = new Set(names);
207
+ const missing = EXPECTED_TOOLS.filter((t) => !served.has(t));
208
+ const unexpected = names.filter((n) => !EXPECTED_TOOLS.includes(n));
209
+ if (missing.length > 0 || unexpected.length > 0) {
210
+ // Name BOTH directions: a missing verb is the #81 symptom (the model had no
211
+ // entwurf_self), an unexpected one means this is not the bridge we ship.
212
+ const parts = [];
213
+ if (missing.length > 0)
214
+ parts.push(`missing ${missing.join(", ")}`);
215
+ if (unexpected.length > 0)
216
+ parts.push(`unexpected ${unexpected.join(", ")}`);
217
+ done("tool-set-mismatch", `'${command}' is an MCP server but does not serve this bridge's verb set — ${parts.join("; ")} (served: ${names.join(", ") || "none"})`);
218
+ return;
219
+ }
220
+ done("ok", `'${command}' booted and served the exact entwurf verb set (${EXPECTED_TOOLS.length} tools)`);
221
+ return;
222
+ }
223
+ });
224
+ const send = (obj) => {
225
+ try {
226
+ child.stdin.write(`${JSON.stringify(obj)}\n`);
227
+ }
228
+ catch {
229
+ // Synchronous throws (a destroyed stream) land here; async EPIPE lands on the
230
+ // listener above. Both are the same verdict-free silence.
231
+ }
232
+ };
233
+ const sendReadyFrames = () => {
234
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
235
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
236
+ };
237
+ send({
238
+ jsonrpc: "2.0",
239
+ id: 1,
240
+ method: "initialize",
241
+ params: {
242
+ protocolVersion: "2024-11-05",
243
+ capabilities: {},
244
+ clientInfo: { name: "probe-bridge-command", version: "0" },
245
+ },
246
+ });
247
+ });
248
+ }
249
+ // CLI entrypoint. Kept argv-driven (no env carrier) so `./run.sh probe-bridge-command entwurf-bridge`
250
+ // asks about exactly the name an operator typed, and a doctor can shell out to the same verdict.
251
+ // Match the BASENAME exactly. A suffix test would also fire for `check-probe-bridge-command.ts`,
252
+ // which imports this module — the CLI arm would then hijack that gate's argv and exit 2.
253
+ if (process.argv[1] && /^probe-bridge-command\.(ts|js)$/.test(basename(process.argv[1]))) {
254
+ const argv = process.argv.slice(2);
255
+ let options;
256
+ if (argv[0] === "--invocation-json") {
257
+ let raw;
258
+ try {
259
+ raw = JSON.parse(argv[1] ?? "");
260
+ }
261
+ catch {
262
+ console.error("probe-bridge-command: --invocation-json must be valid JSON");
263
+ process.exit(2);
264
+ }
265
+ const obj = raw;
266
+ if (typeof obj?.command !== "string" ||
267
+ !obj.command ||
268
+ !Array.isArray(obj.args) ||
269
+ !obj.args.every((arg) => typeof arg === "string") ||
270
+ typeof obj.env !== "object" ||
271
+ obj.env === null ||
272
+ Array.isArray(obj.env) ||
273
+ !Object.values(obj.env).every((value) => typeof value === "string")) {
274
+ console.error("probe-bridge-command: invocation JSON requires string command, string[] args, string-map env");
275
+ process.exit(2);
276
+ }
277
+ options = {
278
+ command: obj.command,
279
+ args: obj.args,
280
+ env: { ...process.env, ...obj.env },
281
+ };
282
+ }
283
+ else {
284
+ const [command, ...args] = argv;
285
+ if (!command) {
286
+ console.error("usage: probe-bridge-command <command> [args...]");
287
+ process.exit(2);
288
+ }
289
+ options = { command, args };
290
+ }
291
+ const result = await probeBridgeCommand(options);
292
+ console.log(`[probe-bridge-command] ${result.reason}: ${result.detail}`);
293
+ process.exit(result.ok ? 0 : 1);
294
+ }
@@ -42,13 +42,16 @@
42
42
  //
43
43
  // It is a TYPE closure, not a boot closure, and the difference is shipped bytes.
44
44
  // The retired claim here ("the pi-lane-only acp/* stays out of the tarball") is
45
- // FALSE: dist/pi-extensions/lib/acp/ carries 8 modules (acp-client, backend-adapter,
46
- // config, context, engraving, models, overlay, tool-surface) that no bridge code
47
- // path executes tsc followed a type reference and emitted them. Dead emit is a
45
+ // FALSE: dist/pi-extensions/lib/acp/ is not a boot closure. config.js IS reached —
46
+ // doctor-pi-provider (#81) value-imports it. The other 7 modules (acp-client,
47
+ // backend-adapter, context, engraving, models, overlay, tool-surface) execute on
48
+ // no bridge/operator path: tsc followed config.ts's `import type { AcpBackendAdapter }`
49
+ // and emitted backend-adapter.js, which value-imports the rest. Dead emit is a
48
50
  // correctness question only for anything that reads its own location (see the
49
- // registry corpse), but it is dead weight in every consumer's tarball. Cleanup is
51
+ // registry corpse), but the 7 are dead weight in every consumer's tarball. Cleanup is
50
52
  // tracked as a follow-up, and the choice is one of two: narrow the emit, or ship
51
- // the assets those modules expect. Do NOT restore the old sentence.
53
+ // the assets those modules expect. Do NOT restore the old sentence, and do NOT
54
+ // put config.js back on the dead list.
52
55
  //
53
56
  // WHY store-doctor rides THIS build: meta-bridge-doctor.sh's full store scan
54
57
  // must run from an INSTALLED package (under node_modules, where strip-types is
@@ -86,6 +89,12 @@
86
89
  // and run_ts refuses them under an installed package rather than emitting 70+
87
90
  // gate leaves into the tarball.
88
91
  //
92
+ // WHY probe-bridge-command rides this build (#81): doctor-pi-provider IMPORTS it, and that
93
+ // doctor is one of the three operator commands above — so on an installed host the import
94
+ // resolves under node_modules where strip-types is refused. Emitting the leaf keeps the
95
+ // installed doctor's new boot cell alive; a missing twin would kill the doctor before its
96
+ // first verdict line. Its only imports are node builtins, so it adds ZERO new deps.
97
+ //
89
98
  // WHY meta-facts rides this build (#65): it is the operator projection external
90
99
  // consumers call INSTEAD of parsing the store, and those consumers live on
91
100
  // INSTALLED hosts — under node_modules, where run_ts needs a compiled twin.
@@ -107,6 +116,7 @@
107
116
  "../../scripts/meta-bridge-store-doctor.ts",
108
117
  "../../scripts/agy-imprint.ts",
109
118
  "../../scripts/doctor-pi-provider.ts",
119
+ "../../scripts/probe-bridge-command.ts",
110
120
  "../../scripts/new-session-id.ts",
111
121
  "../../scripts/meta-bridge-prune.ts",
112
122
  "../../scripts/meta-bridge-fresh-cut.ts",