@junghanacs/entwurf 0.14.2 → 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.
Files changed (72) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +79 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +55 -4
  5. package/VERIFY.md +2 -2
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +3 -3
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  17. package/mcp/entwurf-bridge/src/index.ts +10 -6
  18. package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
  19. package/package.json +22 -11
  20. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  21. package/pi/entwurf-capabilities.json +1 -0
  22. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  23. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  24. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  25. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  26. package/pi-extensions/entwurf-control.ts +17 -8
  27. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  28. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  29. package/pi-extensions/lib/meta-session.ts +102 -29
  30. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  31. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  32. package/run.sh +334 -16
  33. package/scripts/check-copilot-birth-hook.ts +497 -0
  34. package/scripts/check-copilot-launch.ts +395 -0
  35. package/scripts/check-copilot-receive-arm.ts +734 -0
  36. package/scripts/check-copilot-statusline.ts +122 -0
  37. package/scripts/check-entwurf-capabilities.ts +25 -6
  38. package/scripts/check-fresh-cut-gate.sh +92 -23
  39. package/scripts/check-gate-qualification.ts +7 -1
  40. package/scripts/check-install-container.sh +2 -2
  41. package/scripts/check-install-surface.ts +1 -1
  42. package/scripts/check-meta-capability-source.ts +89 -5
  43. package/scripts/check-meta-doctor-oracle.sh +26 -0
  44. package/scripts/check-meta-manifest-schema.py +38 -0
  45. package/scripts/check-meta-receiver-marker.ts +25 -2
  46. package/scripts/check-meta-session.ts +32 -0
  47. package/scripts/copilot-bridge-doctor.sh +209 -0
  48. package/scripts/copilot-bridge-install.sh +224 -0
  49. package/scripts/copilot-launch.sh +236 -0
  50. package/scripts/copilot-mcp-bridge.sh +177 -0
  51. package/scripts/copilot-mcp-config.py +221 -0
  52. package/scripts/copilot-receive-bridge.sh +483 -0
  53. package/scripts/copilot-statusline-bridge.sh +149 -0
  54. package/scripts/copilot-statusline-config.py +201 -0
  55. package/scripts/copilot-statusline.sh +68 -0
  56. package/scripts/dev-bin.sh +4 -1
  57. package/scripts/meta-bridge-hook-log.sh +17 -5
  58. package/scripts/mutants/capability-cache.json +64 -0
  59. package/scripts/mutants/copilot-birth.json +155 -0
  60. package/scripts/mutants/copilot-launch.json +187 -0
  61. package/scripts/mutants/copilot-receive.json +263 -0
  62. package/scripts/mutants/fresh-cut.json +17 -0
  63. package/scripts/mutants/mux-fresh-call.json +216 -2
  64. package/scripts/mutants/pack-install.json +17 -0
  65. package/scripts/raw-async-delivery/README.md +170 -125
  66. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  67. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  68. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  69. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  70. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  71. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  72. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * check-copilot-birth-hook — #82 gate: the Copilot BIRTH path, proven without Copilot.
3
+ *
4
+ * WHAT IT BINDS. The chain from the shipped unit to a real garden citizen:
5
+ *
6
+ * copilot-bridge-install.sh --assemble-only (the REAL assembler, into a temp dir)
7
+ * -> hooks.json baked: version 1, camelCase events, `exec` a STRING, no `args`
8
+ * -> the baked launcher, invoked with NO ARGV, envelope on stdin
9
+ * -> a v3 record with backend "copilot" in a temp store
10
+ * -> that record listed as a peer with liveness `unsupported`
11
+ *
12
+ * WHY IT DRIVES THE INSTALLER INSTEAD OF BUILDING THE ASSEMBLY ITSELF. A gate that
13
+ * re-implemented the bake would be asserting against its own copy of the logic; the
14
+ * shipped installer could then drift underneath it and stay green. `--assemble-only`
15
+ * exists for this caller and stops before the Copilot CLI is touched, so the gate runs
16
+ * on a host (and in CI) with no Copilot installed.
17
+ *
18
+ * WHY NO-ARGV IS THE CENTRAL CELL. Copilot's hook schema has no `args` key at all —
19
+ * `exec` is a single string, and an array is rejected at plugin load. So a Copilot hook
20
+ * ALWAYS starts with argc=0. The Claude launcher treats argc=0 as a hard error (it is
21
+ * the only visible symptom of an older Claude dropping `args`), which is exactly why
22
+ * Copilot held 0 of 409 meta-records until this unit existed. This gate fires the
23
+ * launcher the way Copilot does — no argv — and requires a record.
24
+ *
25
+ * WHAT IT DOES NOT PROVE, and must not be read as proving. It proves the MECHANISM,
26
+ * not the ADMISSION. §6 acceptance is a record minted by a real Copilot session, and a
27
+ * Copilot session mints on its first prompt — a billed model turn. A synthetic envelope
28
+ * through the real launcher is gate evidence; it is not a live citizen (cross-review,
29
+ * terra, 2026-08-20).
30
+ *
31
+ * Hermetic: temp dirs only, no network, no Copilot, no model turn.
32
+ */
33
+
34
+ import assert from "node:assert/strict";
35
+ import { execFileSync, spawnSync } from "node:child_process";
36
+ import {
37
+ chmodSync,
38
+ copyFileSync,
39
+ existsSync,
40
+ mkdirSync,
41
+ mkdtempSync,
42
+ readdirSync,
43
+ readFileSync,
44
+ rmSync,
45
+ writeFileSync,
46
+ } from "node:fs";
47
+ import { tmpdir } from "node:os";
48
+ import * as path from "node:path";
49
+ import { fileURLToPath } from "node:url";
50
+ import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
51
+ import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
52
+ import { META_SENDER_BACKENDS, resolveTrustedMetaSenderIdentity } from "../pi-extensions/lib/meta-sender-identity.ts";
53
+ import { listAllMetaIdentitiesDir, processStartKey } from "../pi-extensions/lib/meta-session.ts";
54
+
55
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
56
+ const PLUGIN = "entwurf-meta-receive-copilot";
57
+
58
+ let passed = 0;
59
+ function ok(label: string, cond: boolean): void {
60
+ assert.ok(cond, label);
61
+ console.log(` ok ${label}`);
62
+ passed++;
63
+ }
64
+
65
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-birth."));
66
+ const asm = path.join(root, "asm");
67
+
68
+ // ── 1. the real assembler ────────────────────────────────────────────────────
69
+ // Through `run.sh`, not straight at the script: the verb dispatch is part of the
70
+ // install surface, and calling the script directly skipped it. check-pack-install
71
+ // caught exactly that — run.sh's `$@` still carried the verb name, so a strict argument
72
+ // parser refused its own verb (2026-08-21). The gate now covers the path an operator
73
+ // actually types.
74
+ execFileSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
75
+ env: { ...process.env, ENTWURF_COPILOT_ASM: asm },
76
+ stdio: "pipe",
77
+ });
78
+ const unit = path.join(asm, PLUGIN);
79
+ const launcher = path.join(unit, "scripts", "copilot-hook-launch.sh");
80
+ ok(
81
+ "assembler produced the unit, the launcher and the capability registry",
82
+ [
83
+ path.join(asm, ".claude-plugin", "marketplace.json"),
84
+ path.join(unit, ".claude-plugin", "plugin.json"),
85
+ path.join(unit, "hooks", "hooks.json"),
86
+ path.join(unit, "entwurf-capabilities.json"),
87
+ launcher,
88
+ ].every((p) => existsSync(p)),
89
+ );
90
+
91
+ // ── 2. the manifest is the COPILOT form, not the Claude one ──────────────────
92
+ const hooks = JSON.parse(readFileSync(path.join(unit, "hooks", "hooks.json"), "utf8")) as {
93
+ version?: unknown;
94
+ hooks?: Record<string, Array<Record<string, unknown>>>;
95
+ };
96
+ ok("hooks.json declares the literal version 1 (Copilot rejects the plugin without it)", hooks.version === 1);
97
+ ok(
98
+ "hook events are exactly the two camelCase events that fire on a first prompt",
99
+ JSON.stringify(Object.keys(hooks.hooks ?? {}).sort()) === JSON.stringify(["sessionStart", "userPromptSubmitted"]),
100
+ );
101
+ const leaves = Object.values(hooks.hooks ?? {}).flat();
102
+ ok(
103
+ "[QK:COPILOT-BIRTH-EXEC-IS-STRING] every hook entry's `exec` is a STRING (an array is rejected at plugin load)",
104
+ leaves.every((l) => typeof l.exec === "string"),
105
+ );
106
+ ok(
107
+ "every hook entry points at the assembled launcher",
108
+ leaves.every((l) => l.exec === launcher),
109
+ );
110
+ // The Claude unit's whole identity contract rides `args`. Copilot has no such key, so
111
+ // a stray one here would be a manifest written against the wrong vendor's schema.
112
+ ok(
113
+ "no hook entry carries `args` — Copilot's schema has no such key",
114
+ leaves.every((l) => !("args" in l)),
115
+ );
116
+ ok(
117
+ "no hook entry carries an empty `matcher` — Copilot rejects one",
118
+ leaves.every((l) => l.matcher !== ""),
119
+ );
120
+
121
+ // ── 3. the launcher is baked ─────────────────────────────────────────────────
122
+ const launcherText = readFileSync(launcher, "utf8");
123
+ // The guard's own comparison literals are split (`"__NODE_""BIN__"`) precisely so this
124
+ // assertion can be exact: after a bake, no whole placeholder token survives anywhere.
125
+ ok(
126
+ "launcher carries no unsubstituted placeholder",
127
+ !launcherText.includes("__NODE_BIN__") && !launcherText.includes("__HOOK_ENTRY__"),
128
+ );
129
+ const bakedNode = /^NODE_BIN="(.*)"$/m.exec(launcherText)?.[1] ?? "";
130
+ ok("launcher's baked node is an existing executable", bakedNode.length > 0 && existsSync(bakedNode));
131
+
132
+ // ── 4. FIRE, the way Copilot fires: no argv, envelope on stdin ───────────────
133
+ interface Fired {
134
+ status: number | null;
135
+ stdout: string;
136
+ stderr: string;
137
+ }
138
+ /** The child's env: this gate's own, with the store relocated and every DIRECT store
139
+ * override removed. `PI_CODING_AGENT_DIR` only isolates what derives from it, so an
140
+ * operator shell that pins `ENTWURF_META_SENDERS_DIR` (or the sessions equivalent) would
141
+ * send the very artifacts asserted on below into the real store and read a stale one back. */
142
+ function isolatedEnv(storeDir: string): NodeJS.ProcessEnv {
143
+ const env: NodeJS.ProcessEnv = { ...process.env, PI_CODING_AGENT_DIR: storeDir };
144
+ delete env.ENTWURF_META_SENDERS_DIR;
145
+ delete env.ENTWURF_META_SESSIONS_DIR;
146
+ return env;
147
+ }
148
+ function fire(envelope: unknown, storeDir: string): Fired {
149
+ const res = spawnSync(launcher, [], {
150
+ input: typeof envelope === "string" ? envelope : JSON.stringify(envelope),
151
+ env: isolatedEnv(storeDir),
152
+ encoding: "utf8",
153
+ });
154
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
155
+ }
156
+ function records(storeDir: string): ReturnType<typeof listAllMetaIdentitiesDir>["identities"] {
157
+ const dir = path.join(storeDir, "meta-sessions");
158
+ if (!existsSync(dir)) return [];
159
+ return listAllMetaIdentitiesDir(dir).identities;
160
+ }
161
+ function hookLog(storeDir: string): string {
162
+ const file = path.join(storeDir, "meta-bridge-hook.log");
163
+ return existsSync(file) ? readFileSync(file, "utf8") : "";
164
+ }
165
+
166
+ const store = path.join(root, "store");
167
+ mkdirSync(store, { recursive: true });
168
+ const NATIVE_ID = "cop-birth-0001";
169
+ const CWD = "/home/junghan/repos/gh/entwurf";
170
+
171
+ const first = fire({ sessionId: NATIVE_ID, cwd: CWD, source: "new", timestamp: 1755690000000 }, store);
172
+ ok("[QK:COPILOT-BIRTH-NO-ARGV-LAUNCH] a no-argv fire with the NATIVE envelope exits 0", first.status === 0);
173
+ ok("the hook emits the neutral response and nothing else", first.stdout.trim() === "{}");
174
+ let live = records(store);
175
+ ok("exactly one record was minted", live.length === 1);
176
+ ok("[QK:COPILOT-BIRTH-MINTS-COPILOT] the record's backend is copilot", live[0]?.backend === "copilot");
177
+ ok("the record joins on the native sessionId", live[0]?.nativeSessionId === NATIVE_ID);
178
+ ok("the record carries the envelope's cwd", live[0]?.cwd === CWD);
179
+ // Omitted, never guessed: Copilot's envelope carries neither, and both are nullable.
180
+ ok("model and transcriptPath are null, not invented", live[0]?.model === null && live[0]?.transcriptPath === null);
181
+ const bornGardenId = live[0]?.gardenId ?? "";
182
+
183
+ // ── 5. the second event of the same first prompt ATTACHES ───────────────────
184
+ // Measured firing order is userPromptSubmitted -> sessionStart, so the citizen is
185
+ // minted by one and re-seen by the other. Two records for one session would be two
186
+ // citizens for one Copilot window.
187
+ const second = fire({ hook_event_name: "SessionStart", session_id: NATIVE_ID, cwd: CWD, source: "new" }, store);
188
+ ok("a second fire with the CLAUDE-COMPAT envelope also exits 0", second.status === 0);
189
+ live = records(store);
190
+ ok("the store still holds exactly one record", live.length === 1);
191
+ ok("the second fire attached to the SAME garden id", live[0]?.gardenId === bornGardenId);
192
+
193
+ // ── 6. WHO-SENT is armed; RECEIVER state is not ─────────────────────────────
194
+ // The two halves used to be one assertion ("no marker of any kind"), and that
195
+ // conflation is the defect #82 RAIL 5b closed: the doorbell's absence is a fact about
196
+ // the RECEIVER rail only. A sender marker needs a shared parent, not a doorbell, so
197
+ // this backend can say who sent a message while still being unable to receive one.
198
+ //
199
+ // The join this gate performs is the SAME one production performs, which is why the
200
+ // oracle is independent of the writer: `fire()` spawns the launcher as this process's
201
+ // child and the launcher `exec`s the payload, so the payload's parent IS this gate.
202
+ // The gate therefore knows the answer (`process.pid`) before reading the file.
203
+ const sendersDir = path.join(store, "meta-senders");
204
+ const markerFile = path.join(sendersDir, "copilot", `${process.pid}.json`);
205
+ ok(
206
+ "[QK:COPILOT-BIRTH-WRITES-SENDER-MARKER] a sender marker was written under THIS process's pid — the parent the bridge child would look itself up by",
207
+ existsSync(markerFile),
208
+ );
209
+ const marker = JSON.parse(readFileSync(markerFile, "utf8")) as {
210
+ backend?: string;
211
+ gardenId?: string;
212
+ nativeSessionId?: string;
213
+ cwd?: string;
214
+ ownerPid?: number;
215
+ ownerStartKey?: string;
216
+ };
217
+ ok("the marker names the citizen that was just minted, not a second one", marker.gardenId === bornGardenId);
218
+ ok(
219
+ "the marker agrees with the record on backend and native id — a drift on either makes it a stale hint",
220
+ marker.backend === "copilot" && marker.nativeSessionId === NATIVE_ID,
221
+ );
222
+ ok("the marker's ownerPid IS the gate's own pid", marker.ownerPid === process.pid);
223
+ // The pid-reuse guard: a marker keyed to a pid alone would be inherited by whatever
224
+ // process the OS hands that number to next.
225
+ ok(
226
+ "the marker carries the owner's start-key, so a reused pid cannot inherit this citizen",
227
+ typeof marker.ownerStartKey === "string" && marker.ownerStartKey === processStartKey(process.pid),
228
+ );
229
+
230
+ // The READ half. A marker nobody looks for is invisible, and that asymmetry — writer
231
+ // open, reader closed — is exactly how #46 made an agy citizen send as an anonymous
232
+ // external host. So the resolver is run for real, not inspected.
233
+ ok(
234
+ "[QK:COPILOT-SENDER-READER-OPEN] copilot is one of the backends the resolver scans — a marker nobody looks for is invisible",
235
+ META_SENDER_BACKENDS.includes("copilot"),
236
+ );
237
+ // The resolver reads the RECORD store through this process's own env (the marker is only
238
+ // a hint; the record is the authority), while the marker root is a parameter. So the
239
+ // record half is pointed at the temp store for the duration of the call and put back —
240
+ // same isolation the agy sender gate uses, and `isolatedEnv` keeps it out of every child.
241
+ function withSessionsDir<T>(dir: string, fn: () => T): T {
242
+ const prev = process.env.ENTWURF_META_SESSIONS_DIR;
243
+ process.env.ENTWURF_META_SESSIONS_DIR = dir;
244
+ try {
245
+ return fn();
246
+ } finally {
247
+ if (prev === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
248
+ else process.env.ENTWURF_META_SESSIONS_DIR = prev;
249
+ }
250
+ }
251
+ const trusted = withSessionsDir(path.join(store, "meta-sessions"), () =>
252
+ resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir }),
253
+ );
254
+ ok(
255
+ "the bridge resolver joins that marker to exactly ONE identity — the citizen born above",
256
+ trusted?.identity.gardenId === bornGardenId && trusted?.identity.backend === "copilot",
257
+ );
258
+
259
+ // The record store is the authority; the marker is only a hint it must agree with.
260
+ // Run in its OWN store so the live one above keeps its record for §7.
261
+ {
262
+ const orphanStore = path.join(root, "orphan-marker");
263
+ mkdirSync(orphanStore, { recursive: true });
264
+ fire({ sessionId: "cop-orphan-0001", cwd: CWD, source: "new" }, orphanStore);
265
+ const orphanSenders = path.join(orphanStore, "meta-senders");
266
+ ok(
267
+ "precondition: that store has its own marker too",
268
+ existsSync(path.join(orphanSenders, "copilot", `${process.pid}.json`)),
269
+ );
270
+ for (const f of readdirSync(path.join(orphanStore, "meta-sessions"))) {
271
+ rmSync(path.join(orphanStore, "meta-sessions", f));
272
+ }
273
+ ok(
274
+ "a marker whose record is gone resolves to NOBODY — a hint is not an identity",
275
+ withSessionsDir(path.join(orphanStore, "meta-sessions"), () =>
276
+ resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir: orphanSenders }),
277
+ ) === null,
278
+ );
279
+ }
280
+
281
+ // FAIL-CLOSED on provenance. Reaching the payload WITHOUT the launcher means we do not
282
+ // know what our parent is — an already-open session holding an older cached command is
283
+ // the real case. Birth still happens (a record needs no parent); only who-sent is
284
+ // withheld, and the log says which of the two refusals it was.
285
+ {
286
+ const noTokenStore = path.join(root, "no-provenance");
287
+ mkdirSync(noTokenStore, { recursive: true });
288
+ const entryRel = /^HOOK_ENTRY="\$PLUGIN_ROOT\/(.*)"$/m.exec(launcherText)?.[1] ?? "";
289
+ ok("the launcher's baked hook entry is readable from its text", entryRel.length > 0);
290
+ const bare = isolatedEnv(noTokenStore);
291
+ delete bare.ENTWURF_META_HOOK_LAUNCH;
292
+ const res = spawnSync(bakedNode, [path.join(path.dirname(launcher), "..", entryRel)], {
293
+ input: JSON.stringify({ sessionId: "cop-noprov-0001", cwd: CWD, source: "new" }),
294
+ env: bare,
295
+ encoding: "utf8",
296
+ });
297
+ ok("an unstamped launch still exits 0 — best-effort, never breaks the turn", res.status === 0);
298
+ ok("an unstamped launch still MINTS the citizen", records(noTokenStore).length === 1);
299
+ ok(
300
+ "an unstamped launch writes NO sender marker — an unknown parent is not an owner",
301
+ !readdirSync(noTokenStore).includes("meta-senders"),
302
+ );
303
+ ok(
304
+ "and it says so in the log the doctor reads",
305
+ hookLog(noTokenStore).includes("sender-marker-refused") && hookLog(noTokenStore).includes("provenance missing"),
306
+ );
307
+ }
308
+
309
+ // BIRTH DOES NOT ARM, and that is still true now that Copilot HAS a doorbell (#82 RAIL
310
+ // 5). What changed is the reason, not the assertion. It used to hold because no wake
311
+ // surface existed anywhere in the bundle; it holds today because the surface that exists
312
+ // belongs to a DIFFERENT PROCESS — the forked extension, which owns the watch and can
313
+ // therefore honestly claim to hold one. A marker written from this hook would name the
314
+ // Copilot host pid as the owner of a watch that pid does not hold, and the citizen would
315
+ // read as deliverable for as long as the TUI stayed open, wired to nothing.
316
+ const storeEntries = readdirSync(store);
317
+ ok(
318
+ "[QK:COPILOT-BIRTH-DOES-NOT-ARM-RECEIVER] the birth hook creates no mailbox and no receiver marker — arming belongs to the process that holds the watch",
319
+ !storeEntries.includes("meta-mailbox") && !storeEntries.includes("meta-receivers"),
320
+ );
321
+ // Identity is not replyability. This citizen can say who it is the moment it is born;
322
+ // whether a reply LANDS is answered one rail over, by the receiver marker the extension
323
+ // writes when it joins. The reply rail itself is picked from nativePushSupported at the
324
+ // bridge, and copilot lands in self-fetch either way.
325
+ ok(
326
+ "copilot is NOT native-push — a sender marker buys who-sent, and replyability comes from the receiver marker instead",
327
+ nativePushSupported("copilot") === false,
328
+ );
329
+
330
+ // ── 7. the citizen is a PEER, and an honest one ─────────────────────────────
331
+ const facts = resolveFactList(live, []);
332
+ const peer = facts.peers.find((p) => p.gardenId === bornGardenId);
333
+ ok("the minted citizen appears in the peer fact list", peer !== undefined);
334
+ ok("its liveness is `unsupported` — no control-socket probe exists for this backend", peer?.liveness === "unsupported");
335
+
336
+ // ── 8. negatives: every refusal is a REFUSAL, not a guessed record ──────────
337
+ function refuses(label: string, envelope: unknown, expectInLog: string): void {
338
+ const negStore = path.join(root, `neg-${label.replace(/[^a-z0-9]+/gi, "-")}`);
339
+ mkdirSync(negStore, { recursive: true });
340
+ const res = fire(envelope, negStore);
341
+ ok(`${label}: exits 0 (best-effort, never breaks the operator's turn)`, res.status === 0);
342
+ ok(`${label}: writes NO record`, records(negStore).length === 0);
343
+ ok(`${label}: logs an ERROR the doctor can read (${expectInLog})`, hookLog(negStore).includes(expectInLog));
344
+ }
345
+ // The one a naive `sessionId ?? session_id` would swallow: two identities for one
346
+ // session means the envelope is not trustworthy, so neither id may be minted.
347
+ refuses(
348
+ "[QK:COPILOT-BIRTH-ID-DISAGREEMENT] disagreeing sessionId/session_id",
349
+ { sessionId: "a-1", session_id: "b-2", cwd: CWD },
350
+ "disagree",
351
+ );
352
+ refuses("missing cwd", { sessionId: NATIVE_ID }, "cwd missing");
353
+ refuses("no session id under either key", { cwd: CWD, source: "new" }, "no sessionId/session_id");
354
+ refuses("malformed envelope", "{not json", "envelope parse failed");
355
+ // Agreement is NOT a refusal — the compat translator emitting both keys is normal.
356
+ const agreeStore = path.join(root, "agree");
357
+ mkdirSync(agreeStore, { recursive: true });
358
+ const agreeing = fire({ sessionId: NATIVE_ID, session_id: NATIVE_ID, cwd: CWD }, agreeStore);
359
+ ok(
360
+ "both keys AGREEING is accepted (that is the compat translator, not a defect)",
361
+ agreeing.status === 0 && records(agreeStore).length === 1,
362
+ );
363
+
364
+ // ── 9. an UNBAKED launcher refuses loudly instead of exec'ing a placeholder ──
365
+ const rawUnit = path.join(root, "raw");
366
+ mkdirSync(path.join(rawUnit, "scripts"), { recursive: true });
367
+ const rawLauncher = path.join(rawUnit, "scripts", "copilot-hook-launch.sh");
368
+ copyFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"), rawLauncher);
369
+ chmodSync(rawLauncher, 0o755);
370
+ const rawStore = path.join(root, "raw-store");
371
+ mkdirSync(rawStore, { recursive: true });
372
+ const raw = spawnSync(rawLauncher, [], {
373
+ input: JSON.stringify({ sessionId: NATIVE_ID, cwd: CWD }),
374
+ env: { ...process.env, PI_CODING_AGENT_DIR: rawStore },
375
+ encoding: "utf8",
376
+ });
377
+ ok("the committed (unbaked) launcher exits non-zero", raw.status !== 0);
378
+ ok("it names the install verb rather than failing silently", (raw.stderr ?? "").includes("install-copilot-bridge"));
379
+ ok("it wrote no record", records(rawStore).length === 0);
380
+
381
+ // ── 10. the shipped skeleton keeps its placeholders ─────────────────────────
382
+ // If the committed unit were already baked to some host's node path, every other
383
+ // host's install would ship a launcher pointing at a binary it does not have.
384
+ const shippedHooks = readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "hooks", "hooks.json"), "utf8");
385
+ ok("the committed hooks.json still carries __COPILOT_LAUNCHER__", shippedHooks.includes("__COPILOT_LAUNCHER__"));
386
+ const shippedLauncher = readFileSync(
387
+ path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"),
388
+ "utf8",
389
+ );
390
+ ok(
391
+ "the committed launcher still carries both placeholders",
392
+ shippedLauncher.includes('NODE_BIN="__NODE_BIN__"') && shippedLauncher.includes("__HOOK_ENTRY__"),
393
+ );
394
+
395
+ // ── 11. the install path, driven against a FAKE copilot ─────────────────────
396
+ // The one part of this lane that cannot be exercised for real without touching the
397
+ // operator's Copilot — and the place cross-review named the strongest unguarded
398
+ // defect: an unqualified stale-unit removal that treats a CLI failure as an absence,
399
+ // or that reaches a same-named plugin from somebody else's marketplace.
400
+ const OURS = "entwurf-meta-receive-copilot@meta-bridge-copilot-local";
401
+ const STALE = "entwurf-meta-receive@meta-bridge-local";
402
+ const FOREIGN = "entwurf-meta-receive@someone-elses-marketplace";
403
+
404
+ interface FakeRun {
405
+ status: number | null;
406
+ stdout: string;
407
+ stderr: string;
408
+ /** every `copilot …` argv the installer issued, in order */
409
+ calls: string[];
410
+ /** the plugin ids the fake still holds when the installer is done */
411
+ installed: string[];
412
+ }
413
+ function runInstall(opts: {
414
+ installed: string[];
415
+ uninstallFails?: boolean;
416
+ listFails?: boolean;
417
+ label: string;
418
+ }): FakeRun {
419
+ const home = path.join(root, `install-${opts.label}`);
420
+ const bin = path.join(home, "bin");
421
+ mkdirSync(bin, { recursive: true });
422
+ const state = path.join(home, "installed.txt");
423
+ const log = path.join(home, "calls.log");
424
+ writeFileSync(state, opts.installed.join("\n") + (opts.installed.length ? "\n" : ""));
425
+ writeFileSync(log, "");
426
+ // A fake that ANSWERS like the measured CLI: `plugin list` prints qualified ids,
427
+ // `plugin uninstall <id>` removes exactly that id.
428
+ writeFileSync(
429
+ path.join(bin, "copilot"),
430
+ [
431
+ "#!/usr/bin/env bash",
432
+ `STATE=${JSON.stringify(state)}`,
433
+ `LOG=${JSON.stringify(log)}`,
434
+ 'echo "$*" >> "$LOG"',
435
+ 'case "$1 $2" in',
436
+ opts.listFails
437
+ ? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
438
+ : ' "plugin list") echo "Installed plugins:"; sed "s/^/ • /" "$STATE"; exit 0 ;;',
439
+ ' "plugin uninstall")',
440
+ opts.uninstallFails
441
+ ? ' echo "boom" >&2; exit 1 ;;'
442
+ : ' grep -Fvx "$3" "$STATE" > "$STATE.tmp" || true; mv "$STATE.tmp" "$STATE"; exit 0 ;;',
443
+ ' "plugin install") echo "$3" >> "$STATE"; exit 0 ;;',
444
+ ' "plugin marketplace") exit 0 ;;',
445
+ "esac",
446
+ "exit 0",
447
+ ].join("\n"),
448
+ );
449
+ chmodSync(path.join(bin, "copilot"), 0o755);
450
+ const res = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge"], {
451
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, ENTWURF_COPILOT_ASM: path.join(home, "asm") },
452
+ encoding: "utf8",
453
+ });
454
+ return {
455
+ status: res.status,
456
+ stdout: res.stdout ?? "",
457
+ stderr: res.stderr ?? "",
458
+ calls: readFileSync(log, "utf8").split("\n").filter(Boolean),
459
+ installed: readFileSync(state, "utf8").split("\n").filter(Boolean),
460
+ };
461
+ }
462
+
463
+ const withStale = runInstall({ installed: [STALE], label: "stale" });
464
+ ok(
465
+ "[QK:COPILOT-INSTALL-QUALIFIED-STALE] install removes the stale Claude unit by its QUALIFIED id",
466
+ withStale.calls.includes(`plugin uninstall ${STALE}`),
467
+ );
468
+ ok("install then registers our unit", withStale.installed.includes(OURS) && withStale.status === 0);
469
+ ok("the stale unit is gone afterwards", !withStale.installed.includes(STALE));
470
+
471
+ const withForeign = runInstall({ installed: [FOREIGN], label: "foreign" });
472
+ ok(
473
+ "[QK:COPILOT-INSTALL-FOREIGN-UNTOUCHED] a same-named plugin from ANOTHER marketplace is left alone",
474
+ withForeign.installed.includes(FOREIGN) &&
475
+ !withForeign.calls.some((c) => c.startsWith(`plugin uninstall ${FOREIGN}`)),
476
+ );
477
+
478
+ const uninstallBroken = runInstall({ installed: [STALE], uninstallFails: true, label: "cli-error" });
479
+ ok(
480
+ "[QK:COPILOT-INSTALL-UNINSTALL-FAILURE-IS-FATAL] a FAILING uninstall is not read as an absence — the install refuses",
481
+ uninstallBroken.status !== 0 && !uninstallBroken.installed.includes(OURS),
482
+ );
483
+
484
+ const listBroken = runInstall({ installed: [STALE], listFails: true, label: "list-error" });
485
+ ok(
486
+ "[QK:COPILOT-INSTALL-LIST-FAILURE-IS-FATAL] a FAILING plugin list is not read as an empty host — the install refuses",
487
+ listBroken.status !== 0 && !listBroken.installed.includes(OURS),
488
+ );
489
+
490
+ const clean = runInstall({ installed: [], label: "clean" });
491
+ ok(
492
+ "a host with no stale unit installs cleanly and says so",
493
+ clean.status === 0 && clean.stdout.includes("nothing to remove"),
494
+ );
495
+
496
+ writeFileSync(path.join(root, "gate.ok"), "");
497
+ console.log(`[check-copilot-birth-hook] ${passed} assertions ok`);