@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
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // check-probe-bridge-command — deterministic contract gate for the #81 boot probe.
3
+ //
4
+ // The probe is what two doctors now stake their verdict on, so its reason taxonomy is a contract,
5
+ // not an implementation detail: each value names a DIFFERENT operator situation, and collapsing two
6
+ // of them would put the wrong repair in front of an operator. This gate pins the classification and
7
+ // the one side effect the probe owns — reaping the child it spawned.
8
+ //
9
+ // Hermetic: every subject is a stub script in a mktemp dir. No network, no operator state, no MCP
10
+ // server of ours is booted (check-entwurf-bridge-boot owns that axis).
11
+ import { spawnSync } from "node:child_process";
12
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { EXPECTED_TOOLS, probeBridgeCommand } from "./probe-bridge-command.ts";
17
+
18
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const PROBE_SRC = readFileSync(join(REPO_ROOT, "scripts", "probe-bridge-command.ts"), "utf8");
20
+
21
+ let passed = 0;
22
+ // The `[QK:…]` token must land on a FAILURE line (qualification does not count a token printed on
23
+ // an `ok` line — a later red must not self-certify an earlier claim), and it must appear exactly
24
+ // once in this file. So: strip it from the green line, print it verbatim on the red one.
25
+ function ok(label: string, cond: boolean, detail?: string): void {
26
+ if (!cond) {
27
+ console.error(`FAIL: ${label}`);
28
+ if (detail) console.error(detail);
29
+ process.exit(1);
30
+ }
31
+ console.log(` ok ${label.replace(/\[QK:[^\]]+\]\s*/, "")}`);
32
+ passed++;
33
+ }
34
+
35
+ const dir = mkdtempSync(join(tmpdir(), "entwurf-probe-gate-"));
36
+ const stub = (name: string, body: string): string => {
37
+ const p = join(dir, name);
38
+ writeFileSync(p, body);
39
+ chmodSync(p, 0o755);
40
+ return p;
41
+ };
42
+
43
+ /** An MCP stub that answers tools/list with exactly the given verb names. */
44
+ const mcpStub = (name: string, names: readonly string[]): string => {
45
+ const tools = names.map((n) => `{"name":"${n}"}`).join(",");
46
+ return stub(
47
+ name,
48
+ `#!/usr/bin/env bash
49
+ while IFS= read -r line; do
50
+ case "$line" in
51
+ *'"id":1'*) printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-entwurf-bridge","version":"0"}}}' ;;
52
+ *'"id":2'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[${tools}]}}' ;;
53
+ esac
54
+ done
55
+ `,
56
+ );
57
+ };
58
+
59
+ try {
60
+ // ── the healthy shape ─────────────────────────────────────────────────────
61
+ const healthy = mcpStub("healthy", EXPECTED_TOOLS);
62
+ const rHealthy = await probeBridgeCommand({ command: healthy });
63
+ ok("the exact verb set is the only green", rHealthy.ok && rHealthy.reason === "ok", JSON.stringify(rHealthy));
64
+
65
+ // ── identity: booting an MCP server is not being THIS bridge ──────────────
66
+ // Missing one verb is the observed #81 shape (a session whose schema had no entwurf_self).
67
+ const partial = mcpStub(
68
+ "partial",
69
+ EXPECTED_TOOLS.filter((t) => t !== "entwurf_self"),
70
+ );
71
+ const rPartial = await probeBridgeCommand({ command: partial });
72
+ ok(
73
+ "[QK:PROBE-REASON-MISSING-VERB] a served MCP surface MISSING a verb is a mismatch, not ok",
74
+ !rPartial.ok && rPartial.reason === "tool-set-mismatch" && rPartial.detail.includes("missing entwurf_self"),
75
+ JSON.stringify(rPartial),
76
+ );
77
+ // The other direction: a stale build still serving a retired verb must not read as this bridge.
78
+ const extra = mcpStub("extra", [...EXPECTED_TOOLS, "entwurf_retired_verb"]);
79
+ const rExtra = await probeBridgeCommand({ command: extra });
80
+ ok(
81
+ "an EXTRA verb is a mismatch too (stale build / foreign binary)",
82
+ !rExtra.ok && rExtra.reason === "tool-set-mismatch" && rExtra.detail.includes("unexpected entwurf_retired_verb"),
83
+ JSON.stringify(rExtra),
84
+ );
85
+
86
+ // ── the defect that started #81: resolves, then dies on exec ──────────────
87
+ const dead = stub("dead", "#!/usr/bin/env bash\necho 'boom: no such file or directory' >&2\nexit 127\n");
88
+ const rDead = await probeBridgeCommand({ command: dead });
89
+ ok(
90
+ "a launcher that exits before tools/list is classified as such, with its stderr",
91
+ !rDead.ok && rDead.reason === "exited-before-tools-list" && rDead.detail.includes("boom"),
92
+ JSON.stringify(rDead),
93
+ );
94
+
95
+ // ── not executable at all ─────────────────────────────────────────────────
96
+ const rMissing = await probeBridgeCommand({ command: join(dir, "does-not-exist") });
97
+ ok(
98
+ "an unexecutable command is spawn-failed, NOT a boot failure",
99
+ !rMissing.ok && rMissing.reason === "spawn-failed",
100
+ JSON.stringify(rMissing),
101
+ );
102
+
103
+ // ── initialize is a handshake, not a frame we merely write ───────────────
104
+ const noInitialize = stub(
105
+ "no-initialize",
106
+ `#!/usr/bin/env bash
107
+ printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[${EXPECTED_TOOLS.map((n) => `{"name":"${n}"}`).join(",")}]}}'
108
+ while IFS= read -r _line; do :; done
109
+ `,
110
+ );
111
+ const rNoInitialize = await probeBridgeCommand({ command: noInitialize, timeoutMs: 700 });
112
+ ok(
113
+ "[QK:PROBE-REQUIRES-INITIALIZE] an exact tools/list sent before initialize completes is not a healthy MCP bridge",
114
+ !rNoInitialize.ok && rNoInitialize.reason === "initialize-failed",
115
+ JSON.stringify(rNoInitialize),
116
+ );
117
+
118
+ // ── not an MCP server (answers id:2, no tools array) ──────────────────────
119
+ const notMcp = stub(
120
+ "not-mcp",
121
+ `#!/usr/bin/env bash
122
+ while IFS= read -r line; do
123
+ case "$line" in
124
+ *'"id":1'*) printf '%s\\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"not-mcp","version":"0"}}}' ;;
125
+ *'"id":2'*) printf '%s\\n' '{"jsonrpc":"2.0","id":2,"result":{}}' ;;
126
+ esac
127
+ done
128
+ `,
129
+ );
130
+ const rNotMcp = await probeBridgeCommand({ command: notMcp });
131
+ ok(
132
+ "an id:2 reply without result.tools is not an MCP server",
133
+ !rNotMcp.ok && rNotMcp.reason === "no-tools-array",
134
+ JSON.stringify(rNotMcp),
135
+ );
136
+
137
+ // ── timeout + bounded cleanup ─────────────────────────────────────────────
138
+ // The probe runs whatever the operator configured, including a launcher that ignores SIGTERM.
139
+ // If it resolved without escalating, that process would outlive the doctor. The stub records
140
+ // its own pid so this gate can assert the reap actually happened — an unobserved kill is a
141
+ // claim, not evidence.
142
+ const pidFile = join(dir, "hung.pid");
143
+ const hung = stub(
144
+ "hung",
145
+ `#!/usr/bin/env bash
146
+ trap '' TERM
147
+ echo $$ > ${JSON.stringify(pidFile)}
148
+ while true; do sleep 0.05; done
149
+ `,
150
+ );
151
+ const rHung = await probeBridgeCommand({ command: hung, timeoutMs: 700 });
152
+ ok(
153
+ "a launcher that stays up but never answers is a timeout",
154
+ !rHung.ok && rHung.reason === "timeout",
155
+ JSON.stringify(rHung),
156
+ );
157
+ const hungPid = Number(readFileSync(pidFile, "utf8").trim());
158
+ ok(
159
+ "timeout stub recorded a usable pid (the cleanup assertion below has a subject)",
160
+ Number.isInteger(hungPid) && hungPid > 0,
161
+ );
162
+ // SIGTERM is trapped, so only the SIGKILL escalation can end it — and the probe must have
163
+ // completed that escalation BEFORE returning. Checking right here (no polling, no grace loop)
164
+ // is what pins that ordering: a probe that merely scheduled the kill would fail this cell,
165
+ // which is exactly the state a doctor's immediate process.exit() would leave behind.
166
+ const alive = spawnSync("kill", ["-0", String(hungPid)], { stdio: "ignore" }).status === 0;
167
+ // Clean up BEFORE asserting. When this claim's mutant re-plants the leak, `alive` is true and
168
+ // the assertion exits the process immediately — so without this line the gate that exists to
169
+ // prove nothing is orphaned would itself orphan the stub on the host, once per qualification
170
+ // run. A killed mutant must still leave zero residue.
171
+ if (alive) spawnSync("kill", ["-9", String(hungPid)], { stdio: "ignore" });
172
+ ok(
173
+ "[QK:PROBE-CLEANUP-ESCALATES] a SIGTERM-ignoring child is escalated to SIGKILL, never left orphaned",
174
+ !alive,
175
+ `pid ${hungPid} was still alive after the probe returned — the probe leaked the process it spawned`,
176
+ );
177
+
178
+ // ── source pin: the stdin EPIPE guard ─────────────────────────────────────
179
+ // WHY pinned instead of measured: the failure needs the child to exec, die and close its stdin
180
+ // read end BETWEEN uv_spawn and the parent's first write. Nothing this gate can write to a stub
181
+ // controls that ordering — measured on this host, five stub shapes (bad shebang, self-closed
182
+ // stdin, instant exit, missing file, a directory) produced EPIPE 0/20 each while idle, and only
183
+ // 12-way CPU contention opened the window (11/60 crashes before the fix, 0/60 after). A cell
184
+ // that only fires under load is a flaky gate, not a proof, so the property is pinned at the
185
+ // source. It is load-bearing because the crash it prevents is silent-by-substitution: the probe
186
+ // dies printing a Node stack trace where the doctor verdict should carry the LAUNCHER's stderr —
187
+ // the one line that names which hop broke. Ordering matters as much as presence: the listener
188
+ // must be installed before any write can dispatch.
189
+ const guard = '\t\tchild.stdin.on("error", () => {});';
190
+ const guardAt = PROBE_SRC.indexOf(guard);
191
+ const firstWriteAt = PROBE_SRC.indexOf("child.stdin.write(");
192
+ ok(
193
+ "[QK:PROBE-STDIN-EPIPE-GUARD] child.stdin carries an error listener installed before the first write — an async EPIPE from a launcher that died on exec must never replace the verdict with an uncaught exception",
194
+ guardAt >= 0 && firstWriteAt >= 0 && guardAt < firstWriteAt,
195
+ `guard index ${guardAt}, first child.stdin.write index ${firstWriteAt} in scripts/probe-bridge-command.ts`,
196
+ );
197
+ } finally {
198
+ rmSync(dir, { recursive: true, force: true });
199
+ }
200
+
201
+ console.log(`check-probe-bridge-command: ${passed} checks passed`);
@@ -439,6 +439,58 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
439
439
  );
440
440
  }
441
441
 
442
+ // ===========================================================================
443
+ // The operator's CONFIGURED bridge invocation is proven BEFORE the cost-bearing LIVE tier
444
+ //
445
+ // `check-bridge` boots the launcher this checkout SHIPS. It cannot speak for the string
446
+ // pi's provider actually execs, and only that second one reaches a live ACP session.
447
+ // 2026-08-19 measured the difference at full price: a relocated `~/.local/bin/entwurf-bridge`
448
+ // symlink into a pnpm cmd-shim (basedir derived from $0, so not relocatable) exited 127, the
449
+ // bundled bridge never booted, and the model had no `mcp__entwurf-bridge__*` tool — while
450
+ // `command -v` answered yes throughout. #81 had already built the leaf that settles this in
451
+ // under a second, and doctor-pi-provider consumes it; it was simply never a step, so the
452
+ // verdict surfaced sixteen LIVE steps later at smoke-acp-bundled-mcp-live.
453
+ // Pinned here because the step is CHEAP and therefore easy to drop again "to speed the gate up":
454
+ // its whole value is the position, so both the position and the classifier arm are asserted.
455
+ // run_step, not run_live_step — this doctor is not LIVE-gated and never emits 97, so a SKIP arm
456
+ // would describe a prerequisite it cannot have.
457
+ // ===========================================================================
458
+ {
459
+ const runSh = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
460
+ const gateBody = runSh.slice(runSh.indexOf("release_gate() {"), runSh.indexOf("# 5. Summary"));
461
+ const verify = readFileSync(join(REPO_DIR, "VERIFY.md"), "utf8");
462
+ const STEP =
463
+ 'run_step "doctor-pi-provider (#81: the operator\'s CONFIGURED bridge invocation actually boots)" gate bash "$self" doctor-pi-provider';
464
+ const doctorSteps = gateBody.split("\n").filter((l) => l.includes('"$self" doctor-pi-provider'));
465
+ const gaps: string[] = [];
466
+ if (doctorSteps.length !== 1)
467
+ gaps.push(`release_gate runs doctor-pi-provider ${doctorSteps.length}x (need exactly one MUST step)`);
468
+ if (doctorSteps.length === 1 && doctorSteps[0]?.trim() !== STEP)
469
+ gaps.push(`the doctor step is not the exact pinned invocation (got: ${doctorSteps[0]?.trim()})`);
470
+ if (doctorSteps.length === 1 && doctorSteps[0]?.includes("run_live_step"))
471
+ gaps.push("the doctor step goes through run_live_step, inventing a SKIP arm for a doctor that never emits 97");
472
+ // Position is the point: a cheap probe scheduled after the expensive tier proves nothing new.
473
+ const doctorAt = gateBody.indexOf('"$self" doctor-pi-provider');
474
+ // Anchored on the step KEYWORD, not the bare name: prose above this step already discusses
475
+ // smoke-acp-bundled-mcp-live by name, and a needle that a comment can satisfy would drag the
476
+ // boundary backwards and fail an ordering that is actually fine.
477
+ const firstLiveAt = gateBody.indexOf('run_live_step "smoke-acp-');
478
+ if (doctorAt >= 0 && firstLiveAt >= 0 && doctorAt > firstLiveAt)
479
+ gaps.push("the doctor step runs AFTER the ACP LIVE tier — the fail-fast position that motivates it is gone");
480
+ if (!verify.includes("doctor-pi-provider"))
481
+ gaps.push("VERIFY.md does not name doctor-pi-provider in the MUST tier at all");
482
+ if (!verify.includes("the invocation the operator's pi provider actually EXECS"))
483
+ gaps.push("VERIFY.md no longer states WHICH invocation this step proves (ships vs execs)");
484
+ assert.ok(
485
+ gaps.length === 0,
486
+ "[QK:PI-DOCTOR-IS-RELEASE-MUST] the operator's configured bridge invocation must be booted as a release-gate " +
487
+ "MUST exactly once, through run_step (no SKIP arm — this doctor never emits 97), positioned BEFORE the ACP " +
488
+ "LIVE tier, with VERIFY naming it and keeping 'what this checkout ships' apart from 'what the provider execs' " +
489
+ "— drop any of those and the #81 127-class defect goes back to costing sixteen LIVE steps to discover. " +
490
+ `Broken: ${gaps.join("; ")}`,
491
+ );
492
+ }
493
+
442
494
  console.log(
443
495
  "[check-release-gate-outcomes] ok — STEP OUTCOME protocol: one skip exit code shared by the shell and TS halves " +
444
496
  `(${LIVE_SKIP_EXIT}, clear of the per-tool 0..4 and shell 126+ bands), classifier maps 0→PASS / skip→SKIP / ` +
@@ -448,5 +500,6 @@ console.log(
448
500
  "a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
449
501
  "LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
450
502
  "check-gate-qualification stays reachable on its owners (absent from the default chain, exactly once in CI, " +
451
- "exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it",
503
+ "exactly once as a release-gate MUST step) and the CI step qualifies the FULL floor, which runs before it; and " +
504
+ "the operator's CONFIGURED bridge invocation is booted exactly once through run_step, before the ACP LIVE tier",
452
505
  );
@@ -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.ts";
33
+ import { normalizeMcpServers, readProviderSettingsFile } from "../pi-extensions/lib/acp/config.ts";
34
+ import { probeBridgeCommand } from "./probe-bridge-command.ts";
21
35
 
22
36
  const BARE = "entwurf-bridge";
23
37
  const KEY = "entwurf-bridge";
@@ -32,12 +46,55 @@ const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-prov
32
46
  let hardFail = 0;
33
47
  const log = (s: string) => process.stdout.write(s + "\n");
34
48
 
35
- function commandOf(settings: { mcpServers?: Record<string, unknown> }): string | undefined {
36
- const entry = settings.mcpServers?.[KEY];
37
- if (entry && typeof entry === "object" && typeof (entry as { command?: unknown }).command === "string") {
38
- return (entry as { command: string }).command;
39
- }
40
- return undefined;
49
+ /** The configured stdio invocation for our key, as PRODUCTION would build it. */
50
+ interface BridgeEntry {
51
+ command: string;
52
+ args: string[];
53
+ env: Record<string, string>;
54
+ }
55
+
56
+ // Build the effective entry EXACTLY the way production does (config.ts `resolveProviderConfig`):
57
+ // a shallow PER-NAME merge of the two `mcpServers` maps, then ONE `normalizeMcpServers` over the
58
+ // merged map. Hand-parsing command/args (this doctor's first shape) dropped a malformed arg
59
+ // silently and probed an invocation production never runs.
60
+ //
61
+ // The ORDER is load-bearing too. Normalizing each file separately validates entries production
62
+ // never sees: a malformed server in the global map that the project map shadows is gone by the time
63
+ // pi normalizes, yet a per-file doctor throws on it and calls a session red that in fact starts
64
+ // fine. That is the same "the doctor's subject differs from the runtime's" defect as the silent
65
+ // arg-drop, pointed the other way — and it is not confined to our key, since any unrelated global
66
+ // server could trip it.
67
+ //
68
+ // THROWS (McpServerConfigError) when the MERGED map is malformed — the fail-loud path, caught by
69
+ // the caller. That error names the offending SERVER and reason, not a file: after the merge an
70
+ // entry no longer belongs to one scope, so claiming a filename here would be a guess. A non-stdio
71
+ // (http/sse) entry is returned as `null` so the caller can say WHY it cannot be probed instead of
72
+ // pretending it is absent.
73
+ function mergedBridgeEntry(
74
+ globalSettings: { mcpServers?: Record<string, unknown> },
75
+ projectSettings: { mcpServers?: Record<string, unknown> },
76
+ ): BridgeEntry | null | undefined {
77
+ const mergedRaw = { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) };
78
+ const { servers } = normalizeMcpServers(mergedRaw);
79
+ const entry = servers.find((srv) => srv.name === KEY);
80
+ if (entry === undefined) return undefined; // not configured in either scope
81
+ if ("url" in entry) return null; // http/sse — a real config, but nothing to spawn
82
+ return {
83
+ command: entry.command,
84
+ args: entry.args,
85
+ // The ACP wire shape is a name/value list; collapse it to the env map a spawn wants.
86
+ env: Object.fromEntries(entry.env.map((kv) => [kv.name, kv.value])),
87
+ };
88
+ }
89
+
90
+ // Per-scope DISPLAY only — never validation. The scope lines exist so an operator can see which
91
+ // file supplied the effective entry; judging shape here would re-introduce the per-file validation
92
+ // the merge rule above exists to avoid.
93
+ function describeScope(settings: { mcpServers?: Record<string, unknown> }): string {
94
+ const raw = settings.mcpServers?.[KEY];
95
+ if (raw === undefined) return "entwurf-bridge NOT configured";
96
+ const cmd = (raw as { command?: unknown } | null)?.command;
97
+ return typeof cmd === "string" ? `'${cmd}'` : "configured (shape judged in the merged view)";
41
98
  }
42
99
 
43
100
  // Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
@@ -45,16 +102,16 @@ function commandOf(settings: { mcpServers?: Record<string, unknown> }): string |
45
102
  function resolvable(cmd: string): boolean {
46
103
  if (cmd.includes("/")) {
47
104
  try {
48
- statSync(cmd);
49
- // eslint-disable-next-line no-bitwise
50
- return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
105
+ accessSync(cmd, FS.X_OK);
106
+ return true;
51
107
  } catch {
52
108
  return false;
53
109
  }
54
110
  }
55
111
  try {
56
- // `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
57
- execSync(`command -v ${cmd}`, { stdio: "ignore" });
112
+ // `command -v` is a POSIX sh builtin. Pass the configured name as argv, never shell text:
113
+ // an unowned override is still operator data and must not become a doctor injection surface.
114
+ execFileSync("sh", ["-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", cmd], { stdio: "ignore" });
58
115
  return true;
59
116
  } catch {
60
117
  return false;
@@ -64,24 +121,41 @@ function resolvable(cmd: string): boolean {
64
121
  log("[pi-provider doctor]");
65
122
 
66
123
  // Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
67
- let userCmd: string | undefined;
68
- let projCmd: string | undefined;
124
+ let effectiveEntry: BridgeEntry | null | undefined;
125
+ let userScopeDesc = "";
126
+ let projScopeDesc = "";
127
+ let effectiveScope = "none";
69
128
  try {
70
- userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
71
- projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
129
+ const userSettings = readProviderSettingsFile(globalPath).settings;
130
+ const projSettings = readProviderSettingsFile(projectPath).settings;
131
+ userScopeDesc = describeScope(userSettings);
132
+ projScopeDesc = describeScope(projSettings);
133
+ // EFFECTIVE = project shadows user per-NAME. The whole ENTRY shadows, not a field of it, so
134
+ // command, args and env always come from one scope; the scope label is decided by which map
135
+ // owns the key, which is exactly what the merge spread resolves.
136
+ effectiveEntry = mergedBridgeEntry(userSettings, projSettings);
137
+ const ownsKey = (m: Record<string, unknown> | undefined): boolean => m !== undefined && Object.hasOwn(m, KEY);
138
+ effectiveScope = ownsKey(projSettings.mcpServers)
139
+ ? "project"
140
+ : ownsKey(userSettings.mcpServers)
141
+ ? "user(global)"
142
+ : "none";
72
143
  } catch (err) {
73
144
  log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
74
145
  process.exit(1);
75
146
  }
76
147
 
77
- // EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
78
- const effectiveCmd = projCmd ?? userCmd;
79
- const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
148
+ const effectiveDesc =
149
+ effectiveEntry === undefined
150
+ ? "entwurf-bridge NOT configured"
151
+ : effectiveEntry === null
152
+ ? "configured as an http/sse server"
153
+ : `'${effectiveEntry.command}'`;
80
154
 
81
155
  log("── scopes (project shadows user per-name)");
82
- log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
83
- log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
84
- log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
156
+ log(` user(global) ${globalPath}: ${userScopeDesc}`);
157
+ log(` project ${projectPath}: ${projScopeDesc}`);
158
+ log(` EFFECTIVE (${effectiveScope}): ${effectiveDesc}`);
85
159
 
86
160
  // install-state ownership (user scope). absent state on a configured effective is either a
87
161
  // pre-Task-2 install or a user-override we deliberately did not own.
@@ -100,36 +174,66 @@ if (existsSync(statePath)) {
100
174
  }
101
175
 
102
176
  log("── verdict");
103
- if (effectiveCmd === undefined) {
177
+ if (effectiveEntry === undefined) {
104
178
  log(
105
179
  " note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).",
106
180
  );
107
- } else if (effectiveCmd === BARE) {
108
- if (resolvable(effectiveCmd)) {
109
- log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
110
- } else {
111
- log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
112
- hardFail = 1;
113
- }
181
+ } else if (effectiveEntry === null) {
182
+ // A real, well-formed config that this bridge cannot be: entwurf-bridge is a stdio server, so
183
+ // an http/sse entry under our key means pi would connect to something that is not us. Nothing
184
+ // here is spawnable, so there is no boot to prove — say that plainly rather than reporting the
185
+ // absence of a failure as ok.
186
+ log(
187
+ ` 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.`,
188
+ );
189
+ hardFail = 1;
114
190
  } else {
115
- // effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
116
- // the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
117
- // user override — say so distinctly so "run setup" is the clear next step. A truly foreign
118
- // command is an unowned override left as the operator's choice. Neither is a hard fail.
119
- const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
120
- if (ownership && ownership !== "user-override") {
121
- log(
122
- ` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`,
123
- );
191
+ const cmd = effectiveEntry.command;
192
+ const isBare = cmd === BARE;
193
+ const isLegacyManaged = cmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
194
+
195
+ // Runtime truth is independent of ownership truth. An unowned override remains the operator's
196
+ // choice, but it still shadows the stable bridge in production; calling a dead override green
197
+ // repeats #81 under a different spelling. Probe the exact normalized command + args + env for
198
+ // EVERY effective stdio entry, then classify who owns that entry separately below.
199
+ if (!resolvable(cmd)) {
200
+ log(` FAIL: effective command '${cmd}' does NOT resolve or is not executable.`);
124
201
  hardFail = 1;
125
- } else if (isLegacyManaged) {
126
- log(
127
- ` 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 '?').`,
128
- );
129
202
  } else {
130
- log(
131
- ` 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).`,
132
- );
203
+ log(` effective command '${cmd}' RESOLVES — probing the exact configured invocation…`);
204
+ const probe = await probeBridgeCommand({
205
+ command: cmd,
206
+ args: effectiveEntry.args,
207
+ env: { ...process.env, ...effectiveEntry.env },
208
+ });
209
+ if (probe.ok) {
210
+ log(
211
+ isBare
212
+ ? ` ok: effective command is the bare stable bin '${BARE}' and it BOOTS — ${probe.detail}`
213
+ : ` runtime: configured override BOOTS — ${probe.detail}`,
214
+ );
215
+ } else {
216
+ log(` FAIL: effective invocation does NOT serve MCP [${probe.reason}] — ${probe.detail}`);
217
+ log(` Diagnose its launcher and configured args/env; entwurf will not overwrite an unowned command.`);
218
+ hardFail = 1;
219
+ }
220
+ }
221
+
222
+ if (!isBare) {
223
+ // Ownership classification never rounds a broken runtime up to green. It only says who may
224
+ // repair the non-canonical entry after the independent boot verdict above.
225
+ if (ownership && ownership !== "user-override") {
226
+ log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${cmd}'.`);
227
+ hardFail = 1;
228
+ } else if (isLegacyManaged) {
229
+ log(
230
+ ` note: effective is our LEGACY managed repo path ('${cmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize.`,
231
+ );
232
+ } else {
233
+ log(
234
+ ` 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.`,
235
+ );
236
+ }
133
237
  }
134
238
  }
135
239
 
@@ -54,8 +54,8 @@
54
54
  "claim": "REUSE-CARRIES-CHILD-DIAGNOSTICS",
55
55
  "title": "the reuse path drops the child's stderr tail again — exactly the live sonnet failure that showed only \"ACP connection closed\"",
56
56
  "subject": "pi-extensions/lib/acp/backend.ts",
57
- "find": ["\t\t\tfinishError(err, aborted, session.stderrTail);"],
58
- "replace": ["\t\t\tfinishError(err, aborted);"],
57
+ "find": ["\t\t\tfinishError(err, aborted, session.stderrTail, lifecycle);"],
58
+ "replace": ["\t\t\tfinishError(err, aborted, undefined, lifecycle);"],
59
59
  "gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
60
60
  "timeoutSeconds": 180,
61
61
  "signature": "[QK:REUSE-CARRIES-CHILD-DIAGNOSTICS]",
@@ -89,12 +89,34 @@
89
89
  "claim": "RETIRED-TEARDOWN-NOT-ANNOUNCED",
90
90
  "title": "a teardown we performed ourselves is reported as a death — every ordinary turn-scoped turn cries wolf about its own cleanup",
91
91
  "subject": "pi-extensions/lib/acp/backend.ts",
92
- "find": ["\t} else if (!session.retiring) {"],
92
+ "find": ["\t} else if (!session.retiring && !session.reporting) {"],
93
93
  "replace": ["\t} else {"],
94
94
  "gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
95
95
  "timeoutSeconds": 180,
96
96
  "signature": "[QK:RETIRED-TEARDOWN-NOT-ANNOUNCED]",
97
97
  "signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
98
+ },
99
+ {
100
+ "claim": "EOF-FIRST-CARRIES-CHILD-END",
101
+ "title": "the failing turn seals the moment the transport closes, without waiting for the child's exit — re-plants the #72 field signature (a bare \"ACP connection closed\" naming neither exit code nor signal)",
102
+ "subject": "pi-extensions/lib/acp/backend.ts",
103
+ "find": ["\tawait settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);"],
104
+ "replace": ["\tvoid CHILD_END_SETTLE_MS;"],
105
+ "gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
106
+ "timeoutSeconds": 180,
107
+ "signature": "[QK:EOF-FIRST-CARRIES-CHILD-END]",
108
+ "signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
109
+ },
110
+ {
111
+ "claim": "CHILD-END-SILENCE-BOUNDED",
112
+ "title": "the post-mortem window grows past the bound it contracts for — a turn whose child never reports an end waits on a clock the caller did not choose",
113
+ "subject": "pi-extensions/lib/acp/backend.ts",
114
+ "find": ["\tawait settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);"],
115
+ "replace": ["\tawait settleChildEnd(session.childEnd, 2_500);"],
116
+ "gate": ["bash", "run.sh", "check-acp-prompt-lifecycle"],
117
+ "timeoutSeconds": 180,
118
+ "signature": "[QK:CHILD-END-SILENCE-BOUNDED]",
119
+ "signatureSource": "scripts/check-acp-prompt-lifecycle.ts"
98
120
  }
99
121
  ]
100
122
  }