@junghanacs/entwurf 0.14.2 → 0.15.1

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 (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +149 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +120 -20
  5. package/VERIFY.md +10 -7
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +2 -2
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +118 -18
  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 +1079 -186
  33. package/scripts/agy-bridge.sh +4 -2
  34. package/scripts/check-copilot-birth-hook.ts +1174 -0
  35. package/scripts/check-copilot-launch.ts +395 -0
  36. package/scripts/check-copilot-receive-arm.ts +734 -0
  37. package/scripts/check-copilot-statusline.ts +122 -0
  38. package/scripts/check-entwurf-capabilities.ts +25 -6
  39. package/scripts/check-fresh-cut-gate.sh +104 -27
  40. package/scripts/check-gate-qualification.ts +10 -1
  41. package/scripts/check-install-container.sh +2 -2
  42. package/scripts/check-install-surface.ts +1 -1
  43. package/scripts/check-meta-capability-source.ts +89 -5
  44. package/scripts/check-meta-doctor-oracle.sh +26 -0
  45. package/scripts/check-meta-manifest-schema.py +38 -0
  46. package/scripts/check-meta-receiver-marker.ts +25 -2
  47. package/scripts/check-meta-session.ts +32 -0
  48. package/scripts/check-setup-qualification.sh +126 -0
  49. package/scripts/copilot-bridge-doctor.sh +242 -0
  50. package/scripts/copilot-bridge-install.sh +384 -0
  51. package/scripts/copilot-bridge-oracle.sh +223 -0
  52. package/scripts/copilot-bridge-uninstall.sh +137 -0
  53. package/scripts/copilot-launch.sh +236 -0
  54. package/scripts/copilot-mcp-bridge.sh +177 -0
  55. package/scripts/copilot-mcp-config.py +221 -0
  56. package/scripts/copilot-receive-bridge.sh +483 -0
  57. package/scripts/copilot-statusline-bridge.sh +149 -0
  58. package/scripts/copilot-statusline-config.py +201 -0
  59. package/scripts/copilot-statusline.sh +68 -0
  60. package/scripts/dev-bin.sh +53 -9
  61. package/scripts/fake-copilot-vendor.sh +67 -0
  62. package/scripts/meta-bridge-hook-log.sh +17 -5
  63. package/scripts/mutants/capability-cache.json +64 -0
  64. package/scripts/mutants/copilot-birth.json +244 -0
  65. package/scripts/mutants/copilot-launch.json +187 -0
  66. package/scripts/mutants/copilot-receive.json +263 -0
  67. package/scripts/mutants/fresh-cut.json +17 -0
  68. package/scripts/mutants/mux-fresh-call.json +216 -2
  69. package/scripts/mutants/pack-install.json +17 -0
  70. package/scripts/mutants/pi-package-ownership.json +92 -0
  71. package/scripts/mutants/setup-verdict.json +148 -0
  72. package/scripts/mutants/source-install.json +32 -0
  73. package/scripts/pi_settings_io.py +22 -0
  74. package/scripts/raw-async-delivery/README.md +170 -125
  75. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  76. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  77. package/scripts/register-pi-package.py +525 -7
  78. package/scripts/register-pi-provider.py +162 -12
  79. package/scripts/smoke-agy-install-state.sh +86 -30
  80. package/scripts/smoke-agy-statusline-state.sh +9 -6
  81. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  82. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  83. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  84. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  85. package/scripts/smoke-setup-verdict.sh +291 -0
  86. package/scripts/smoke-user-scope-citizen.sh +388 -1
  87. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,1174 @@
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
+ symlinkSync,
46
+ writeFileSync,
47
+ } from "node:fs";
48
+ import { tmpdir } from "node:os";
49
+ import * as path from "node:path";
50
+ import { fileURLToPath } from "node:url";
51
+ import { resolveFactList } from "../pi-extensions/lib/entwurf-facts.ts";
52
+ import { nativePushSupported } from "../pi-extensions/lib/entwurf-v2-contract.ts";
53
+ import { META_SENDER_BACKENDS, resolveTrustedMetaSenderIdentity } from "../pi-extensions/lib/meta-sender-identity.ts";
54
+ import { listAllMetaIdentitiesDir, processStartKey } from "../pi-extensions/lib/meta-session.ts";
55
+
56
+ const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
57
+ const PLUGIN = "entwurf-meta-receive-copilot";
58
+
59
+ let passed = 0;
60
+ function ok(label: string, cond: boolean): void {
61
+ assert.ok(cond, label);
62
+ console.log(` ok ${label}`);
63
+ passed++;
64
+ }
65
+
66
+ const root = mkdtempSync(path.join(tmpdir(), "entwurf-copilot-birth."));
67
+ const asm = path.join(root, "asm");
68
+
69
+ // ── 1. the real assembler ────────────────────────────────────────────────────
70
+ // Through `run.sh`, not straight at the script: the verb dispatch is part of the
71
+ // install surface, and calling the script directly skipped it. check-pack-install
72
+ // caught exactly that — run.sh's `$@` still carried the verb name, so a strict argument
73
+ // parser refused its own verb (2026-08-21). The gate now covers the path an operator
74
+ // actually types.
75
+ execFileSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
76
+ env: { ...process.env, ENTWURF_COPILOT_ASM: asm },
77
+ stdio: "pipe",
78
+ });
79
+ const unit = path.join(asm, PLUGIN);
80
+ const launcher = path.join(unit, "scripts", "copilot-hook-launch.sh");
81
+ ok(
82
+ "assembler produced the unit, the launcher and the capability registry",
83
+ [
84
+ path.join(asm, ".claude-plugin", "marketplace.json"),
85
+ path.join(unit, ".claude-plugin", "plugin.json"),
86
+ path.join(unit, "hooks", "hooks.json"),
87
+ path.join(unit, "entwurf-capabilities.json"),
88
+ launcher,
89
+ ].every((p) => existsSync(p)),
90
+ );
91
+
92
+ // ── 2. the manifest is the COPILOT form, not the Claude one ──────────────────
93
+ const hooks = JSON.parse(readFileSync(path.join(unit, "hooks", "hooks.json"), "utf8")) as {
94
+ version?: unknown;
95
+ hooks?: Record<string, Array<Record<string, unknown>>>;
96
+ };
97
+ ok("hooks.json declares the literal version 1 (Copilot rejects the plugin without it)", hooks.version === 1);
98
+ ok(
99
+ "hook events are exactly the two camelCase events that fire on a first prompt",
100
+ JSON.stringify(Object.keys(hooks.hooks ?? {}).sort()) === JSON.stringify(["sessionStart", "userPromptSubmitted"]),
101
+ );
102
+ const leaves = Object.values(hooks.hooks ?? {}).flat();
103
+ ok(
104
+ "[QK:COPILOT-BIRTH-EXEC-IS-STRING] every hook entry's `exec` is a STRING (an array is rejected at plugin load)",
105
+ leaves.every((l) => typeof l.exec === "string"),
106
+ );
107
+ ok(
108
+ "every hook entry points at the assembled launcher",
109
+ leaves.every((l) => l.exec === launcher),
110
+ );
111
+ // The Claude unit's whole identity contract rides `args`. Copilot has no such key, so
112
+ // a stray one here would be a manifest written against the wrong vendor's schema.
113
+ ok(
114
+ "no hook entry carries `args` — Copilot's schema has no such key",
115
+ leaves.every((l) => !("args" in l)),
116
+ );
117
+ ok(
118
+ "no hook entry carries an empty `matcher` — Copilot rejects one",
119
+ leaves.every((l) => l.matcher !== ""),
120
+ );
121
+
122
+ // ── 3. the launcher is baked ─────────────────────────────────────────────────
123
+ const launcherText = readFileSync(launcher, "utf8");
124
+ // The guard's own comparison literals are split (`"__NODE_""BIN__"`) precisely so this
125
+ // assertion can be exact: after a bake, no whole placeholder token survives anywhere.
126
+ ok(
127
+ "launcher carries no unsubstituted placeholder",
128
+ !launcherText.includes("__NODE_BIN__") && !launcherText.includes("__HOOK_ENTRY__"),
129
+ );
130
+ const bakedNode = /^NODE_BIN="(.*)"$/m.exec(launcherText)?.[1] ?? "";
131
+ ok("launcher's baked node is an existing executable", bakedNode.length > 0 && existsSync(bakedNode));
132
+
133
+ // ── 4. FIRE, the way Copilot fires: no argv, envelope on stdin ───────────────
134
+ interface Fired {
135
+ status: number | null;
136
+ stdout: string;
137
+ stderr: string;
138
+ }
139
+ /** The child's env: this gate's own, with the store relocated and every DIRECT store
140
+ * override removed. `PI_CODING_AGENT_DIR` only isolates what derives from it, so an
141
+ * operator shell that pins `ENTWURF_META_SENDERS_DIR` (or the sessions equivalent) would
142
+ * send the very artifacts asserted on below into the real store and read a stale one back. */
143
+ function isolatedEnv(storeDir: string): NodeJS.ProcessEnv {
144
+ const env: NodeJS.ProcessEnv = { ...process.env, PI_CODING_AGENT_DIR: storeDir };
145
+ delete env.ENTWURF_META_SENDERS_DIR;
146
+ delete env.ENTWURF_META_SESSIONS_DIR;
147
+ return env;
148
+ }
149
+ function fire(envelope: unknown, storeDir: string): Fired {
150
+ const res = spawnSync(launcher, [], {
151
+ input: typeof envelope === "string" ? envelope : JSON.stringify(envelope),
152
+ env: isolatedEnv(storeDir),
153
+ encoding: "utf8",
154
+ });
155
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
156
+ }
157
+ function records(storeDir: string): ReturnType<typeof listAllMetaIdentitiesDir>["identities"] {
158
+ const dir = path.join(storeDir, "meta-sessions");
159
+ if (!existsSync(dir)) return [];
160
+ return listAllMetaIdentitiesDir(dir).identities;
161
+ }
162
+ function hookLog(storeDir: string): string {
163
+ const file = path.join(storeDir, "meta-bridge-hook.log");
164
+ return existsSync(file) ? readFileSync(file, "utf8") : "";
165
+ }
166
+
167
+ const store = path.join(root, "store");
168
+ mkdirSync(store, { recursive: true });
169
+ const NATIVE_ID = "cop-birth-0001";
170
+ const CWD = "/home/junghan/repos/gh/entwurf";
171
+
172
+ const first = fire({ sessionId: NATIVE_ID, cwd: CWD, source: "new", timestamp: 1755690000000 }, store);
173
+ ok("[QK:COPILOT-BIRTH-NO-ARGV-LAUNCH] a no-argv fire with the NATIVE envelope exits 0", first.status === 0);
174
+ ok("the hook emits the neutral response and nothing else", first.stdout.trim() === "{}");
175
+ let live = records(store);
176
+ ok("exactly one record was minted", live.length === 1);
177
+ ok("[QK:COPILOT-BIRTH-MINTS-COPILOT] the record's backend is copilot", live[0]?.backend === "copilot");
178
+ ok("the record joins on the native sessionId", live[0]?.nativeSessionId === NATIVE_ID);
179
+ ok("the record carries the envelope's cwd", live[0]?.cwd === CWD);
180
+ // Omitted, never guessed: Copilot's envelope carries neither, and both are nullable.
181
+ ok("model and transcriptPath are null, not invented", live[0]?.model === null && live[0]?.transcriptPath === null);
182
+ const bornGardenId = live[0]?.gardenId ?? "";
183
+
184
+ // ── 5. the second event of the same first prompt ATTACHES ───────────────────
185
+ // Measured firing order is userPromptSubmitted -> sessionStart, so the citizen is
186
+ // minted by one and re-seen by the other. Two records for one session would be two
187
+ // citizens for one Copilot window.
188
+ const second = fire({ hook_event_name: "SessionStart", session_id: NATIVE_ID, cwd: CWD, source: "new" }, store);
189
+ ok("a second fire with the CLAUDE-COMPAT envelope also exits 0", second.status === 0);
190
+ live = records(store);
191
+ ok("the store still holds exactly one record", live.length === 1);
192
+ ok("the second fire attached to the SAME garden id", live[0]?.gardenId === bornGardenId);
193
+
194
+ // ── 6. WHO-SENT is armed; RECEIVER state is not ─────────────────────────────
195
+ // The two halves used to be one assertion ("no marker of any kind"), and that
196
+ // conflation is the defect #82 RAIL 5b closed: the doorbell's absence is a fact about
197
+ // the RECEIVER rail only. A sender marker needs a shared parent, not a doorbell, so
198
+ // this backend can say who sent a message while still being unable to receive one.
199
+ //
200
+ // The join this gate performs is the SAME one production performs, which is why the
201
+ // oracle is independent of the writer: `fire()` spawns the launcher as this process's
202
+ // child and the launcher `exec`s the payload, so the payload's parent IS this gate.
203
+ // The gate therefore knows the answer (`process.pid`) before reading the file.
204
+ const sendersDir = path.join(store, "meta-senders");
205
+ const markerFile = path.join(sendersDir, "copilot", `${process.pid}.json`);
206
+ ok(
207
+ "[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",
208
+ existsSync(markerFile),
209
+ );
210
+ const marker = JSON.parse(readFileSync(markerFile, "utf8")) as {
211
+ backend?: string;
212
+ gardenId?: string;
213
+ nativeSessionId?: string;
214
+ cwd?: string;
215
+ ownerPid?: number;
216
+ ownerStartKey?: string;
217
+ };
218
+ ok("the marker names the citizen that was just minted, not a second one", marker.gardenId === bornGardenId);
219
+ ok(
220
+ "the marker agrees with the record on backend and native id — a drift on either makes it a stale hint",
221
+ marker.backend === "copilot" && marker.nativeSessionId === NATIVE_ID,
222
+ );
223
+ ok("the marker's ownerPid IS the gate's own pid", marker.ownerPid === process.pid);
224
+ // The pid-reuse guard: a marker keyed to a pid alone would be inherited by whatever
225
+ // process the OS hands that number to next.
226
+ ok(
227
+ "the marker carries the owner's start-key, so a reused pid cannot inherit this citizen",
228
+ typeof marker.ownerStartKey === "string" && marker.ownerStartKey === processStartKey(process.pid),
229
+ );
230
+
231
+ // The READ half. A marker nobody looks for is invisible, and that asymmetry — writer
232
+ // open, reader closed — is exactly how #46 made an agy citizen send as an anonymous
233
+ // external host. So the resolver is run for real, not inspected.
234
+ ok(
235
+ "[QK:COPILOT-SENDER-READER-OPEN] copilot is one of the backends the resolver scans — a marker nobody looks for is invisible",
236
+ META_SENDER_BACKENDS.includes("copilot"),
237
+ );
238
+ // The resolver reads the RECORD store through this process's own env (the marker is only
239
+ // a hint; the record is the authority), while the marker root is a parameter. So the
240
+ // record half is pointed at the temp store for the duration of the call and put back —
241
+ // same isolation the agy sender gate uses, and `isolatedEnv` keeps it out of every child.
242
+ function withSessionsDir<T>(dir: string, fn: () => T): T {
243
+ const prev = process.env.ENTWURF_META_SESSIONS_DIR;
244
+ process.env.ENTWURF_META_SESSIONS_DIR = dir;
245
+ try {
246
+ return fn();
247
+ } finally {
248
+ if (prev === undefined) delete process.env.ENTWURF_META_SESSIONS_DIR;
249
+ else process.env.ENTWURF_META_SESSIONS_DIR = prev;
250
+ }
251
+ }
252
+ const trusted = withSessionsDir(path.join(store, "meta-sessions"), () =>
253
+ resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir }),
254
+ );
255
+ ok(
256
+ "the bridge resolver joins that marker to exactly ONE identity — the citizen born above",
257
+ trusted?.identity.gardenId === bornGardenId && trusted?.identity.backend === "copilot",
258
+ );
259
+
260
+ // The record store is the authority; the marker is only a hint it must agree with.
261
+ // Run in its OWN store so the live one above keeps its record for §7.
262
+ {
263
+ const orphanStore = path.join(root, "orphan-marker");
264
+ mkdirSync(orphanStore, { recursive: true });
265
+ fire({ sessionId: "cop-orphan-0001", cwd: CWD, source: "new" }, orphanStore);
266
+ const orphanSenders = path.join(orphanStore, "meta-senders");
267
+ ok(
268
+ "precondition: that store has its own marker too",
269
+ existsSync(path.join(orphanSenders, "copilot", `${process.pid}.json`)),
270
+ );
271
+ for (const f of readdirSync(path.join(orphanStore, "meta-sessions"))) {
272
+ rmSync(path.join(orphanStore, "meta-sessions", f));
273
+ }
274
+ ok(
275
+ "a marker whose record is gone resolves to NOBODY — a hint is not an identity",
276
+ withSessionsDir(path.join(orphanStore, "meta-sessions"), () =>
277
+ resolveTrustedMetaSenderIdentity({ ownerPids: [process.pid], sendersDir: orphanSenders }),
278
+ ) === null,
279
+ );
280
+ }
281
+
282
+ // FAIL-CLOSED on provenance. Reaching the payload WITHOUT the launcher means we do not
283
+ // know what our parent is — an already-open session holding an older cached command is
284
+ // the real case. Birth still happens (a record needs no parent); only who-sent is
285
+ // withheld, and the log says which of the two refusals it was.
286
+ {
287
+ const noTokenStore = path.join(root, "no-provenance");
288
+ mkdirSync(noTokenStore, { recursive: true });
289
+ const entryRel = /^HOOK_ENTRY="\$PLUGIN_ROOT\/(.*)"$/m.exec(launcherText)?.[1] ?? "";
290
+ ok("the launcher's baked hook entry is readable from its text", entryRel.length > 0);
291
+ const bare = isolatedEnv(noTokenStore);
292
+ delete bare.ENTWURF_META_HOOK_LAUNCH;
293
+ const res = spawnSync(bakedNode, [path.join(path.dirname(launcher), "..", entryRel)], {
294
+ input: JSON.stringify({ sessionId: "cop-noprov-0001", cwd: CWD, source: "new" }),
295
+ env: bare,
296
+ encoding: "utf8",
297
+ });
298
+ ok("an unstamped launch still exits 0 — best-effort, never breaks the turn", res.status === 0);
299
+ ok("an unstamped launch still MINTS the citizen", records(noTokenStore).length === 1);
300
+ ok(
301
+ "an unstamped launch writes NO sender marker — an unknown parent is not an owner",
302
+ !readdirSync(noTokenStore).includes("meta-senders"),
303
+ );
304
+ ok(
305
+ "and it says so in the log the doctor reads",
306
+ hookLog(noTokenStore).includes("sender-marker-refused") && hookLog(noTokenStore).includes("provenance missing"),
307
+ );
308
+ }
309
+
310
+ // BIRTH DOES NOT ARM, and that is still true now that Copilot HAS a doorbell (#82 RAIL
311
+ // 5). What changed is the reason, not the assertion. It used to hold because no wake
312
+ // surface existed anywhere in the bundle; it holds today because the surface that exists
313
+ // belongs to a DIFFERENT PROCESS — the forked extension, which owns the watch and can
314
+ // therefore honestly claim to hold one. A marker written from this hook would name the
315
+ // Copilot host pid as the owner of a watch that pid does not hold, and the citizen would
316
+ // read as deliverable for as long as the TUI stayed open, wired to nothing.
317
+ const storeEntries = readdirSync(store);
318
+ ok(
319
+ "[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",
320
+ !storeEntries.includes("meta-mailbox") && !storeEntries.includes("meta-receivers"),
321
+ );
322
+ // Identity is not replyability. This citizen can say who it is the moment it is born;
323
+ // whether a reply LANDS is answered one rail over, by the receiver marker the extension
324
+ // writes when it joins. The reply rail itself is picked from nativePushSupported at the
325
+ // bridge, and copilot lands in self-fetch either way.
326
+ ok(
327
+ "copilot is NOT native-push — a sender marker buys who-sent, and replyability comes from the receiver marker instead",
328
+ nativePushSupported("copilot") === false,
329
+ );
330
+
331
+ // ── 7. the citizen is a PEER, and an honest one ─────────────────────────────
332
+ const facts = resolveFactList(live, []);
333
+ const peer = facts.peers.find((p) => p.gardenId === bornGardenId);
334
+ ok("the minted citizen appears in the peer fact list", peer !== undefined);
335
+ ok("its liveness is `unsupported` — no control-socket probe exists for this backend", peer?.liveness === "unsupported");
336
+
337
+ // ── 8. negatives: every refusal is a REFUSAL, not a guessed record ──────────
338
+ function refuses(label: string, envelope: unknown, expectInLog: string): void {
339
+ const negStore = path.join(root, `neg-${label.replace(/[^a-z0-9]+/gi, "-")}`);
340
+ mkdirSync(negStore, { recursive: true });
341
+ const res = fire(envelope, negStore);
342
+ ok(`${label}: exits 0 (best-effort, never breaks the operator's turn)`, res.status === 0);
343
+ ok(`${label}: writes NO record`, records(negStore).length === 0);
344
+ ok(`${label}: logs an ERROR the doctor can read (${expectInLog})`, hookLog(negStore).includes(expectInLog));
345
+ }
346
+ // The one a naive `sessionId ?? session_id` would swallow: two identities for one
347
+ // session means the envelope is not trustworthy, so neither id may be minted.
348
+ refuses(
349
+ "[QK:COPILOT-BIRTH-ID-DISAGREEMENT] disagreeing sessionId/session_id",
350
+ { sessionId: "a-1", session_id: "b-2", cwd: CWD },
351
+ "disagree",
352
+ );
353
+ refuses("missing cwd", { sessionId: NATIVE_ID }, "cwd missing");
354
+ refuses("no session id under either key", { cwd: CWD, source: "new" }, "no sessionId/session_id");
355
+ refuses("malformed envelope", "{not json", "envelope parse failed");
356
+ // Agreement is NOT a refusal — the compat translator emitting both keys is normal.
357
+ const agreeStore = path.join(root, "agree");
358
+ mkdirSync(agreeStore, { recursive: true });
359
+ const agreeing = fire({ sessionId: NATIVE_ID, session_id: NATIVE_ID, cwd: CWD }, agreeStore);
360
+ ok(
361
+ "both keys AGREEING is accepted (that is the compat translator, not a defect)",
362
+ agreeing.status === 0 && records(agreeStore).length === 1,
363
+ );
364
+
365
+ // ── 9. an UNBAKED launcher refuses loudly instead of exec'ing a placeholder ──
366
+ const rawUnit = path.join(root, "raw");
367
+ mkdirSync(path.join(rawUnit, "scripts"), { recursive: true });
368
+ const rawLauncher = path.join(rawUnit, "scripts", "copilot-hook-launch.sh");
369
+ copyFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"), rawLauncher);
370
+ chmodSync(rawLauncher, 0o755);
371
+ const rawStore = path.join(root, "raw-store");
372
+ mkdirSync(rawStore, { recursive: true });
373
+ const raw = spawnSync(rawLauncher, [], {
374
+ input: JSON.stringify({ sessionId: NATIVE_ID, cwd: CWD }),
375
+ env: { ...process.env, PI_CODING_AGENT_DIR: rawStore },
376
+ encoding: "utf8",
377
+ });
378
+ ok("the committed (unbaked) launcher exits non-zero", raw.status !== 0);
379
+ ok("it names the install verb rather than failing silently", (raw.stderr ?? "").includes("install-copilot-bridge"));
380
+ ok("it wrote no record", records(rawStore).length === 0);
381
+
382
+ // ── 10. the shipped skeleton keeps its placeholders ─────────────────────────
383
+ // If the committed unit were already baked to some host's node path, every other
384
+ // host's install would ship a launcher pointing at a binary it does not have.
385
+ const shippedHooks = readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "hooks", "hooks.json"), "utf8");
386
+ ok("the committed hooks.json still carries __COPILOT_LAUNCHER__", shippedHooks.includes("__COPILOT_LAUNCHER__"));
387
+ const shippedLauncher = readFileSync(
388
+ path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, "scripts", "copilot-hook-launch.sh"),
389
+ "utf8",
390
+ );
391
+ ok(
392
+ "the committed launcher still carries both placeholders",
393
+ shippedLauncher.includes('NODE_BIN="__NODE_BIN__"') && shippedLauncher.includes("__HOOK_ENTRY__"),
394
+ );
395
+
396
+ // ── 11. the install path, driven against a FAKE copilot ─────────────────────
397
+ // The one part of this lane that cannot be exercised for real without touching the
398
+ // operator's Copilot — and the place cross-review named the strongest unguarded
399
+ // defect: an unqualified stale-unit removal that treats a CLI failure as an absence,
400
+ // or that reaches a same-named plugin from somebody else's marketplace.
401
+ const OURS = "entwurf-meta-receive-copilot@meta-bridge-copilot-local";
402
+ const STALE = "entwurf-meta-receive@meta-bridge-local";
403
+ const FOREIGN = "entwurf-meta-receive@someone-elses-marketplace";
404
+ const MKT = "meta-bridge-copilot-local";
405
+ const SHIPPED_VERSION = (
406
+ JSON.parse(
407
+ readFileSync(path.join(REPO, "pi", "meta-bridge-copilot", PLUGIN, ".claude-plugin", "plugin.json"), "utf8"),
408
+ ) as {
409
+ version: string;
410
+ }
411
+ ).version;
412
+
413
+ interface FakeHost {
414
+ home: string;
415
+ env: NodeJS.ProcessEnv;
416
+ asm: string;
417
+ /** package ownership state file inside the sandboxed XDG root */
418
+ stateFile: string;
419
+ log: string;
420
+ pluginState: string;
421
+ mktState: string;
422
+ }
423
+ interface FakeOpts {
424
+ installed?: string[];
425
+ /** registered marketplaces as [name, localPath] rows */
426
+ marketplaces?: Array<[string, string]>;
427
+ /** verbatim `plugin list` body override — for malformed/garbled listing cells */
428
+ pluginListRaw?: string;
429
+ uninstallFails?: boolean;
430
+ listFails?: boolean;
431
+ mktListFails?: boolean;
432
+ mktRemoveFails?: boolean;
433
+ }
434
+ interface FakeRun {
435
+ status: number | null;
436
+ stdout: string;
437
+ stderr: string;
438
+ /** every `copilot …` argv the driven surface issued, in order */
439
+ calls: string[];
440
+ /** the plugin ids the fake still holds when the surface is done */
441
+ installed: string[];
442
+ /** the marketplace rows the fake still holds */
443
+ marketplaces: string[];
444
+ }
445
+ /** A fake that ANSWERS like the MEASURED CLI (copilot 1.0.80, 2026-08-27): `plugin
446
+ * list` prints qualified ids with a `(vX)` suffix, `plugin marketplace list` prints a
447
+ * registered local marketplace as `<name> (Local: <abs path>)`, `plugin uninstall <id>`
448
+ * removes exactly that id — and `--force` anywhere is refused loudly, because the real
449
+ * `marketplace remove --force` uninstalls that marketplace's plugins as a side effect
450
+ * and no entwurf surface may reach for it. Marketplace subverbs are modeled SEPARATELY
451
+ * (the old generic `exit 0` arm could not kill an inverse that removed a foreign
452
+ * marketplace). */
453
+ function makeFakeHost(label: string, opts: FakeOpts): FakeHost {
454
+ const home = path.join(root, `fake-${label}`);
455
+ const bin = path.join(home, "bin");
456
+ mkdirSync(bin, { recursive: true });
457
+ const xdg = path.join(home, "xdg");
458
+ mkdirSync(xdg, { recursive: true });
459
+ const pluginState = path.join(home, "installed.txt");
460
+ const mktState = path.join(home, "marketplaces.txt");
461
+ const rawList = path.join(home, "rawlist.txt");
462
+ const log = path.join(home, "calls.log");
463
+ const installed = opts.installed ?? [];
464
+ const marketplaces = opts.marketplaces ?? [];
465
+ if (opts.pluginListRaw !== undefined) writeFileSync(rawList, `${opts.pluginListRaw}\n`);
466
+ writeFileSync(pluginState, installed.join("\n") + (installed.length ? "\n" : ""));
467
+ writeFileSync(mktState, marketplaces.map(([n, p]) => `${n}\t${p}`).join("\n") + (marketplaces.length ? "\n" : ""));
468
+ writeFileSync(log, "");
469
+ writeFileSync(
470
+ path.join(bin, "copilot"),
471
+ [
472
+ "#!/usr/bin/env bash",
473
+ `STATE=${JSON.stringify(pluginState)}`,
474
+ `MKTS=${JSON.stringify(mktState)}`,
475
+ `LOG=${JSON.stringify(log)}`,
476
+ `VER=${JSON.stringify(SHIPPED_VERSION)}`,
477
+ 'echo "$*" >> "$LOG"',
478
+ 'for a in "$@"; do [ "$a" = "--force" ] && { echo "fake copilot: --force is forbidden here" >&2; exit 99; }; done',
479
+ 'case "$1 $2 $3" in',
480
+ opts.mktListFails
481
+ ? ' "plugin marketplace list") echo "not authenticated" >&2; exit 1 ;;'
482
+ : [
483
+ ' "plugin marketplace list")',
484
+ ' echo "Included with GitHub Copilot:"',
485
+ ' while IFS=$\'\\t\' read -r n p; do [ -n "$n" ] && echo " • $n (Local: $p)"; done < "$MKTS"',
486
+ " exit 0 ;;",
487
+ ].join("\n"),
488
+ ' "plugin marketplace add") printf "%s\\t%s\\n" ' + JSON.stringify(MKT) + ' "$4" >> "$MKTS"; exit 0 ;;',
489
+ opts.mktRemoveFails
490
+ ? ' "plugin marketplace remove") echo "mkt boom" >&2; exit 1 ;;'
491
+ : ' "plugin marketplace remove") awk -F"\\t" -v n="$4" \'$1 != n\' "$MKTS" > "$MKTS.tmp"; mv "$MKTS.tmp" "$MKTS"; exit 0 ;;',
492
+ "esac",
493
+ 'case "$1 $2" in',
494
+ opts.listFails
495
+ ? ' "plugin list") echo "not authenticated" >&2; exit 1 ;;'
496
+ : opts.pluginListRaw !== undefined
497
+ ? ` "plugin list") echo "Installed plugins:"; cat ${JSON.stringify(rawList)}; exit 0 ;;`
498
+ : ' "plugin list") echo "Installed plugins:"; sed "s/^/ • /;s/$/ (v$VER)/" "$STATE"; exit 0 ;;',
499
+ ' "plugin uninstall")',
500
+ opts.uninstallFails
501
+ ? ' echo "boom" >&2; exit 1 ;;'
502
+ : ' grep -Fvx "$3" "$STATE" > "$STATE.tmp"; mv "$STATE.tmp" "$STATE"; exit 0 ;;',
503
+ ' "plugin install") echo "$3" >> "$STATE"; exit 0 ;;',
504
+ "esac",
505
+ "exit 0",
506
+ ].join("\n"),
507
+ );
508
+ chmodSync(path.join(bin, "copilot"), 0o755);
509
+ return {
510
+ home,
511
+ asm: path.join(home, "asm"),
512
+ stateFile: path.join(xdg, "entwurf", "copilot-bridge", "install-state.json"),
513
+ log,
514
+ pluginState,
515
+ mktState,
516
+ env: {
517
+ ...process.env,
518
+ PATH: `${bin}:${process.env.PATH}`,
519
+ XDG_DATA_HOME: xdg,
520
+ // The doctor reads the record store and hook log from the agent dir; keep the
521
+ // gate hermetic rather than letting it read the operator's real host state.
522
+ PI_CODING_AGENT_DIR: path.join(home, "agent"),
523
+ ENTWURF_COPILOT_ASM: path.join(home, "asm"),
524
+ },
525
+ };
526
+ }
527
+ function runVerb(host: FakeHost, verb: string): FakeRun {
528
+ const res = spawnSync("bash", [path.join(REPO, "run.sh"), verb], { env: host.env, encoding: "utf8" });
529
+ return {
530
+ status: res.status,
531
+ stdout: res.stdout ?? "",
532
+ stderr: res.stderr ?? "",
533
+ calls: readFileSync(host.log, "utf8").split("\n").filter(Boolean),
534
+ installed: readFileSync(host.pluginState, "utf8").split("\n").filter(Boolean),
535
+ marketplaces: readFileSync(host.mktState, "utf8").split("\n").filter(Boolean),
536
+ };
537
+ }
538
+ function runInstall(opts: FakeOpts & { label: string }): FakeRun & { host: FakeHost } {
539
+ const host = makeFakeHost(`install-${opts.label}`, opts);
540
+ return { ...runVerb(host, "install-copilot-bridge"), host };
541
+ }
542
+
543
+ const withStale = runInstall({ installed: [STALE], label: "stale" });
544
+ ok(
545
+ "[QK:COPILOT-INSTALL-QUALIFIED-STALE] install removes the stale Claude unit by its QUALIFIED id",
546
+ withStale.calls.includes(`plugin uninstall ${STALE}`),
547
+ );
548
+ ok("install then registers our unit", withStale.installed.includes(OURS) && withStale.status === 0);
549
+ ok("the stale unit is gone afterwards", !withStale.installed.includes(STALE));
550
+
551
+ const withForeign = runInstall({ installed: [FOREIGN], label: "foreign" });
552
+ ok(
553
+ "[QK:COPILOT-INSTALL-FOREIGN-UNTOUCHED] a same-named plugin from ANOTHER marketplace is left alone",
554
+ withForeign.installed.includes(FOREIGN) &&
555
+ !withForeign.calls.some((c) => c.startsWith(`plugin uninstall ${FOREIGN}`)),
556
+ );
557
+
558
+ const uninstallBroken = runInstall({ installed: [STALE], uninstallFails: true, label: "cli-error" });
559
+ ok(
560
+ "[QK:COPILOT-INSTALL-UNINSTALL-FAILURE-IS-FATAL] a FAILING uninstall is not read as an absence — the install refuses",
561
+ uninstallBroken.status !== 0 && !uninstallBroken.installed.includes(OURS),
562
+ );
563
+
564
+ const listBroken = runInstall({ installed: [STALE], listFails: true, label: "list-error" });
565
+ ok(
566
+ "[QK:COPILOT-INSTALL-LIST-FAILURE-IS-FATAL] a FAILING plugin list is not read as an empty host — the install refuses",
567
+ listBroken.status !== 0 && !listBroken.installed.includes(OURS),
568
+ );
569
+
570
+ const clean = runInstall({ installed: [], label: "clean" });
571
+ ok(
572
+ "a host with no stale unit installs cleanly and says so",
573
+ clean.status === 0 && clean.stdout.includes("nothing to remove"),
574
+ );
575
+
576
+ // ── 12. install ownership state (#86 C3a) ───────────────────────────────────
577
+ // The birth unit gets the same discipline the other three Copilot units have: a
578
+ // package-owned install-state written after the assembly publish and before any
579
+ // vendor mutation, adoption that is explicit and narrow, and vendor-list failures
580
+ // that read as UNKNOWN, never as absence.
581
+ ok(
582
+ "a fresh install writes the ownership state with the exact schema",
583
+ (() => {
584
+ if (!existsSync(clean.host.stateFile)) return false;
585
+ const s = JSON.parse(readFileSync(clean.host.stateFile, "utf8")) as Record<string, unknown>;
586
+ return (
587
+ s.schemaVersion === 1 &&
588
+ s.qualifiedId === OURS &&
589
+ s.marketplaceName === MKT &&
590
+ s.assemblyPath === clean.host.asm &&
591
+ s.pluginVersion === SHIPPED_VERSION &&
592
+ s.ownedMarketplace === true &&
593
+ s.ownedAssembly === true &&
594
+ typeof s.installedAt === "string"
595
+ );
596
+ })(),
597
+ );
598
+ ok(
599
+ "the vendor sequence ran loud and in the bounded order (add before install, no blind uninstall)",
600
+ clean.calls.some((c) => c.startsWith("plugin marketplace add ")) &&
601
+ clean.calls.indexOf(`plugin install ${OURS}`) >
602
+ clean.calls.findIndex((c) => c.startsWith("plugin marketplace add ")),
603
+ );
604
+
605
+ const mktListBroken = runInstall({ installed: [], mktListFails: true, label: "mkt-list-error" });
606
+ ok(
607
+ "a FAILING marketplace list is not read as an empty host — the install refuses with zero writes",
608
+ mktListBroken.status !== 0 && !mktListBroken.installed.includes(OURS) && !existsSync(mktListBroken.host.stateFile),
609
+ );
610
+
611
+ // Reinstall over an existing state: the old blind `|| true` pair is gone, so a failing
612
+ // vendor step is an honest partial failure with the state retained for the rerun. The
613
+ // first install on each host is clean by construction (nothing to uninstall/remove), so
614
+ // the failure mode only fires on the RE-install, which is the path the blind pair hid.
615
+ {
616
+ const host = makeFakeHost("reinstall-uninstall-broken", { uninstallFails: true });
617
+ const first = runVerb(host, "install-copilot-bridge");
618
+ ok("precondition: the first install on the uninstall-broken host is green", first.status === 0);
619
+ const second = runVerb(host, "install-copilot-bridge");
620
+ ok(
621
+ "a reinstall whose exact-id uninstall FAILS stops loud with the state retained for repair",
622
+ second.status !== 0 && existsSync(host.stateFile) && second.installed.includes(OURS),
623
+ );
624
+ }
625
+ {
626
+ const host = makeFakeHost("reinstall-mkt-remove-broken", { mktRemoveFails: true });
627
+ const first = runVerb(host, "install-copilot-bridge");
628
+ ok("precondition: the first install on the remove-broken host is green", first.status === 0);
629
+ const second = runVerb(host, "install-copilot-bridge");
630
+ ok(
631
+ "a reinstall whose marketplace remove FAILS stops loud (never retried with --force) with the state retained",
632
+ second.status !== 0 && existsSync(host.stateFile) && !second.calls.some((c) => c.includes("--force")),
633
+ );
634
+ }
635
+ {
636
+ // No state + marketplace pre-registered at our path + no valid assembly → the
637
+ // legacy-marketplace adoption clause refuses before any write.
638
+ const host = makeFakeHost("mkt-no-assembly", {
639
+ marketplaces: [[MKT, path.join(root, "fake-mkt-no-assembly", "asm")]],
640
+ });
641
+ const refuse = runVerb(host, "install-copilot-bridge");
642
+ ok(
643
+ "a no-state marketplace registration over an INVALID assembly refuses with zero writes",
644
+ refuse.status !== 0 && !existsSync(host.stateFile) && !existsSync(host.asm),
645
+ );
646
+ }
647
+
648
+ // Legacy adoption, both shapes, driven through the honest route: install once, then
649
+ // strip the state and run again.
650
+ {
651
+ const adopt = runInstall({ installed: [], label: "adopt" });
652
+ ok("precondition: the adoption host installed green", adopt.status === 0 && existsSync(adopt.host.stateFile));
653
+ rmSync(adopt.host.stateFile);
654
+ const second = runVerb(adopt.host, "install-copilot-bridge");
655
+ ok(
656
+ "a legacy no-state installation with the exact QUALIFIED, our marketplace path and a valid assembly is ADOPTED and reported",
657
+ second.status === 0 &&
658
+ second.stdout.includes("adopting the legacy no-state installation") &&
659
+ existsSync(adopt.host.stateFile),
660
+ );
661
+ // Now break the assembly and strip the state again: adoption must refuse, and the
662
+ // refusal is zero-write (no state minted, the broken assembly untouched).
663
+ rmSync(adopt.host.stateFile);
664
+ rmSync(path.join(adopt.host.asm, PLUGIN, "hooks", "hooks.json"));
665
+ const invalid = runVerb(adopt.host, "install-copilot-bridge");
666
+ ok(
667
+ "a legacy no-state installation whose assembly FAILS the structural oracle refuses with zero writes",
668
+ invalid.status !== 0 &&
669
+ !existsSync(adopt.host.stateFile) &&
670
+ !existsSync(path.join(adopt.host.asm, PLUGIN, "hooks", "hooks.json")),
671
+ );
672
+ }
673
+
674
+ // ── 13. the package-owned INVERSE (#86 C3a) ─────────────────────────────────
675
+ {
676
+ const host = makeFakeHost("inverse-no-state", {
677
+ installed: [OURS],
678
+ marketplaces: [[MKT, path.join(root, "fake-inverse-no-state", "asm")]],
679
+ });
680
+ const res = runVerb(host, "uninstall-copilot-bridge");
681
+ ok(
682
+ "[QK:COPILOT-BIRTH-INVERSE-NO-STATE] with NO ownership state the inverse refuses and mutates NOTHING — a blind best-effort remove is exactly the defect",
683
+ res.status !== 0 &&
684
+ res.installed.includes(OURS) &&
685
+ res.marketplaces.length === 1 &&
686
+ !res.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
687
+ );
688
+ ok("the no-state refusal names the adoption repair", (res.stderr ?? "").includes("install-copilot-bridge"));
689
+ }
690
+ {
691
+ const host = makeFakeHost("inverse-corrupt", { installed: [OURS] });
692
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
693
+ writeFileSync(host.stateFile, "{not json");
694
+ const res = runVerb(host, "uninstall-copilot-bridge");
695
+ ok(
696
+ "a CORRUPT state refuses with zero vendor mutation and the state retained for inspection",
697
+ res.status !== 0 && res.installed.includes(OURS) && existsSync(host.stateFile) && res.calls.length === 0,
698
+ );
699
+ }
700
+ {
701
+ const host = makeFakeHost("inverse-drift", { installed: [OURS] });
702
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
703
+ writeFileSync(
704
+ host.stateFile,
705
+ JSON.stringify({
706
+ schemaVersion: 1,
707
+ qualifiedId: OURS,
708
+ marketplaceName: MKT,
709
+ assemblyPath: "/somewhere/else/.assembled",
710
+ pluginVersion: SHIPPED_VERSION,
711
+ ownedMarketplace: true,
712
+ ownedAssembly: true,
713
+ installedAt: "2026-08-27T00:00:00Z",
714
+ }),
715
+ );
716
+ const res = runVerb(host, "uninstall-copilot-bridge");
717
+ ok(
718
+ "a state whose assemblyPath DRIFTED from this host's effective assembly refuses with zero vendor mutation",
719
+ res.status !== 0 && res.installed.includes(OURS) && res.calls.length === 0,
720
+ );
721
+ }
722
+ function writeBoundState(host: FakeHost): void {
723
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
724
+ writeFileSync(
725
+ host.stateFile,
726
+ JSON.stringify({
727
+ schemaVersion: 1,
728
+ qualifiedId: OURS,
729
+ marketplaceName: MKT,
730
+ assemblyPath: host.asm,
731
+ pluginVersion: SHIPPED_VERSION,
732
+ ownedMarketplace: true,
733
+ ownedAssembly: true,
734
+ installedAt: "2026-08-27T00:00:00Z",
735
+ }),
736
+ );
737
+ }
738
+ {
739
+ const host = makeFakeHost("inverse-foreign-mkt", {
740
+ installed: [OURS],
741
+ marketplaces: [[MKT, "/somebody/elses/marketplace-root"]],
742
+ });
743
+ writeBoundState(host);
744
+ const res = runVerb(host, "uninstall-copilot-bridge");
745
+ ok(
746
+ "[QK:COPILOT-BIRTH-INVERSE-FOREIGN] a marketplace with OUR name at ANOTHER path refuses BEFORE the plugin uninstall — zero vendor writes, everything preserved (that qualified id could be THAT marketplace's plugin)",
747
+ res.status !== 0 &&
748
+ res.installed.includes(OURS) &&
749
+ !res.calls.includes(`plugin uninstall ${OURS}`) &&
750
+ res.marketplaces.some((row) => row.includes("/somebody/elses/marketplace-root")) &&
751
+ !res.calls.some((c) => c.startsWith("plugin marketplace remove")) &&
752
+ existsSync(host.stateFile),
753
+ );
754
+ }
755
+ {
756
+ // ownedMarketplace=false while OUR marketplace IS registered at the recorded path:
757
+ // completing the inverse would delete the backing assembly/state under a
758
+ // registration it must preserve — so the whole operation refuses up front.
759
+ const host = makeFakeHost("inverse-unowned-mkt", { installed: [OURS] });
760
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
761
+ writeFileSync(host.mktState, `${MKT}\t${host.asm}\n`);
762
+ mkdirSync(path.join(host.asm, PLUGIN), { recursive: true });
763
+ writeFileSync(
764
+ host.stateFile,
765
+ JSON.stringify({
766
+ schemaVersion: 1,
767
+ qualifiedId: OURS,
768
+ marketplaceName: MKT,
769
+ assemblyPath: host.asm,
770
+ pluginVersion: SHIPPED_VERSION,
771
+ ownedMarketplace: false,
772
+ ownedAssembly: true,
773
+ installedAt: "2026-08-27T00:00:00Z",
774
+ }),
775
+ );
776
+ const res = runVerb(host, "uninstall-copilot-bridge");
777
+ ok(
778
+ "a REGISTERED marketplace the state does not own refuses the WHOLE inverse before any vendor write — no dangling registration over removed backing",
779
+ res.status !== 0 &&
780
+ res.installed.includes(OURS) &&
781
+ !res.calls.includes(`plugin uninstall ${OURS}`) &&
782
+ res.marketplaces.length === 1 &&
783
+ existsSync(path.join(host.asm, PLUGIN)) &&
784
+ existsSync(host.stateFile),
785
+ );
786
+ }
787
+ {
788
+ // State schema is fail-closed EXACTLY: representative missing-key, wrong-typed
789
+ // bool, and unknown-key states each refuse with zero vendor mutation.
790
+ const badStates: Array<[string, Record<string, unknown>]> = [
791
+ [
792
+ "missing-key",
793
+ {
794
+ schemaVersion: 1,
795
+ qualifiedId: OURS,
796
+ marketplaceName: MKT,
797
+ assemblyPath: "PLACEHOLDER",
798
+ pluginVersion: SHIPPED_VERSION,
799
+ ownedMarketplace: true,
800
+ ownedAssembly: true,
801
+ },
802
+ ],
803
+ [
804
+ "wrong-bool",
805
+ {
806
+ schemaVersion: 1,
807
+ qualifiedId: OURS,
808
+ marketplaceName: MKT,
809
+ assemblyPath: "PLACEHOLDER",
810
+ pluginVersion: SHIPPED_VERSION,
811
+ ownedMarketplace: "true",
812
+ ownedAssembly: true,
813
+ installedAt: "2026-08-27T00:00:00Z",
814
+ },
815
+ ],
816
+ [
817
+ "unknown-key",
818
+ {
819
+ schemaVersion: 1,
820
+ qualifiedId: OURS,
821
+ marketplaceName: MKT,
822
+ assemblyPath: "PLACEHOLDER",
823
+ pluginVersion: SHIPPED_VERSION,
824
+ ownedMarketplace: true,
825
+ ownedAssembly: true,
826
+ installedAt: "2026-08-27T00:00:00Z",
827
+ extra: 1,
828
+ },
829
+ ],
830
+ ];
831
+ for (const [label, state] of badStates) {
832
+ const host = makeFakeHost(`inverse-state-${label}`, { installed: [OURS] });
833
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
834
+ state.assemblyPath = host.asm;
835
+ writeFileSync(host.stateFile, JSON.stringify(state));
836
+ const res = runVerb(host, "uninstall-copilot-bridge");
837
+ ok(
838
+ `a ${label} ownership state refuses fail-closed with zero vendor mutation (flags are never coerced)`,
839
+ res.status !== 0 && res.installed.includes(OURS) && res.calls.length === 0 && existsSync(host.stateFile),
840
+ );
841
+ }
842
+ }
843
+ {
844
+ // EXACT-row adversary: a longer qualified id that CONTAINS ours authorizes nothing —
845
+ // not the inverse's uninstall branch, not install adoption, not the doctor's
846
+ // registration read.
847
+ const ADVERSARY = `${OURS}-extra`;
848
+ const invHost = makeFakeHost("adversary-inverse", { installed: [ADVERSARY] });
849
+ writeBoundState(invHost);
850
+ const inv = runVerb(invHost, "uninstall-copilot-bridge");
851
+ ok(
852
+ "inverse: a longer row containing our qualified id is NOT our plugin — named already-absent, adversary survives, no uninstall argv",
853
+ inv.status === 0 &&
854
+ inv.stdout.includes("already absent") &&
855
+ inv.installed.includes(ADVERSARY) &&
856
+ !inv.calls.some((c) => c.startsWith("plugin uninstall")),
857
+ );
858
+ const adoptHost = makeFakeHost("adversary-adopt", { installed: [ADVERSARY] });
859
+ const adopt = runVerb(adoptHost, "install-copilot-bridge");
860
+ ok(
861
+ "install: the adversary row does not trigger the adoption branch — the host reads as fresh and the adversary survives",
862
+ adopt.status === 0 && !adopt.stdout.includes("adopting") && adopt.installed.includes(ADVERSARY),
863
+ );
864
+ const docHost = makeFakeHost("adversary-doctor", { installed: [ADVERSARY] });
865
+ const doc = runVerb(docHost, "doctor-copilot-bridge");
866
+ ok(
867
+ "doctor: the adversary row does not read as our registration",
868
+ doc.status !== 0 && doc.stdout.includes("NOT installed"),
869
+ );
870
+ }
871
+ {
872
+ // ASM delete-safety representative: a symlinked recorded assembly refuses the whole
873
+ // inverse before any vendor write.
874
+ const host = makeFakeHost("inverse-asm-symlink", { installed: [OURS] });
875
+ const realDir = path.join(host.home, "real-asm");
876
+ mkdirSync(realDir, { recursive: true });
877
+ rmSync(host.asm, { recursive: true, force: true });
878
+ symlinkSync(realDir, host.asm);
879
+ writeBoundState(host);
880
+ const res = runVerb(host, "uninstall-copilot-bridge");
881
+ ok(
882
+ "a SYMLINKED recorded assembly refuses the whole inverse before any vendor write (read-only lists are the only argv)",
883
+ res.status !== 0 &&
884
+ res.installed.includes(OURS) &&
885
+ res.calls.every((c) => c === "plugin list" || c === "plugin marketplace list") &&
886
+ existsSync(realDir) &&
887
+ existsSync(host.stateFile),
888
+ );
889
+ }
890
+ {
891
+ // --assemble-only is gate-only and must never rebuild the LIVE assembly: no
892
+ // override (or the default path) refuses before any assembly mutation.
893
+ const host = makeFakeHost("assemble-only-default", {});
894
+ const env = { ...host.env };
895
+ delete env.ENTWURF_COPILOT_ASM;
896
+ const res = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge", "--assemble-only"], {
897
+ env,
898
+ encoding: "utf8",
899
+ });
900
+ const liveAsm = path.join(env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
901
+ ok(
902
+ "--assemble-only without an explicit temp ENTWURF_COPILOT_ASM refuses BEFORE any assembly mutation",
903
+ res.status !== 0 && (res.stderr ?? "").includes("ENTWURF_COPILOT_ASM") && !existsSync(liveAsm),
904
+ );
905
+ }
906
+ {
907
+ const host = makeFakeHost("inverse-happy", { installed: [OURS, FOREIGN] });
908
+ // A real assembly to remove: the honest route again — this is the same host shape a
909
+ // clean install leaves behind, minus the vendor rows the fake seeds directly.
910
+ writeBoundState(host);
911
+ mkdirSync(path.join(host.asm, PLUGIN), { recursive: true });
912
+ writeFileSync(host.mktState, `${MKT}\t${host.asm}\n`);
913
+ const res = runVerb(host, "uninstall-copilot-bridge");
914
+ ok(
915
+ "the full inverse removes the exact plugin, the exact marketplace, the recorded assembly, then the state LAST",
916
+ res.status === 0 &&
917
+ !res.installed.includes(OURS) &&
918
+ res.marketplaces.length === 0 &&
919
+ !existsSync(host.asm) &&
920
+ !existsSync(host.stateFile),
921
+ );
922
+ ok(
923
+ "the inverse used only EXACT qualified/marketplace argv — never a bare id, never --force",
924
+ res.calls.includes(`plugin uninstall ${OURS}`) &&
925
+ res.calls.includes(`plugin marketplace remove ${MKT}`) &&
926
+ !res.calls.includes(`plugin uninstall ${PLUGIN}`) &&
927
+ !res.calls.some((c) => c.includes("--force")),
928
+ );
929
+ ok("a same-named FOREIGN plugin survives the inverse untouched", res.installed.includes(FOREIGN));
930
+ }
931
+ {
932
+ const host = makeFakeHost("inverse-retry", { installed: [], marketplaces: [] });
933
+ writeBoundState(host);
934
+ const res = runVerb(host, "uninstall-copilot-bridge");
935
+ ok(
936
+ "a RETRY over already-absent vendor resources names each absence and still clears the state (rerun-repair)",
937
+ res.status === 0 && res.stdout.includes("already absent") && !existsSync(host.stateFile),
938
+ );
939
+ }
940
+ {
941
+ const host = makeFakeHost("inverse-list-broken", { installed: [OURS], listFails: true });
942
+ writeBoundState(host);
943
+ const res = runVerb(host, "uninstall-copilot-bridge");
944
+ ok(
945
+ "[QK:COPILOT-BIRTH-NO-FALSE-ABSENCE] a FAILING plugin list is UNKNOWN, not an empty host — the inverse refuses with the state retained and no mutation",
946
+ res.status !== 0 &&
947
+ (res.stderr ?? "").includes("UNKNOWN") &&
948
+ existsSync(host.stateFile) &&
949
+ !res.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
950
+ );
951
+ }
952
+
953
+ {
954
+ // Version drift (final amendment): the vendor lists our exact id at a version the
955
+ // state did not record — the inverse must refuse fail-closed with everything
956
+ // preserved, and the doctor must name it.
957
+ const invHost = makeFakeHost("inverse-version-drift", { installed: [OURS] });
958
+ mkdirSync(path.dirname(invHost.stateFile), { recursive: true });
959
+ mkdirSync(path.join(invHost.asm, PLUGIN), { recursive: true });
960
+ writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n`);
961
+ writeFileSync(
962
+ invHost.stateFile,
963
+ JSON.stringify({
964
+ schemaVersion: 1,
965
+ qualifiedId: OURS,
966
+ marketplaceName: MKT,
967
+ assemblyPath: invHost.asm,
968
+ pluginVersion: "0.0.9-drift",
969
+ ownedMarketplace: true,
970
+ ownedAssembly: true,
971
+ installedAt: "2026-08-27T00:00:00Z",
972
+ }),
973
+ );
974
+ const inv = runVerb(invHost, "uninstall-copilot-bridge");
975
+ ok(
976
+ "inverse: an exact row at a version the state did not record is VERSION-DRIFT — fail-closed, no vendor mutation, plugin/marketplace/assembly/state all preserved",
977
+ inv.status !== 0 &&
978
+ (inv.stderr ?? "").includes("version-drift") &&
979
+ inv.installed.includes(OURS) &&
980
+ inv.marketplaces.length === 1 &&
981
+ existsSync(path.join(invHost.asm, PLUGIN)) &&
982
+ existsSync(invHost.stateFile) &&
983
+ !inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")),
984
+ );
985
+ const docHost = makeFakeHost("doctor-version-drift", { installed: [OURS] });
986
+ const doctorAsm = path.join(docHost.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
987
+ mkdirSync(path.dirname(docHost.stateFile), { recursive: true });
988
+ writeFileSync(docHost.mktState, `${MKT}\t${doctorAsm}\n`);
989
+ writeFileSync(
990
+ docHost.stateFile,
991
+ JSON.stringify({
992
+ schemaVersion: 1,
993
+ qualifiedId: OURS,
994
+ marketplaceName: MKT,
995
+ assemblyPath: doctorAsm,
996
+ pluginVersion: "0.0.9-drift",
997
+ ownedMarketplace: true,
998
+ ownedAssembly: true,
999
+ installedAt: "2026-08-27T00:00:00Z",
1000
+ }),
1001
+ );
1002
+ const doc = runVerb(docHost, "doctor-copilot-bridge");
1003
+ ok(
1004
+ "doctor: the same drift is a named RED (`version drift`)",
1005
+ doc.status !== 0 && doc.stdout.includes("version drift"),
1006
+ );
1007
+ }
1008
+
1009
+ // ── 13b. C3a corrective amendment (B review defects, 2026-08-27) ─────────────
1010
+ {
1011
+ // B defect 2: TWO marketplace rows with our exact name. The retired grep|head -1
1012
+ // grammar silently took the FIRST — here deliberately the one at OUR assembly, so
1013
+ // the old parser would proceed. Every surface must refuse the ambiguity instead.
1014
+ const invHost = makeFakeHost("inverse-dup-mkt", { installed: [OURS] });
1015
+ writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n${MKT}\t/somebody/elses/dup-root\n`);
1016
+ writeBoundState(invHost);
1017
+ const inv = runVerb(invHost, "uninstall-copilot-bridge");
1018
+ ok(
1019
+ "[QK:COPILOT-MKT-DUPLICATE-REFUSED] inverse: duplicate same-named marketplace rows are ambiguity — the whole inverse refuses before any vendor write, never 'the first row'",
1020
+ inv.status !== 0 &&
1021
+ (inv.stderr ?? "").includes("multiple marketplace rows") &&
1022
+ inv.installed.includes(OURS) &&
1023
+ inv.marketplaces.length === 2 &&
1024
+ !inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")) &&
1025
+ existsSync(invHost.stateFile),
1026
+ );
1027
+ const instHost = makeFakeHost("install-dup-mkt", {});
1028
+ writeFileSync(instHost.mktState, `${MKT}\t${instHost.asm}\n${MKT}\t/somebody/elses/dup-root\n`);
1029
+ const inst = runVerb(instHost, "install-copilot-bridge");
1030
+ ok(
1031
+ "install: the same duplicate marketplace listing refuses before any write (no state, no assembly)",
1032
+ inst.status !== 0 && !existsSync(instHost.stateFile) && !existsSync(instHost.asm),
1033
+ );
1034
+ const docHost = makeFakeHost("doctor-dup-mkt", { installed: [OURS] });
1035
+ writeFileSync(docHost.mktState, `${MKT}\t/first/root\n${MKT}\t/second/root\n`);
1036
+ const doc = runVerb(docHost, "doctor-copilot-bridge");
1037
+ ok(
1038
+ "doctor: duplicate same-named marketplace rows are a RED ownership fact",
1039
+ doc.status !== 0 && doc.stdout.includes("duplicated") && doc.stdout.includes("ownership axis: FAIL"),
1040
+ );
1041
+ }
1042
+ {
1043
+ // B defect 3: a TRUNCATED exact row (`(v0.1` — no closing paren) used to slip the
1044
+ // startswith/endswith pair and round to ABSENT; absence licenses the inverse to
1045
+ // continue as retry-safe. Malformed must be malformed on every surface.
1046
+ const raw = ` • ${OURS} (v0.1`;
1047
+ const invHost = makeFakeHost("inverse-truncated-row", { pluginListRaw: raw });
1048
+ writeBoundState(invHost);
1049
+ mkdirSync(path.join(invHost.asm, PLUGIN), { recursive: true });
1050
+ writeFileSync(invHost.mktState, `${MKT}\t${invHost.asm}\n`);
1051
+ const inv = runVerb(invHost, "uninstall-copilot-bridge");
1052
+ ok(
1053
+ "[QK:COPILOT-MALFORMED-ROW-NOT-ABSENT] inverse: a truncated exact row is MALFORMED — refuse with zero vendor mutation, never 'already absent'",
1054
+ inv.status !== 0 &&
1055
+ (inv.stderr ?? "").includes("malformed") &&
1056
+ !inv.stdout.includes("already absent") &&
1057
+ !inv.calls.some((c) => c.startsWith("plugin uninstall") || c.startsWith("plugin marketplace remove")) &&
1058
+ existsSync(path.join(invHost.asm, PLUGIN)) &&
1059
+ existsSync(invHost.stateFile),
1060
+ );
1061
+ const instHost = makeFakeHost("install-truncated-row", { pluginListRaw: raw });
1062
+ const inst = runVerb(instHost, "install-copilot-bridge");
1063
+ ok(
1064
+ "install: the same truncated row refuses before any write (no state minted)",
1065
+ inst.status !== 0 && (inst.stderr ?? "").includes("malformed") && !existsSync(instHost.stateFile),
1066
+ );
1067
+ }
1068
+ {
1069
+ // B defect 4: a pluginVersion carrying whitespace would be truncated by the
1070
+ // space-separated fact transport (`cut -d' ' -f3`) into a FABRICATED version and a
1071
+ // misleading downstream reason. The shared state validator refuses it as corrupt.
1072
+ const wsState = (asmPath: string): string =>
1073
+ JSON.stringify({
1074
+ schemaVersion: 1,
1075
+ qualifiedId: OURS,
1076
+ marketplaceName: MKT,
1077
+ assemblyPath: asmPath,
1078
+ pluginVersion: `${SHIPPED_VERSION} extra`,
1079
+ ownedMarketplace: true,
1080
+ ownedAssembly: true,
1081
+ installedAt: "2026-08-27T00:00:00Z",
1082
+ });
1083
+ const invHost = makeFakeHost("inverse-ws-version", { installed: [OURS] });
1084
+ mkdirSync(path.dirname(invHost.stateFile), { recursive: true });
1085
+ writeFileSync(invHost.stateFile, wsState(invHost.asm));
1086
+ const inv = runVerb(invHost, "uninstall-copilot-bridge");
1087
+ ok(
1088
+ "[QK:COPILOT-STATE-VERSION-WHITESPACE] inverse: a whitespace-carrying pluginVersion is a CORRUPT state named for its transport reason — zero vendor mutation, never a fabricated version verdict",
1089
+ inv.status !== 0 &&
1090
+ (inv.stderr ?? "").includes("whitespace") &&
1091
+ !(inv.stderr ?? "").includes("version-drift") &&
1092
+ inv.installed.includes(OURS) &&
1093
+ inv.calls.length === 0 &&
1094
+ existsSync(invHost.stateFile),
1095
+ );
1096
+ const docHost = makeFakeHost("doctor-ws-version", { installed: [OURS] });
1097
+ const doctorAsm = path.join(docHost.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
1098
+ mkdirSync(path.dirname(docHost.stateFile), { recursive: true });
1099
+ writeFileSync(docHost.mktState, `${MKT}\t${doctorAsm}\n`);
1100
+ writeFileSync(docHost.stateFile, wsState(doctorAsm));
1101
+ const doc = runVerb(docHost, "doctor-copilot-bridge");
1102
+ ok(
1103
+ "doctor: the same whitespace version is a corrupt-state RED, not a fabricated 'version drift'",
1104
+ doc.status !== 0 && doc.stdout.includes("whitespace") && !doc.stdout.includes("version drift"),
1105
+ );
1106
+ }
1107
+ {
1108
+ // The doctor's structural verdict IS the shared oracle (B defect 1's doctor half):
1109
+ // a host the real installer just set up reads green THROUGH the oracle, and one
1110
+ // broken oracle fact turns the same host red with the oracle's own reason. The
1111
+ // install here runs WITHOUT the ENTWURF_COPILOT_ASM seam so the assembly lands at
1112
+ // the XDG-derived path the doctor actually reads.
1113
+ const host = makeFakeHost("doctor-oracle", {});
1114
+ const envNoSeam = { ...host.env };
1115
+ delete envNoSeam.ENTWURF_COPILOT_ASM;
1116
+ const inst = spawnSync("bash", [path.join(REPO, "run.sh"), "install-copilot-bridge"], {
1117
+ env: envNoSeam,
1118
+ encoding: "utf8",
1119
+ });
1120
+ ok("precondition: a full install at the doctor's own XDG assembly path is green", inst.status === 0);
1121
+ const doctorAsm = path.join(host.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
1122
+ const green = runVerb(host, "doctor-copilot-bridge");
1123
+ ok(
1124
+ "doctor: the freshly installed host is GREEN through the shared structural oracle",
1125
+ green.status === 0 && green.stdout.includes("shared structural oracle"),
1126
+ );
1127
+ rmSync(path.join(doctorAsm, PLUGIN, "hooks", "hooks.json"));
1128
+ const red = runVerb(host, "doctor-copilot-bridge");
1129
+ ok(
1130
+ "[QK:COPILOT-DOCTOR-ORACLE-CONSUMED] doctor: one broken oracle fact turns the same host RED with the oracle's reason",
1131
+ red.status !== 0 && red.stdout.includes("structural oracle") && red.stdout.includes("hooks.json"),
1132
+ );
1133
+ }
1134
+
1135
+ // ── 14. the doctor's ownership axis (#86 C3a) ───────────────────────────────
1136
+ {
1137
+ const host = makeFakeHost("doctor-list-broken", { installed: [], listFails: true });
1138
+ const res = runVerb(host, "doctor-copilot-bridge");
1139
+ ok(
1140
+ "the doctor reads a FAILING plugin list as UNKNOWN (red), never as an empty host",
1141
+ res.status !== 0 && res.stdout.includes("UNKNOWN"),
1142
+ );
1143
+ }
1144
+ {
1145
+ const host = makeFakeHost("doctor-ownership-drift", {
1146
+ installed: [OURS],
1147
+ marketplaces: [[MKT, "/somebody/elses/marketplace-root"]],
1148
+ });
1149
+ // The doctor derives its assembly path from XDG (no ENTWURF_COPILOT_ASM seam), so
1150
+ // bind the state to that derived path — the drift under test is the MARKETPLACE's.
1151
+ const doctorAsm = path.join(host.env.XDG_DATA_HOME as string, "entwurf", "meta-bridge-copilot", ".assembled");
1152
+ mkdirSync(path.dirname(host.stateFile), { recursive: true });
1153
+ writeFileSync(
1154
+ host.stateFile,
1155
+ JSON.stringify({
1156
+ schemaVersion: 1,
1157
+ qualifiedId: OURS,
1158
+ marketplaceName: MKT,
1159
+ assemblyPath: doctorAsm,
1160
+ pluginVersion: SHIPPED_VERSION,
1161
+ ownedMarketplace: true,
1162
+ ownedAssembly: true,
1163
+ installedAt: "2026-08-27T00:00:00Z",
1164
+ }),
1165
+ );
1166
+ const res = runVerb(host, "doctor-copilot-bridge");
1167
+ ok(
1168
+ "the doctor FAILs a marketplace registered under our name at another path (ownership drift) and prints the axes separately",
1169
+ res.status !== 0 && res.stdout.includes("ownership drift") && res.stdout.includes("ownership axis: FAIL"),
1170
+ );
1171
+ }
1172
+
1173
+ writeFileSync(path.join(root, "gate.ok"), "");
1174
+ console.log(`[check-copilot-birth-hook] ${passed} assertions ok`);