@junghanacs/entwurf 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +113 -0
- package/DELIVERY.md +73 -3
- package/README.md +55 -4
- package/VERIFY.md +6 -6
- 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 +26 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- 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 +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -6
- package/package.json +23 -12
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +389 -26
- 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-copilot-birth-hook.ts +497 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +10 -3
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +4 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +203 -1
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- 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-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +3 -1
- package/scripts/smoke-mux-lifecycle-live.ts +4 -2
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
- package/scripts/tsconfig.json +1 -0
|
@@ -168,6 +168,38 @@ check("upsertMetaSession: creates the store dir if absent; leaves no .tmp residu
|
|
|
168
168
|
}
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
check("upsertMetaSession: unknown-backend neighbour refuses as a stale reader, not a fresh-cut", () => {
|
|
172
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-stale-reader-"));
|
|
173
|
+
try {
|
|
174
|
+
const gid = "20260101T000000-0a1b2c";
|
|
175
|
+
fs.writeFileSync(
|
|
176
|
+
path.join(dir, `${gid}.meta.json`),
|
|
177
|
+
`${JSON.stringify({
|
|
178
|
+
schemaVersion: 3,
|
|
179
|
+
gardenId: gid,
|
|
180
|
+
backend: "gemini",
|
|
181
|
+
nativeSessionId: "n-unknown-backend",
|
|
182
|
+
cwd: "/synthetic",
|
|
183
|
+
model: null,
|
|
184
|
+
transcriptPath: null,
|
|
185
|
+
createdAt: T0.toISOString(),
|
|
186
|
+
recordUpdatedAt: T0.toISOString(),
|
|
187
|
+
})}\n`,
|
|
188
|
+
);
|
|
189
|
+
assert.throws(
|
|
190
|
+
() => upsertMetaSession({ dir, input: claudeInput(), now: T1 }),
|
|
191
|
+
(err: unknown) =>
|
|
192
|
+
err instanceof MetaRecordError &&
|
|
193
|
+
err.message.includes('"backend" must be one of') &&
|
|
194
|
+
err.message.includes("the reader is stale") &&
|
|
195
|
+
err.message.includes("do not archive those records"),
|
|
196
|
+
"unknown-backend store must name a stale reader, not only a fresh-cut",
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
171
203
|
// ---------------------------------------------------------------- exclusive CREATE publish (#66 fail-closed)
|
|
172
204
|
check("publishExclusiveIdentity: absent final publishes, round-trips, no tmp residue", () => {
|
|
173
205
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-excl-"));
|
|
@@ -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
|
);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-bridge-doctor.sh — the FAIL-LOUD surface for the Copilot birth plugin (#82).
|
|
3
|
+
#
|
|
4
|
+
# WHY IT IS NOT `doctor-meta-bridge --copilot`. The Claude doctor's red conditions
|
|
5
|
+
# certify Claude's receiver marker and FileChanged/asyncRewake hook rail. Copilot has a
|
|
6
|
+
# receiver too since #82 RAIL 5, but it is a different mechanism owned by a different
|
|
7
|
+
# process (a forked extension) and certified by its own doctor,
|
|
8
|
+
# `doctor-copilot-receive`. This BIRTH doctor certifies birth and who-sent. A shared
|
|
9
|
+
# doctor would make one rail pass on evidence owned by another, which is a branch, not a
|
|
10
|
+
# shared surface.
|
|
11
|
+
#
|
|
12
|
+
# (Until #82 RAIL 5b it had none of the four, and this comment said so. The sender
|
|
13
|
+
# marker joined because who-sent needs a shared parent, not a doorbell — the two facts
|
|
14
|
+
# had been merged under one absence.)
|
|
15
|
+
#
|
|
16
|
+
# WHAT A PASS FROM THIS DOCTOR MEANS, AND WHAT IT DOES NOT. Copilot exposes no plugin
|
|
17
|
+
# load or hook-execution receipt, so this doctor can prove the artifact is correct and
|
|
18
|
+
# REGISTERED, never that Copilot loaded it. A PASS with zero records is therefore
|
|
19
|
+
# consistent with two different worlds — a session not yet spoken to, and a unit Copilot
|
|
20
|
+
# silently never invokes. Only a real first prompt separates them, and that receipt (a
|
|
21
|
+
# record in the store) is what closes admission — not this doctor (cross-review, terra).
|
|
22
|
+
#
|
|
23
|
+
# THE ONE HONEST DIFFERENCE FROM THE CLAUDE DOCTOR. There, a plugin installed with
|
|
24
|
+
# zero meta-records is a SILENT MISS and exits non-zero, because a Claude session
|
|
25
|
+
# mints at session open — so zero records means something ate the hook. A Copilot
|
|
26
|
+
# session is born on its FIRST PROMPT (measured: opening the TUI fires no hook at
|
|
27
|
+
# all), so "installed, zero records" is the ordinary state of a Copilot that has been
|
|
28
|
+
# opened and not yet spoken to. Calling that red would train the operator to ignore
|
|
29
|
+
# the doctor. It is reported as NOT-YET, by name, and the red condition is a hook that
|
|
30
|
+
# RAN and FAILED — an ERROR line this unit wrote to the shared hook log.
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
34
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
35
|
+
MKT_NAME="meta-bridge-copilot-local"
|
|
36
|
+
PLUGIN="entwurf-meta-receive-copilot"
|
|
37
|
+
# QUALIFIED ids throughout: `copilot plugin list` prints `plugin@marketplace` (measured
|
|
38
|
+
# 2026-08-21), and a bare name would both accept a same-named plugin from somebody
|
|
39
|
+
# else's marketplace as ours and flag theirs as our stale unit (cross-review, terra).
|
|
40
|
+
QUALIFIED="$PLUGIN@$MKT_NAME"
|
|
41
|
+
STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
|
|
42
|
+
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled"
|
|
43
|
+
AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
44
|
+
SESSIONS_DIR="$AGENT_DIR/meta-sessions"
|
|
45
|
+
HOOK_LOG="$AGENT_DIR/meta-bridge-hook.log"
|
|
46
|
+
|
|
47
|
+
fail=0
|
|
48
|
+
ok() { echo " ok $*"; }
|
|
49
|
+
bad() { echo " FAIL $*"; fail=1; }
|
|
50
|
+
note() { echo " note $*"; }
|
|
51
|
+
|
|
52
|
+
echo "[copilot-bridge-doctor] toolchain"
|
|
53
|
+
command -v copilot >/dev/null 2>&1 && ok "copilot CLI on PATH" || bad "copilot CLI missing from PATH"
|
|
54
|
+
command -v node >/dev/null 2>&1 && ok "node on PATH" || bad "node missing from PATH"
|
|
55
|
+
|
|
56
|
+
echo "[copilot-bridge-doctor] assembled artifact"
|
|
57
|
+
if [ -d "$ASM/$PLUGIN" ]; then
|
|
58
|
+
ok "assembly present at $ASM"
|
|
59
|
+
else
|
|
60
|
+
bad "assembly missing at $ASM — run ./run.sh install-copilot-bridge"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
64
|
+
HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
|
|
65
|
+
if [ -x "$LAUNCHER" ]; then
|
|
66
|
+
ok "launcher present and executable"
|
|
67
|
+
# The exec form names the launcher as THE executable — a lost +x bit is ENOEXEC at
|
|
68
|
+
# first prompt, not a degraded path.
|
|
69
|
+
if grep -q "__NODE_BIN__\|__HOOK_ENTRY__" "$LAUNCHER"; then
|
|
70
|
+
bad "launcher still carries an installer placeholder (bake did not run)"
|
|
71
|
+
else
|
|
72
|
+
ok "launcher is baked (no placeholders)"
|
|
73
|
+
BAKED_NODE="$(sed -n 's/^NODE_BIN="\(.*\)"$/\1/p' "$LAUNCHER" | head -1)"
|
|
74
|
+
[ -x "$BAKED_NODE" ] && ok "baked node exists: $BAKED_NODE" \
|
|
75
|
+
|| bad "baked node is missing or not executable: ${BAKED_NODE:-(unparsed)} — node moved (NixOS store churn?); reinstall"
|
|
76
|
+
BAKED_ENTRY="$(sed -n 's|^HOOK_ENTRY="\$PLUGIN_ROOT/\(.*\)"$|\1|p' "$LAUNCHER" | head -1)"
|
|
77
|
+
[ -n "$BAKED_ENTRY" ] && [ -f "$ASM/$PLUGIN/$BAKED_ENTRY" ] && ok "hook entry present: $BAKED_ENTRY" \
|
|
78
|
+
|| bad "hook entry missing beside the launcher: ${BAKED_ENTRY:-(unparsed)}"
|
|
79
|
+
fi
|
|
80
|
+
else
|
|
81
|
+
bad "launcher missing or not executable: $LAUNCHER"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
[ -f "$ASM/$PLUGIN/entwurf-capabilities.json" ] \
|
|
85
|
+
&& ok "capability registry travels at the plugin root (every mint reads it)" \
|
|
86
|
+
|| bad "capability registry missing at the plugin root — every mint would throw"
|
|
87
|
+
|
|
88
|
+
if [ -f "$HOOKS" ]; then
|
|
89
|
+
HOOKS_PATH="$HOOKS" ASM_LAUNCHER="$LAUNCHER" python3 - <<'PY'
|
|
90
|
+
import json, os, sys
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
hooks = json.loads(Path(os.environ["HOOKS_PATH"]).read_text(encoding="utf-8"))
|
|
93
|
+
launcher = os.environ["ASM_LAUNCHER"]
|
|
94
|
+
problems = []
|
|
95
|
+
if hooks.get("version") != 1:
|
|
96
|
+
problems.append("hooks.json root `version` must be the literal 1 (Copilot rejects the plugin otherwise)")
|
|
97
|
+
events = hooks.get("hooks") or {}
|
|
98
|
+
if set(events) != {"sessionStart", "userPromptSubmitted"}:
|
|
99
|
+
problems.append(f"hook events drifted: {sorted(events)} — want exactly ['sessionStart', 'userPromptSubmitted']")
|
|
100
|
+
for name, entries in events.items():
|
|
101
|
+
for i, entry in enumerate(entries if isinstance(entries, list) else []):
|
|
102
|
+
exec_value = entry.get("exec")
|
|
103
|
+
if not isinstance(exec_value, str):
|
|
104
|
+
problems.append(f"hooks.{name}[{i}].exec must be a STRING (an array is rejected at plugin load)")
|
|
105
|
+
elif exec_value != launcher:
|
|
106
|
+
problems.append(f"hooks.{name}[{i}].exec does not point at the assembled launcher: {exec_value}")
|
|
107
|
+
if "args" in entry:
|
|
108
|
+
problems.append(f"hooks.{name}[{i}] carries `args`, which Copilot's schema has no key for")
|
|
109
|
+
for p in problems:
|
|
110
|
+
print(f" FAIL {p}")
|
|
111
|
+
sys.exit(1 if problems else 0)
|
|
112
|
+
PY
|
|
113
|
+
[ $? -eq 0 ] && ok "hooks.json is the Copilot native form, baked at the assembled launcher" || fail=1
|
|
114
|
+
else
|
|
115
|
+
bad "hooks.json missing: $HOOKS"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
echo "[copilot-bridge-doctor] copilot wiring"
|
|
119
|
+
PLUGIN_LIST="$(copilot plugin list 2>/dev/null)"
|
|
120
|
+
case "$PLUGIN_LIST" in
|
|
121
|
+
*"$QUALIFIED"*)
|
|
122
|
+
# WHAT THIS DOES NOT PROVE. Copilot exposes no load/execution receipt, so a listing
|
|
123
|
+
# says the plugin is REGISTERED, never that Copilot loaded this unit or will invoke
|
|
124
|
+
# its hook. Only a real first prompt settles that — see the birth-evidence section.
|
|
125
|
+
ok "$QUALIFIED is registered in Copilot (registration, not proof it is loaded)" ;;
|
|
126
|
+
*) bad "$QUALIFIED is NOT installed in Copilot — run ./run.sh install-copilot-bridge" ;;
|
|
127
|
+
esac
|
|
128
|
+
case "$PLUGIN_LIST" in
|
|
129
|
+
*"$STALE_CLAUDE_UNIT"*)
|
|
130
|
+
bad "the Claude unit '$STALE_CLAUDE_UNIT' is still installed in Copilot — it fires on every prompt and exits 1 before node starts (Copilot's schema has no args key). Re-run the installer without --keep-stale-claude-unit." ;;
|
|
131
|
+
*) ok "the Claude unit is not installed in Copilot" ;;
|
|
132
|
+
esac
|
|
133
|
+
|
|
134
|
+
echo "[copilot-bridge-doctor] birth evidence"
|
|
135
|
+
COPILOT_RECORDS=0
|
|
136
|
+
if [ -d "$SESSIONS_DIR" ]; then
|
|
137
|
+
COPILOT_RECORDS="$(grep -l '"backend": "copilot"' "$SESSIONS_DIR"/*.meta.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
138
|
+
fi
|
|
139
|
+
if [ "$COPILOT_RECORDS" -gt 0 ]; then
|
|
140
|
+
ok "$COPILOT_RECORDS copilot meta-record(s) in $SESSIONS_DIR — the citizen exists"
|
|
141
|
+
else
|
|
142
|
+
# NOT a failure. See the header: a Copilot session mints on its first prompt, so an
|
|
143
|
+
# opened-but-unspoken session legitimately has no record yet.
|
|
144
|
+
note "no copilot meta-record yet. A Copilot session is born on its FIRST PROMPT, not when"
|
|
145
|
+
note "the window opens — open Copilot, send one prompt, then re-run this doctor."
|
|
146
|
+
note "NOT-YET is only meaningful BEFORE that first prompt. If a Copilot session has"
|
|
147
|
+
note "already been prompted on this host and this still says zero, the unit is not"
|
|
148
|
+
note "being invoked and that IS the failure — Copilot gives no load receipt to tell"
|
|
149
|
+
note "the two apart from here."
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [ -f "$HOOK_LOG" ]; then
|
|
153
|
+
# RECOVERY RULE. The hook log is append-only, so a repaired install would otherwise
|
|
154
|
+
# stay red forever on errors it has already outgrown (cross-review, terra). What is
|
|
155
|
+
# red is an ERROR with NO successful mint after it: a failure the unit never recovered
|
|
156
|
+
# from. An ERROR followed by a create/attach is history, and is reported as such.
|
|
157
|
+
#
|
|
158
|
+
# grep -c prints 0 AND exits 1 with no match, so a `|| echo 0` fallback would append a
|
|
159
|
+
# SECOND line and every numeric test below would die on "0\n0". Keep it to one line.
|
|
160
|
+
#
|
|
161
|
+
# MINT ERRORS ONLY. Since #82 RAIL 5b this unit also writes a SENDER marker, and a
|
|
162
|
+
# failed marker write is an ERROR that lands AFTER the successful mint line — so the
|
|
163
|
+
# order rule above would read it as "no successful mint after the error" and print a
|
|
164
|
+
# sentence that is simply false (the record IS there). The two failures are separated
|
|
165
|
+
# here rather than downgraded in the payload, because a marker write that keeps
|
|
166
|
+
# breaking must stay loud somewhere; it just is not a birth failure.
|
|
167
|
+
MINT_ERRORS=' ERROR \[copilot\] (?!sender-marker-)'
|
|
168
|
+
LAST_ERROR_LINE="$(grep -nP "$MINT_ERRORS" "$HOOK_LOG" 2>/dev/null | tail -1 | cut -d: -f1)"
|
|
169
|
+
LAST_OK_LINE="$(grep -n ' INFO \[copilot\] \(create\|attach\) ' "$HOOK_LOG" 2>/dev/null | tail -1 | cut -d: -f1)"
|
|
170
|
+
TOTAL_ERRORS="$(grep -cP "$MINT_ERRORS" "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
171
|
+
TOTAL_ERRORS="${TOTAL_ERRORS:-0}"
|
|
172
|
+
if [ -z "$LAST_ERROR_LINE" ]; then
|
|
173
|
+
ok "no copilot ERROR lines in $HOOK_LOG"
|
|
174
|
+
elif [ -n "$LAST_OK_LINE" ] && [ "$LAST_OK_LINE" -gt "$LAST_ERROR_LINE" ]; then
|
|
175
|
+
note "$TOTAL_ERRORS historical copilot ERROR line(s) in $HOOK_LOG, all followed by a successful mint (line $LAST_OK_LINE > $LAST_ERROR_LINE) — recovered, not red"
|
|
176
|
+
else
|
|
177
|
+
bad "the newest copilot line in $HOOK_LOG is an unrecovered ERROR — the hook RAN and did not mint:"
|
|
178
|
+
grep -P "$MINT_ERRORS" "$HOOK_LOG" | tail -3 | sed 's/^/ /'
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# WHO-SENT, judged on its own axis. Both outcomes leave a citizen that EXISTS and can
|
|
182
|
+
# be addressed by others; what is missing is only its ability to send under its own
|
|
183
|
+
# garden id. So a REFUSAL is a note (fail-closed by design — a session opened before
|
|
184
|
+
# the current install reaches the payload without launch provenance and correctly
|
|
185
|
+
# claims no owner), while a failed WRITE is red (we tried and the store would not take
|
|
186
|
+
# it, and nothing downstream will say why).
|
|
187
|
+
MARKER_FAILED="$(grep -c ' ERROR \[copilot\] sender-marker-failed ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
188
|
+
MARKER_REFUSED="$(grep -c ' WARN \[copilot\] sender-marker-refused ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
189
|
+
MARKER_OK="$(grep -c ' INFO \[copilot\] sender marker ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
190
|
+
if [ "${MARKER_FAILED:-0}" -gt 0 ]; then
|
|
191
|
+
bad "${MARKER_FAILED} sender-marker WRITE failure(s) in $HOOK_LOG — those citizens exist but cannot send under their own garden id:"
|
|
192
|
+
grep ' ERROR \[copilot\] sender-marker-failed ' "$HOOK_LOG" | tail -3 | sed 's/^/ /'
|
|
193
|
+
elif [ "${MARKER_REFUSED:-0}" -gt 0 ]; then
|
|
194
|
+
note "${MARKER_REFUSED} sender-marker refusal(s) and ${MARKER_OK:-0} armed — a refusal is fail-closed, not a fault. A session that predates this install reaches the hook without launch provenance; RESTART it to arm who-sent"
|
|
195
|
+
elif [ "${MARKER_OK:-0}" -gt 0 ]; then
|
|
196
|
+
ok "${MARKER_OK} sender marker(s) armed — these citizens send under their own garden id"
|
|
197
|
+
else
|
|
198
|
+
note "no sender-marker lines yet in $HOOK_LOG (nothing has fired since who-sent landed)"
|
|
199
|
+
fi
|
|
200
|
+
else
|
|
201
|
+
note "no hook log yet at $HOOK_LOG (nothing has fired on this host)"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
echo
|
|
205
|
+
if [ "$fail" -ne 0 ]; then
|
|
206
|
+
echo "[copilot-bridge-doctor] FAIL"
|
|
207
|
+
exit 1
|
|
208
|
+
fi
|
|
209
|
+
echo "[copilot-bridge-doctor] PASS"
|