@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.
- package/AGENTS.md +13 -2
- package/CHANGELOG.md +63 -0
- package/DELIVERY.md +57 -0
- package/README.md +16 -7
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +14 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +13 -4
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/mux-fresh-call.ts +57 -4
- package/pi-extensions/lib/mux-resume-call.ts +21 -53
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +5 -3
- package/scripts/check-mux-resume-call.ts +11 -10
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-fresh-call.json +48 -4
- package/scripts/mutants/mux-resume-call.json +3 -3
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +12 -4
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
|
@@ -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).
|
|
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
|
|
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 {
|
|
17
|
-
import { existsSync, constants as FS, readFileSync
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
57
|
-
|
|
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
|
|
68
|
-
let
|
|
124
|
+
let effectiveEntry: BridgeEntry | null | undefined;
|
|
125
|
+
let userScopeDesc = "";
|
|
126
|
+
let projScopeDesc = "";
|
|
127
|
+
let effectiveScope = "none";
|
|
69
128
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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}: ${
|
|
83
|
-
log(` project ${projectPath}: ${
|
|
84
|
-
log(` EFFECTIVE (${effectiveScope}): ${
|
|
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 (
|
|
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 (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -74,7 +74,6 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
74
74
|
("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
|
|
75
75
|
("awaySummaryEnabled", ["awaySummaryEnabled"], False),
|
|
76
76
|
("autoMemoryEnabled", ["autoMemoryEnabled"], False),
|
|
77
|
-
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
78
77
|
("verbose", ["verbose"], False),
|
|
79
78
|
("autoCompactEnabled", ["autoCompactEnabled"], False),
|
|
80
79
|
("showTurnDuration", ["showTurnDuration"], False),
|
|
@@ -84,6 +83,19 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
84
83
|
("workflowKeywordTriggerEnabled", ["workflowKeywordTriggerEnabled"], False),
|
|
85
84
|
]
|
|
86
85
|
|
|
86
|
+
# Keys entwurf used to own but now returns to the operator. An old install-state
|
|
87
|
+
# entry is the only authority to undo our prior write; a bare matching value is
|
|
88
|
+
# not provenance and is never changed. apply() consumes each proven old entry
|
|
89
|
+
# exactly once, then uninstall can no longer restore over the operator's choice.
|
|
90
|
+
RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
91
|
+
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
_managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
|
|
95
|
+
_retired_scalar_names = {name for name, _path, _last in RETIRED_SETTINGS_SCALARS}
|
|
96
|
+
if overlap := _managed_scalar_names & _retired_scalar_names:
|
|
97
|
+
raise RuntimeError(f"settings scalar cannot be both managed and retired: {sorted(overlap)}")
|
|
98
|
+
|
|
87
99
|
|
|
88
100
|
class StateError(RuntimeError):
|
|
89
101
|
pass
|
|
@@ -391,6 +403,9 @@ def apply(repo: Path, asm: Path) -> None:
|
|
|
391
403
|
if not isinstance(root, dict):
|
|
392
404
|
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
393
405
|
|
|
406
|
+
for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
|
|
407
|
+
relinquish_retired_scalar(state, settings, name, path_, last_managed_value)
|
|
408
|
+
|
|
394
409
|
set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
|
|
395
410
|
set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
|
|
396
411
|
for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
|
|
@@ -446,6 +461,53 @@ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
|
|
|
446
461
|
die(f"unknown state entry kind: {kind}")
|
|
447
462
|
|
|
448
463
|
|
|
464
|
+
def settings_state_keys(state: dict[str, Any]) -> dict[str, Any]:
|
|
465
|
+
"""The settings key ledger, or a loud failure.
|
|
466
|
+
|
|
467
|
+
`load_state` validates only the envelope (schemaVersion/owner), so a consumer
|
|
468
|
+
that indexes straight into files.settings.keys turns a corrupt state file into
|
|
469
|
+
a bare KeyError traceback instead of an operator diagnostic. Ownership
|
|
470
|
+
decisions read this ledger, so an unreadable one must stop before any write.
|
|
471
|
+
"""
|
|
472
|
+
files = state.get("files")
|
|
473
|
+
entry = files.get("settings") if isinstance(files, dict) else None
|
|
474
|
+
keys = entry.get("keys") if isinstance(entry, dict) else None
|
|
475
|
+
if not isinstance(keys, dict):
|
|
476
|
+
die(f"install state {state_path()} has no files.settings.keys ledger; re-run install-meta-bridge")
|
|
477
|
+
return keys
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def relinquish_retired_scalar(
|
|
481
|
+
state: dict[str, Any], settings: dict[str, Any], name: str, path: list[str], last_managed_value: Any
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Return one formerly managed scalar to operator ownership.
|
|
484
|
+
|
|
485
|
+
Only a preserved install-state entry proves entwurf wrote this path. If the
|
|
486
|
+
current value still has the exact JSON scalar type+value we last managed,
|
|
487
|
+
restore the snapshot. A changed or absent current value is already the
|
|
488
|
+
operator's and stays untouched. Malformed provenance fails before any file
|
|
489
|
+
write; silently discarding it would make a still-dangerous value look clean.
|
|
490
|
+
"""
|
|
491
|
+
keys = settings_state_keys(state)
|
|
492
|
+
entry = keys.get(name)
|
|
493
|
+
if entry is None:
|
|
494
|
+
return
|
|
495
|
+
if not isinstance(entry, dict) or entry.get("kind") != "scalar" or entry.get("path") != path:
|
|
496
|
+
die(f"retired scalar state entry {name} is malformed; refusing to discard ownership evidence")
|
|
497
|
+
original = entry.get("original")
|
|
498
|
+
if (
|
|
499
|
+
not isinstance(original, dict)
|
|
500
|
+
or set(original) != {"existed", "value"}
|
|
501
|
+
or type(original.get("existed")) is not bool
|
|
502
|
+
):
|
|
503
|
+
die(f"retired scalar state entry {name} has malformed original; refusing to guess")
|
|
504
|
+
|
|
505
|
+
existed, value = get_nested(settings, path)
|
|
506
|
+
if existed and type(value) is type(last_managed_value) and value == last_managed_value:
|
|
507
|
+
restore_entry(settings, entry)
|
|
508
|
+
keys.pop(name)
|
|
509
|
+
|
|
510
|
+
|
|
449
511
|
def preflight_uninstall() -> None:
|
|
450
512
|
load_state(required=True)
|
|
451
513
|
print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
|
|
@@ -536,6 +598,18 @@ def check(repo: Path, asm: Path) -> None:
|
|
|
536
598
|
if recorded_asm
|
|
537
599
|
else desired_marketplace(asm)
|
|
538
600
|
)
|
|
601
|
+
state_settings_keys = settings_state_keys(state)
|
|
602
|
+
for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
|
|
603
|
+
if name in state_settings_keys:
|
|
604
|
+
failures.append(f"install-state still owns retired scalar {name}; re-run install-meta-bridge to relinquish it")
|
|
605
|
+
continue
|
|
606
|
+
existed, value = get_nested(settings, path_)
|
|
607
|
+
if existed and type(value) is type(last_managed_value) and value == last_managed_value:
|
|
608
|
+
print(
|
|
609
|
+
f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
|
|
610
|
+
f"entwurf no longer suppresses or restores this warning choice"
|
|
611
|
+
)
|
|
612
|
+
|
|
539
613
|
checks = [
|
|
540
614
|
(["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
|
|
541
615
|
(["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "bridge-command-boot",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "PI-DOCTOR-BOOT-NEGATIVE",
|
|
7
|
+
"title": "the pi doctor goes back to blessing a command that merely RESOLVES — the exact #81 false success, where a host ran ACP turns with no mcp__entwurf-bridge__* tool while the doctor printed ok",
|
|
8
|
+
"subject": "scripts/doctor-pi-provider.ts",
|
|
9
|
+
"find": ["\t\tif (probe.ok) {"],
|
|
10
|
+
"replace": ["\t\tif (probe.ok || true) {"],
|
|
11
|
+
"gate": ["bash", "scripts/smoke-pi-provider-state.sh"],
|
|
12
|
+
"timeoutSeconds": 120,
|
|
13
|
+
"signature": "[QK:PI-DOCTOR-BOOT-NEGATIVE]",
|
|
14
|
+
"signatureSource": "scripts/smoke-pi-provider-state.sh"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"claim": "AGY-DOCTOR-BOOT-NEGATIVE",
|
|
18
|
+
"title": "the agy doctor stops asking whether the configured command boots and reports the old '(resolvable)' green — a registered bridge agy could never actually call",
|
|
19
|
+
"subject": "scripts/agy-bridge.sh",
|
|
20
|
+
"find": [" if command_boots \"$invocation\"; then"],
|
|
21
|
+
"replace": [" if true; then"],
|
|
22
|
+
"gate": ["bash", "scripts/smoke-agy-install-state.sh"],
|
|
23
|
+
"timeoutSeconds": 300,
|
|
24
|
+
"signature": "[QK:AGY-DOCTOR-BOOT-NEGATIVE]",
|
|
25
|
+
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"claim": "PI-DOCTOR-PROBES-OVERRIDE",
|
|
29
|
+
"title": "the pi doctor skips runtime probing for a non-bare override, so a dead command that shadows the stable bridge returns green as an operator choice",
|
|
30
|
+
"subject": "scripts/doctor-pi-provider.ts",
|
|
31
|
+
"find": ["\tif (!resolvable(cmd)) {"],
|
|
32
|
+
"replace": [
|
|
33
|
+
"\tif (!isBare) {\n\t\tlog(` note: skipped runtime for override '${cmd}'`);\n\t} else if (!resolvable(cmd)) {"
|
|
34
|
+
],
|
|
35
|
+
"gate": ["bash", "scripts/smoke-pi-provider-state.sh"],
|
|
36
|
+
"timeoutSeconds": 120,
|
|
37
|
+
"signature": "[QK:PI-DOCTOR-PROBES-OVERRIDE]",
|
|
38
|
+
"signatureSource": "scripts/smoke-pi-provider-state.sh"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"claim": "AGY-DOCTOR-PROBES-ARGS",
|
|
42
|
+
"title": "the agy invocation parser drops configured args, so the doctor boots command defaults while agy executes a failing argv",
|
|
43
|
+
"subject": "scripts/agy-bridge-config.py",
|
|
44
|
+
"find": [" args = server.get(\"args\", [])"],
|
|
45
|
+
"replace": [" args = []"],
|
|
46
|
+
"gate": ["bash", "scripts/smoke-agy-install-state.sh"],
|
|
47
|
+
"timeoutSeconds": 300,
|
|
48
|
+
"signature": "[QK:AGY-DOCTOR-PROBES-ARGS]",
|
|
49
|
+
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"claim": "AGY-DOCTOR-PROBES-ENV",
|
|
53
|
+
"title": "the agy invocation parser drops configured environment, so the doctor boots command defaults while agy executes a failing environment",
|
|
54
|
+
"subject": "scripts/agy-bridge-config.py",
|
|
55
|
+
"find": [" env = server.get(\"env\", {})"],
|
|
56
|
+
"replace": [" env = {}"],
|
|
57
|
+
"gate": ["bash", "scripts/smoke-agy-install-state.sh"],
|
|
58
|
+
"timeoutSeconds": 300,
|
|
59
|
+
"signature": "[QK:AGY-DOCTOR-PROBES-ENV]",
|
|
60
|
+
"signatureSource": "scripts/smoke-agy-install-state.sh"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"claim": "PROBE-REASON-MISSING-VERB",
|
|
64
|
+
"title": "bridge identity shrinks back to a single tool, so a build missing entwurf_self — the measured #81 session shape — reads as a healthy bridge again",
|
|
65
|
+
"subject": "scripts/probe-bridge-command.ts",
|
|
66
|
+
"find": ["\t\t\t\tconst missing = EXPECTED_TOOLS.filter((t) => !served.has(t));"],
|
|
67
|
+
"replace": ["\t\t\t\tconst missing = [\"entwurf_v2\"].filter((t) => !served.has(t));"],
|
|
68
|
+
"gate": ["bash", "run.sh", "check-probe-bridge-command"],
|
|
69
|
+
"timeoutSeconds": 60,
|
|
70
|
+
"signature": "[QK:PROBE-REASON-MISSING-VERB]",
|
|
71
|
+
"signatureSource": "scripts/check-probe-bridge-command.ts"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"claim": "PROBE-REQUIRES-INITIALIZE",
|
|
75
|
+
"title": "the probe accepts tools/list before the MCP initialize handshake completes — a launcher the harness cannot initialize reads healthy",
|
|
76
|
+
"subject": "scripts/probe-bridge-command.ts",
|
|
77
|
+
"find": ["\t\t\t\tif (!initialized) {"],
|
|
78
|
+
"replace": ["\t\t\t\tif (false) {"],
|
|
79
|
+
"gate": ["bash", "run.sh", "check-probe-bridge-command"],
|
|
80
|
+
"timeoutSeconds": 60,
|
|
81
|
+
"signature": "[QK:PROBE-REQUIRES-INITIALIZE]",
|
|
82
|
+
"signatureSource": "scripts/check-probe-bridge-command.ts"
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"claim": "PROBE-CLEANUP-ESCALATES",
|
|
86
|
+
"title": "the probe stops escalating past SIGTERM, so a launcher that traps TERM outlives the doctor that spawned it — the probe leaks the broken process it exists to detect",
|
|
87
|
+
"subject": "scripts/probe-bridge-command.ts",
|
|
88
|
+
"find": ["\t\t\t\t\t\tchild.kill(\"SIGKILL\");"],
|
|
89
|
+
"replace": ["\t\t\t\t\t\tvoid 0;"],
|
|
90
|
+
"gate": ["bash", "run.sh", "check-probe-bridge-command"],
|
|
91
|
+
"timeoutSeconds": 60,
|
|
92
|
+
"signature": "[QK:PROBE-CLEANUP-ESCALATES]",
|
|
93
|
+
"signatureSource": "scripts/check-probe-bridge-command.ts"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"claim": "PROBE-STDIN-EPIPE-GUARD",
|
|
97
|
+
"title": "the async EPIPE from a launcher that died on exec goes unlistened again, so the probe dies on an uncaught stream error and the doctor prints a Node stack trace where the LAUNCHER's own stderr belongs — the #81 diagnosis erased in the one cell that needs it",
|
|
98
|
+
"subject": "scripts/probe-bridge-command.ts",
|
|
99
|
+
"find": ["\t\tchild.stdin.on(\"error\", () => {});"],
|
|
100
|
+
"replace": ["\t\tvoid 0;"],
|
|
101
|
+
"gate": ["bash", "run.sh", "check-probe-bridge-command"],
|
|
102
|
+
"timeoutSeconds": 60,
|
|
103
|
+
"signature": "[QK:PROBE-STDIN-EPIPE-GUARD]",
|
|
104
|
+
"signatureSource": "scripts/check-probe-bridge-command.ts"
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|