@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,251 @@
1
+ /**
2
+ * copilot-fresh-preflight — the pre-mutation capability check a Copilot fresh call needs
3
+ * (#82 RAIL 9, `docs/adding-a-harness.md` step 9 clauses 3 and 4).
4
+ *
5
+ * ── Why this is a leaf and not a doctor ──
6
+ *
7
+ * A doctor answers "is this host correctly wired?" and is allowed to be slow, to spawn the
8
+ * vendor CLI, and to read live processes. This is not that. It answers ONE narrower question
9
+ * at ONE moment: *before* `mux-fresh-call` mutates the operator's tmux session, are the four
10
+ * things the fresh contract requires actually in place on this filesystem?
11
+ *
12
+ * birth — without the birth plugin the session mints no record, so the callback
13
+ * would carry no garden id and the sibling never becomes addressable.
14
+ * MCP hand — without the bridge server the callback tool does not exist in that
15
+ * session, and the first turn has nothing to call.
16
+ * receive — without the receiver unit the sibling can be launched and can call
17
+ * home, and then nothing can ever be delivered TO it.
18
+ * visible identity — without the custom footer the citizen has a garden id nobody can see,
19
+ * which step 4 refuses to call lifecycle parity.
20
+ *
21
+ * ── Ordering is the whole point ──
22
+ *
23
+ * `scripts/copilot-launch.sh` already fails closed on the receiver, and that check is kept as
24
+ * it is: it guards every manual `entwurf copilot`, whose accepted contract stays receive-only.
25
+ * But a launcher runs INSIDE the window tmux just made, so its refusal leaves a dead window
26
+ * behind and the caller reads a launch receipt for a sibling that never was. `mux-fresh-call`'s
27
+ * rule is that nothing above the single mutation may leave a window behind, so the fresh lane
28
+ * decides the same facts one layer earlier. The overlap on the receiver axis is deliberate and
29
+ * each side names the other; the other three axes are the FRESH lane's requirement only.
30
+ *
31
+ * ── What this deliberately does NOT claim ──
32
+ *
33
+ * Ownership/configuration truth only: entwurf's units are installed and the settings the
34
+ * vendor will read say what they must say. It does NOT prove the Copilot CLI loaded the
35
+ * plugin, connected the MCP server, scanned the extension, or rendered a garden id in its
36
+ * footer — that is runtime truth, and it belongs to `doctor-copilot-*` (which may spawn the
37
+ * vendor) and to the step 9 clause 7 LIVE receipt. A green preflight is a statement about this
38
+ * filesystem, not a prediction about the next process.
39
+ *
40
+ * Every predicate below MIRRORS the shipped adapter that owns that file, deliberately
41
+ * including its environment seams — a preflight that resolved a path its own installer never
42
+ * writes would refuse a correctly installed host and send the operator hunting.
43
+ *
44
+ * No vendor spawn, no network, no await, no mutation.
45
+ */
46
+
47
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
48
+ import * as path from "node:path";
49
+
50
+ /** One reason per capability. These strings are stable contract — they cross the public
51
+ * surfaces as `entwurf_fresh_call` refusals, so a caller can act on them. */
52
+ export type CopilotPreflightRejectReason =
53
+ | "copilot-birth-unit-missing"
54
+ | "copilot-mcp-hand-missing"
55
+ | "copilot-receive-unit-missing"
56
+ | "copilot-visible-identity-missing";
57
+
58
+ const BIRTH_PLUGIN = "entwurf-meta-receive-copilot";
59
+ const RECEIVE_UNIT = "entwurf-receive";
60
+ const MCP_SERVER_KEY = "entwurf-bridge";
61
+ /** Same default and same env seam as `scripts/copilot-statusline-bridge.sh`. */
62
+ const DEFAULT_STATUSLINE_COMMAND = "entwurf-copilot-statusline";
63
+
64
+ function dataHome(env: NodeJS.ProcessEnv): string | null {
65
+ const xdg = env.XDG_DATA_HOME;
66
+ if (typeof xdg === "string" && xdg.length > 0) return xdg;
67
+ const home = env.HOME;
68
+ if (typeof home === "string" && home.length > 0) return path.join(home, ".local", "share");
69
+ return null;
70
+ }
71
+
72
+ /** A JSON object or nothing. Failure to read, parse, or find an object is the SAME answer
73
+ * here — absent — because the caller's next move is identical in all of them: run the
74
+ * installer. Telling a corrupt state file apart from a missing one is the doctor's job. */
75
+ function readJsonObject(file: string): Record<string, unknown> | null {
76
+ try {
77
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
78
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
79
+ ? (parsed as Record<string, unknown>)
80
+ : null;
81
+ } catch {
82
+ // Bounded environment probing, Hard Rule 15's stated exception.
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function isDir(p: string): boolean {
88
+ try {
89
+ return statSync(p).isDirectory();
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function isSymlink(p: string): boolean {
96
+ try {
97
+ return lstatSync(p).isSymbolicLink();
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /** Same DEST the managed launcher will arm from (`scripts/copilot-launch.sh`).
104
+ * `COPILOT_EXTENSIONS_DIR` wins; otherwise `$HOME/.copilot/extensions/<unit>`.
105
+ * String equality, not realpath: the launcher compares with `[ = ]`. */
106
+ function receiveDest(env: NodeJS.ProcessEnv): string | null {
107
+ const override = env.COPILOT_EXTENSIONS_DIR;
108
+ if (typeof override === "string" && override.length > 0) {
109
+ return path.join(override, RECEIVE_UNIT);
110
+ }
111
+ const home = env.HOME;
112
+ if (typeof home !== "string" || home.length === 0) return null;
113
+ return path.join(home, ".copilot", "extensions", RECEIVE_UNIT);
114
+ }
115
+
116
+ function isExecutableFile(p: string): boolean {
117
+ try {
118
+ // `X_OK` for THIS user, which is what `command -v` answers and what Copilot will need.
119
+ // A mode-bit test would call a root-owned 0700 binary executable for everyone.
120
+ accessSync(p, constants.X_OK);
121
+ return statSync(p).isFile();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The statusline command as the shipped adapter resolves it: a value containing a separator is
129
+ * a path and must be executable; a bare name is looked up on PATH. This is a small local
130
+ * search rather than `mux-launch`'s resolver because the import fence keeps `mux-launch` to
131
+ * exactly two importers — a third would be a decision, and this leaf does not need one.
132
+ */
133
+ function commandResolvable(command: string, env: NodeJS.ProcessEnv): boolean {
134
+ if (command.includes("/")) return isExecutableFile(command);
135
+ const raw = env.PATH;
136
+ if (typeof raw !== "string" || raw.length === 0) return false;
137
+ for (const dir of raw.split(path.delimiter)) {
138
+ if (dir.length === 0) continue;
139
+ if (isExecutableFile(path.join(dir, command))) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * The visible-identity axis, step 9 clause 4. The subject is the EFFECTIVE settings the vendor
146
+ * will read, not entwurf's ownership record: a host can carry a correct footer configuration
147
+ * with no install-state (measured on the acceptance host, 2026-08-24 — `doctor-copilot-statusline`
148
+ * reported `settings: configured (resolvable)` / `state: absent` / rc=0), and refusing that host
149
+ * would be refusing a working visible identity because of a missing receipt.
150
+ *
151
+ * The install-state, when present, is checked for the ONE thing it can still contradict: that
152
+ * it manages the very file Copilot reads. A state pointing somewhere else means two settings
153
+ * files disagree about who owns the footer, and which one wins is not decidable from here.
154
+ */
155
+ function visibleIdentityMissing(env: NodeJS.ProcessEnv, data: string): boolean {
156
+ const command = env.COPILOT_STATUSLINE_COMMAND || DEFAULT_STATUSLINE_COMMAND;
157
+ const configured = env.COPILOT_SETTINGS_CONFIG;
158
+ let settingsPath: string;
159
+ if (typeof configured === "string" && configured.length > 0) {
160
+ settingsPath = configured;
161
+ } else {
162
+ const home = env.HOME;
163
+ if (typeof home !== "string" || home.length === 0) return true;
164
+ settingsPath = path.join(home, ".copilot", "settings.json");
165
+ }
166
+ // A symlinked settings file is somebody else's SSOT; the adapter refuses to touch it and
167
+ // this refuses to certify it.
168
+ if (isSymlink(settingsPath)) return true;
169
+ const settings = readJsonObject(settingsPath);
170
+ if (settings === null) return true;
171
+ const statusLine = settings.statusLine;
172
+ if (typeof statusLine !== "object" || statusLine === null || Array.isArray(statusLine)) return true;
173
+ if ((statusLine as Record<string, unknown>).command !== command) return true;
174
+ const footer = settings.footer;
175
+ if (typeof footer !== "object" || footer === null || Array.isArray(footer)) return true;
176
+ // `showCustom` must be exactly true. A truthy string would render nothing.
177
+ if ((footer as Record<string, unknown>).showCustom !== true) return true;
178
+ if (!commandResolvable(command, env)) return true;
179
+
180
+ const state = readJsonObject(path.join(data, "entwurf", "copilot-statusline", "install-state.json"));
181
+ if (state === null) return false; // absent state is not drift — see the doc comment above
182
+ const managed = state.managedSettingsPath;
183
+ if (typeof managed !== "string" || !path.isAbsolute(managed)) return true;
184
+ return path.resolve(managed) !== path.resolve(settingsPath);
185
+ }
186
+
187
+ /**
188
+ * Answer for ONE fresh Copilot launch. `null` means every required capability is in place on
189
+ * this filesystem; anything else is the first missing one, in the order the fresh contract
190
+ * consumes them: be born, hold the hand, be reachable, be visible.
191
+ */
192
+ export function copilotFreshPreflight(env: NodeJS.ProcessEnv = process.env): CopilotPreflightRejectReason | null {
193
+ const data = dataHome(env);
194
+ // With no HOME and no XDG_DATA_HOME there is no place any of these could be installed.
195
+ // Report the FIRST capability rather than inventing a fifth reason for "no home".
196
+ if (data === null) return "copilot-birth-unit-missing";
197
+
198
+ // 1. Birth. The assembly is what the installer bakes and what the birth doctor certifies
199
+ // statically; `hooks.json` is what makes it a hook unit rather than a directory. There is
200
+ // no install-state for this unit — the artifact IS its ownership record.
201
+ const birthUnit = path.join(data, "entwurf", "meta-bridge-copilot", ".assembled", BIRTH_PLUGIN);
202
+ if (!isDir(birthUnit) || !existsSync(path.join(birthUnit, "hooks", "hooks.json"))) {
203
+ return "copilot-birth-unit-missing";
204
+ }
205
+
206
+ // 2. MCP hand. The install-state names the config it owns, so the config path is READ from
207
+ // ownership truth rather than re-derived — a preflight that guessed would refuse a
208
+ // correctly installed host whose config lives somewhere else.
209
+ const mcpState = readJsonObject(path.join(data, "entwurf", "copilot-mcp", "install-state.json"));
210
+ if (mcpState === null || mcpState.serverKey !== MCP_SERVER_KEY) return "copilot-mcp-hand-missing";
211
+ const managedConfigPath = mcpState.managedConfigPath;
212
+ if (typeof managedConfigPath !== "string" || !path.isAbsolute(managedConfigPath)) return "copilot-mcp-hand-missing";
213
+ const servers = readJsonObject(managedConfigPath)?.mcpServers;
214
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return "copilot-mcp-hand-missing";
215
+ // The state can be current while the config drifted (hand-edited, restored from a backup,
216
+ // replaced by another tool). The server key present in the file the CLI actually reads is
217
+ // the fact the first turn depends on.
218
+ if (!Object.hasOwn(servers as Record<string, unknown>, MCP_SERVER_KEY)) return "copilot-mcp-hand-missing";
219
+
220
+ // 3. Receive. The same facts `scripts/copilot-launch.sh` checks — unit name, recorded
221
+ // path, DEST equality against this env's extensions root, then the entry file.
222
+ const recvState = readJsonObject(path.join(data, "entwurf", "copilot-receive", "install-state.json"));
223
+ if (recvState === null || recvState.unit !== RECEIVE_UNIT) return "copilot-receive-unit-missing";
224
+ const recvPath = recvState.path;
225
+ if (typeof recvPath !== "string" || !path.isAbsolute(recvPath)) return "copilot-receive-unit-missing";
226
+ // Mirror the launcher's path-mismatch predicate PRE-MUTATION. A state that names a
227
+ // real unit in a different extensions root than this env will scan still opens a
228
+ // window today if we only check that the files exist — then `entwurf copilot`
229
+ // refuses inside it and the caller holds a launch receipt for a dead sibling.
230
+ const dest = receiveDest(env);
231
+ if (dest === null || recvPath !== dest) return "copilot-receive-unit-missing";
232
+ if (!isDir(recvPath) || !existsSync(path.join(recvPath, "extension.mjs"))) return "copilot-receive-unit-missing";
233
+
234
+ // 4. Visible identity.
235
+ if (visibleIdentityMissing(env, data)) return "copilot-visible-identity-missing";
236
+
237
+ return null;
238
+ }
239
+
240
+ /** Repair text, one line per reason. A reason a caller cannot act on is a reason they will
241
+ * guess about — each names the exact command that installs the missing capability. */
242
+ export const COPILOT_PREFLIGHT_HINT: Record<CopilotPreflightRejectReason, string> = {
243
+ "copilot-birth-unit-missing":
244
+ "the Copilot BIRTH plugin is not installed here, so the sibling would mint no record and its callback would carry no garden id — run: entwurf install-copilot-bridge",
245
+ "copilot-mcp-hand-missing":
246
+ "the entwurf-bridge MCP server is not registered in the Copilot config this host owns, so the callback tool would not exist in that session — run: entwurf install-copilot-mcp",
247
+ "copilot-receive-unit-missing":
248
+ "the Copilot RECEIVER extension is not installed here, so the sibling could call home but nothing could ever be delivered to it — run: entwurf install-copilot-receive",
249
+ "copilot-visible-identity-missing":
250
+ "Copilot's custom footer is not configured to entwurf's resolvable statusline command (or an install-state manages a different settings file), so the sibling's garden id would be visible nowhere — run: entwurf doctor-copilot-statusline, then entwurf install-copilot-statusline",
251
+ };
@@ -48,8 +48,16 @@ import {
48
48
  } from "./meta-session.ts";
49
49
  import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/adapter.ts";
50
50
 
51
- /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
52
- export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity"];
51
+ /** Every native backend that mints a garden-id from its own hook and writes a sender marker.
52
+ *
53
+ * WRITER AND READER OPEN TOGETHER OR NOT AT ALL. A backend listed here whose hook writes no
54
+ * marker costs one wasted directory read; a backend whose hook writes a marker but is absent
55
+ * here is INVISIBLE — the bridge holds the owner pid, never looks in that directory, and the
56
+ * citizen's sends are refused as anonymous for a reason nothing in the log names. That was
57
+ * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
58
+ * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
59
+ * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
60
+ export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot"];
53
61
 
54
62
  /** A marker that passed BOTH guards, together with the record that vouches for it. */
55
63
  export interface TrustedMetaSender {
@@ -12,8 +12,8 @@
12
12
  * 1. RECORD functions + types (mint / serialize / parse / certifyActiveStore /
13
13
  * decideUpsert), the backend-agnostic authority. Pure beyond an injected
14
14
  * `now`; backend capability (wakeMode/deliveryLevel) comes from the packaged
15
- * registry via a cached fs read (loadMetaCapabilityRegistry) see that seam
16
- * below.
15
+ * registry, re-read on every load (loadMetaCapabilityRegistry holds NO cache, so a
16
+ * grade moved on disk is visible to a long-lived process) — see that seam below.
17
17
  * 2. The thin FS-BOUND STORE (step 3): `upsertMetaSession` wraps the pure core
18
18
  * (readdir → `certifyActiveStore` → `decideUpsert` → atomic write) with the real
19
19
  * filesystem. It lives in this module (not a sibling `*-store.ts`) on purpose:
@@ -74,12 +74,14 @@ export class MetaRecordError extends Error {
74
74
  // ---------------------------------------------------------------------------
75
75
 
76
76
  /**
77
- * The three native meta-bridge backends, declared from the start so the
78
- * per-backend adapter seam is forced (a different native layout each — that
79
- * difference is the whole reason for a thin adapter). Discriminator field on
80
- * every record.
77
+ * Native meta-bridge backends. Discriminator on every record. Adding a member
78
+ * is a sibling-reader redeploy (`./run.sh install-meta-bridge` and the matching
79
+ * sibling install), not a store archive: writers certify the whole active store
80
+ * against THIS set, so a deployed plugin still carrying the old set refuses
81
+ * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
82
+ * stale deployed writer — run it after the change.
81
83
  */
82
- export const META_BACKENDS = ["claude-code", "antigravity", "codex"] as const;
84
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot"] as const;
83
85
  export type MetaBackend = (typeof META_BACKENDS)[number];
84
86
 
85
87
  /**
@@ -127,6 +129,47 @@ export const META_BACKEND_DESCRIPTORS: Record<MetaBackend, MetaBackendDescriptor
127
129
  deliveryLevel: "D6",
128
130
  nativeIdLabel: "threadId",
129
131
  },
132
+ // SELF-FETCH citizen since #82 RAIL 5. Copilot CLI 1.0.80 runs our plugin hook (birth,
133
+ // garden address, who-sent) AND forks our first-party extension, whose `joinSession()`
134
+ // holds an `fs.watch` on the garden mailbox signal and can `session.send()` a doorbell
135
+ // into an idle session. That is the same shape Claude's mailbox has, reached through a
136
+ // different vendor surface. Hence:
137
+ // wakeMode self-fetch — the doorbell announces, the model drains its own inbox with
138
+ // `entwurf_inbox_read`, and THAT read is the receipt. The extension never
139
+ // injects the body, so this label promises exactly what happens. It was
140
+ // `direct-inject` while no doorbell existed (measured 2026-08-20: no
141
+ // `FileChanged`, `asyncRewake` or `watchPaths` in the bundle) — that
142
+ // absence was never a claim about the vendor's other surfaces, and the
143
+ // extension rail is the one it missed. Copilot is still NOT a
144
+ // `nativePushSupported` backend and has no native-push adapter.
145
+ // D6 the PRODUCT grade, earned by the managed LIVE acceptance of 2026-08-23
146
+ // (garden 20260823T181316-d9f6ba, native 20fe30c8-b2bc-4600-91a0-8a409131be51,
147
+ // CLI 1.0.80): the receive log joins that native id and arms that garden id,
148
+ // the mailbox state stamps lastEnqueuedAt 09:23:41.235Z and lastReadAt
149
+ // 09:23:56.480Z, and the model answered on the SAME record/native/gid chain.
150
+ // This is still the PRODUCT grade and not the raw probe's: the earlier
151
+ // transport probe was evidence about the mechanism, and it did not move this
152
+ // number — a managed round trip did.
153
+ // D7 is PARTIAL, deliberately: the reply and the read receipt were observed,
154
+ // but the completion taxonomy as a whole and any long-haul operation were
155
+ // not, and the reply envelope itself reaches this comment as an INHERITED
156
+ // fact (it was not re-read from a transcript when this was written). D8 is
157
+ // unproven and D3 (managed second-session isolation) is pending — its
158
+ // decisive log was lost to a scratch cleanup before it could be preserved.
159
+ // The route stays fail-closed on both ends: with no armed receiver marker
160
+ // every dispatch is still refused `mailbox-undeliverable`, so replyability
161
+ // is a fact about a live marker and never a constant of this backend.
162
+ // sessionId the native join key, measured to be ONE id across all three surfaces:
163
+ // the hook envelope, `record.nativeSessionId`, and the SDK's
164
+ // `session.sessionId` (record 20260823T112003-9d069a ==
165
+ // `ARMED sessionId=4fc16d8d-473d-4258-a1fd-f99d3cb375e9`, CLI 1.0.80).
166
+ // That agreement is what lets the extension bind its arm to the record.
167
+ copilot: {
168
+ backend: "copilot",
169
+ wakeMode: "self-fetch",
170
+ deliveryLevel: "D6",
171
+ nativeIdLabel: "sessionId",
172
+ },
130
173
  };
131
174
 
132
175
  // ---------------------------------------------------------------------------
@@ -140,9 +183,9 @@ export function requireNonEmptyString(value: unknown, field: string): string {
140
183
  return value;
141
184
  }
142
185
 
143
- /** Validate the 3-backend NATIVE bridge axis (sender/receiver markers, capability
186
+ /** Validate the native-bridge backend axis (sender/receiver markers, capability
144
187
  * drift guard). Not a record-schema validator: identity records take
145
- * `requireCitizenBackend` (which admits `pi`). Markers stay native-3 because the
188
+ * `requireCitizenBackend` (which admits `pi`). Markers stay on this set because the
146
189
  * pi adapter carries its record-established garden id into children via env rather
147
190
  * than using the native-hook pid marker rail. */
148
191
  export function requireBackend(value: unknown): MetaBackend {
@@ -210,7 +253,7 @@ function isoNow(now: Date): string {
210
253
  export const META_SCHEMA_VERSION_V3 = 3 as const;
211
254
 
212
255
  /** Every backend admitted by the one V3 record-citizen schema. */
213
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "pi"] as const;
256
+ export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "pi"] as const;
214
257
  export type MetaCitizenBackend = (typeof META_CITIZEN_BACKENDS)[number];
215
258
 
216
259
  /**
@@ -655,19 +698,30 @@ export function metaCapabilitiesFilePath(): string {
655
698
  // the rest of `delivery{}`, so today the registry is the sole home.
656
699
  // ---------------------------------------------------------------------------
657
700
 
658
- /** Memoized packaged registry; the file is immutable at runtime, so caching is honest (not stateful lying). */
659
- let cachedMetaCapabilities: MetaCapabilityRegistry | null = null;
660
-
661
701
  /**
662
- * Load + memoize the packaged capability registry — the live source of backend
663
- * honesty metadata as of 3D-3. A missing/corrupt file throws (the registry is a
664
- * packaged invariant; check-pack guarantees its presence).
702
+ * Load the packaged capability registry — the live source of backend honesty metadata
703
+ * as of 3D-3. A missing/corrupt file throws (the registry is a packaged invariant;
704
+ * check-pack guarantees its presence).
705
+ *
706
+ * NO CACHE, deliberately. This used to memoize into a process-lifetime singleton on the
707
+ * argument that "the file is immutable at runtime". That argument was false for the one
708
+ * process that matters: the entwurf-bridge MCP child lives as long as its harness
709
+ * session, so a grade or wakeMode moved by an install/upgrade was invisible to every
710
+ * already-running dispatcher until the operator restarted it — and nothing anywhere
711
+ * said so. The symptom is a citizen answering with last week's capability, which is the
712
+ * silent-wrong-answer class this repo refuses; "restart your session" is an instruction
713
+ * to work around a defect, not a fix for it (#82 RAIL 7).
714
+ *
715
+ * The subtraction is the whole repair, and it is deliberately not an invalidation
716
+ * scheme. Stat-based invalidation (mtime+size) cannot see an atomic same-size,
717
+ * same-timestamp replacement, so it would trade a certain staleness bug for an
718
+ * intermittent one; content hashing would have to read the file anyway. The registry is
719
+ * a few hundred bytes and both production callers — the v2 decider and production
720
+ * deliverability — reach it once per dispatch, not in a loop, so reading it there is
721
+ * cheaper than being wrong.
665
722
  */
666
723
  export function loadMetaCapabilityRegistry(): MetaCapabilityRegistry {
667
- if (cachedMetaCapabilities === null) {
668
- cachedMetaCapabilities = parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
669
- }
670
- return cachedMetaCapabilities;
724
+ return parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
671
725
  }
672
726
 
673
727
  /**
@@ -928,15 +982,18 @@ export function certifyActiveStoreDir(dir: string): ActiveStoreCertification & {
928
982
 
929
983
  /**
930
984
  * The refusal an uncertifiable ACTIVE store earns, in the words both the runtime
931
- * writers and the install doctor use. One prescription, both invocation forms —
932
- * every defect kind (previous generation, corruption, drift, duplicate, symlink)
933
- * is answered by archiving the generation, so there is nothing to branch on.
985
+ * writers and the install doctor use. Rotten records (previous generation,
986
+ * corruption, drift, duplicate, symlink) are answered by archiving. An
987
+ * unknown-backend defect is a stale reader, not a rotten store the listed
988
+ * defect says so; this headline tells the operator to distinguish. No branch:
989
+ * both sentences always print.
934
990
  */
935
991
  export function activeStoreRefusal(cert: ActiveStoreCertification & { dir: string }, shown = 3): string {
936
992
  const headline =
937
993
  `meta-record store ${cert.dir} holds ${cert.defects.length} entry/entries this generation cannot certify — ` +
938
994
  `refusing to write (the active store is v3-only and carries no cross-generation continuity). ` +
939
- `Archive the generation and open a fresh one with ${FRESH_CUT_PRESCRIPTION}.`;
995
+ `Archive the generation and open a fresh one with ${FRESH_CUT_PRESCRIPTION}. ` +
996
+ `If a listed defect says "backend" must be one of this reader's known set, the reader is stale — redeploy the sibling unit (the matching doctor names the install verb); do not archive those records.`;
940
997
  // `shown = 0` is for a caller that ALREADY printed every cause per entry (the
941
998
  // store-doctor): repeating them here doubles the wall on a large previous
942
999
  // generation, which is exactly the aggregation lesson F8 taught. The count and
@@ -1608,12 +1665,28 @@ export function readMetaSenderMarker(opts: ReadMetaSenderMarkerOptions): MetaSen
1608
1665
  // ── meta-receiver presence marker (SE-2 active-receiver signal) ──────────────
1609
1666
 
1610
1667
  /**
1611
- * The arm-capable hook events. Only these can emit watchPaths (and therefore arm
1612
- * the idle-wake), so only these write a receiver presence marker. UserPromptSubmit
1613
- * is deliberately absent: it can backfill the record but cannot re-arm the watch, so
1614
- * it must NOT mint or refresh an "active receiver" claim it cannot back.
1668
+ * The arm-capable events, across every backend that can arm an idle wake.
1669
+ *
1670
+ * CLAUDE (`session-start`, `cwd-changed`, `file-changed`): only these hook events can
1671
+ * emit watchPaths (and therefore arm the idle-wake), so only these write a receiver
1672
+ * presence marker. UserPromptSubmit is deliberately absent: it can backfill the record
1673
+ * but cannot re-arm the watch, so it must NOT mint or refresh an "active receiver"
1674
+ * claim it cannot back.
1675
+ *
1676
+ * COPILOT (`extension-join`): the same rule, one backend over. Copilot's arm is not a
1677
+ * hook at all — the CLI forks a first-party extension and the extension's own
1678
+ * `joinSession()` is what puts a live `fs.watch` on the garden mailbox signal and gives
1679
+ * it a `session.send()` channel to ring. The membership test is unchanged and is about
1680
+ * capability, not event vocabulary: this provenance is admitted because the process that
1681
+ * writes it holds the watch and can start a turn on an idle session. A Copilot hook event
1682
+ * has neither, which is why the birth hook still arms nothing (#82 RAIL 5).
1615
1683
  */
1616
- export const META_RECEIVER_ARM_PROVENANCES = ["session-start", "cwd-changed", "file-changed"] as const;
1684
+ export const META_RECEIVER_ARM_PROVENANCES = [
1685
+ "session-start",
1686
+ "cwd-changed",
1687
+ "file-changed",
1688
+ "extension-join",
1689
+ ] as const;
1617
1690
  export type MetaReceiverArmProvenance = (typeof META_RECEIVER_ARM_PROVENANCES)[number];
1618
1691
 
1619
1692
  function requireArmProvenance(value: unknown): MetaReceiverArmProvenance {
@@ -47,6 +47,11 @@
47
47
 
48
48
  import { randomBytes } from "node:crypto";
49
49
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
50
+ import {
51
+ COPILOT_PREFLIGHT_HINT,
52
+ type CopilotPreflightRejectReason,
53
+ copilotFreshPreflight,
54
+ } from "./copilot-fresh-preflight.ts";
50
55
  import {
51
56
  assertLaunchTarget,
52
57
  LaunchPreconditionError,
@@ -66,27 +71,47 @@ import {
66
71
  type WindowHandle,
67
72
  } from "./mux-placement.ts";
68
73
 
69
- /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
70
- * not a config entry. */
71
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code"] as const;
74
+ /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
75
+ * not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract. */
76
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot"] as const;
72
77
  export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
73
78
 
74
- /** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
79
+ /**
80
+ * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
75
81
  * than a compiled-in location: the official binary is whatever the operator's environment gives
76
- * when they type the name. */
82
+ * when they type the name.
83
+ *
84
+ * `copilot` resolves `entwurf`, NOT the vendor CLI, and that is the contract rather than a
85
+ * convenience. Step 9 clause 1 requires ONE fixed MANAGED runtime path, and a bare `copilot`
86
+ * is not one: it would start without the `COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS` token
87
+ * whose absence skips the extension scan SILENTLY, so the sibling would look launched and could
88
+ * never be delivered to. `entwurf copilot` is the accepted managed invocation that owns that
89
+ * flag, its recursion fence and its receiver precondition; fresh call reaches Copilot only
90
+ * through it. The cost is named: a Copilot fresh call needs a current `entwurf` on PATH, the
91
+ * way a pi fresh call needs `pi`.
92
+ */
77
93
  export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
78
94
  pi: "pi",
79
95
  "claude-code": "claude",
96
+ copilot: "entwurf",
80
97
  };
81
98
 
82
99
  /**
83
100
  * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
84
- * capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
85
- * bridge under its namespaced name. Naming the wrong one costs the whole first turn.
101
+ * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
102
+ * name that harness composes. Naming the wrong one costs the whole first turn.
103
+ *
104
+ * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
105
+ * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
106
+ * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
107
+ * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
108
+ * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
109
+ * sibling's spelling (`docs/adding-a-harness.md` step 5).
86
110
  */
87
111
  export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
88
112
  pi: "entwurf_v2",
89
113
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
114
+ copilot: "entwurf-bridge-entwurf_v2",
90
115
  };
91
116
 
92
117
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
@@ -112,12 +137,41 @@ export function isSafeFreshCallModel(model: string): boolean {
112
137
  * Flag-first submitted no message; Pi rejects the equals form for `--model`.
113
138
  * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
114
139
  * for allowedTools is variadic and eats the prompt as an option value.
140
+ * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
141
+ * `--model`, value as two tokens, and the policy as the explicit `--yolo`
142
+ * token. Measured from `copilot --help` (1.0.80).
115
143
  *
116
- * Both failures looked identical from outside: window open, record and socket minted, no turn.
144
+ * Both pi/claude failures looked identical from outside: window open, record and socket minted,
145
+ * no turn.
117
146
  *
118
- * The equals form is NOT a permission guarantee — on the measured host the tool was already
119
- * permitted, so the option's effect was unobservable. What was observed is that it does no harm
120
- * to the argv. Permission stays a documented host precondition.
147
+ * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
148
+ * the tool was already permitted, so the option's effect was unobservable. What was observed is
149
+ * that it does no harm to the argv. Permission stays a documented host precondition there.
150
+ *
151
+ * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
152
+ * its argv facts are load-bearing:
153
+ *
154
+ * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
155
+ * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
156
+ * `scripts/copilot-launch.sh`.
157
+ * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
158
+ * which would close the window on a sibling that is supposed to stay open and be delivered
159
+ * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
160
+ * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
161
+ * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
162
+ * fresh composition to state its model and permission policy explicitly, never to rely
163
+ * invisibly on someone else's default.
164
+ *
165
+ * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
166
+ * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
167
+ * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
168
+ * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
169
+ * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
170
+ * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
171
+ * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
172
+ * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
173
+ * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
174
+ * example; it is a measured vendor fact even though this argv no longer uses it.
121
175
  */
122
176
  export function buildBackendArgs(backend: FreshCallBackend, prompt: string, model: string): string[] {
123
177
  switch (backend) {
@@ -125,6 +179,8 @@ export function buildBackendArgs(backend: FreshCallBackend, prompt: string, mode
125
179
  return [prompt, "--entwurf-control", "--model", model];
126
180
  case "claude-code":
127
181
  return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
182
+ case "copilot":
183
+ return ["copilot", "--interactive", prompt, "--model", model, "--yolo"];
128
184
  }
129
185
  }
130
186
 
@@ -167,6 +223,7 @@ export type FreshCallRejectReason =
167
223
  | PlacementRejectReason
168
224
  | LaunchRejectReason
169
225
  | TmuxCwdRejectReason
226
+ | CopilotPreflightRejectReason
170
227
  | "caller-identity-unavailable"
171
228
  | "model-empty"
172
229
  | "model-invalid"
@@ -270,6 +327,17 @@ export function freshCall(
270
327
  throw err;
271
328
  }
272
329
 
330
+ // Backend capability, still PRE-MUTATION (step 9 clause 3). It runs AFTER the runtime is
331
+ // proven, because "entwurf is not on PATH" is the more fundamental answer — telling an
332
+ // operator to run `entwurf install-copilot-bridge` when they have no `entwurf` at all sends
333
+ // them to the wrong repair. It runs BEFORE placement for the reason this whole ordering
334
+ // exists: a refusal here cannot leave a window behind, while the launcher's own equivalent
335
+ // check (receiver only, manual `entwurf copilot`) necessarily runs after one is open.
336
+ if (params.backend === "copilot") {
337
+ const missing = copilotFreshPreflight(env);
338
+ if (missing) return { ok: false, reason: missing };
339
+ }
340
+
273
341
  const inspected = inspectPlacement(env);
274
342
  if (!inspected.ok) return { ok: false, reason: inspected.reason };
275
343
  const placement = inspected.placement;
@@ -319,6 +387,9 @@ export function freshCall(
319
387
  /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
320
388
  * they will guess about. */
321
389
  const REJECT_HINT: Record<FreshCallRejectReason, string> = {
390
+ // The Copilot capability reasons keep their repair text on the leaf that decides them, so
391
+ // the sentence an operator reads cannot drift away from the predicate that produced it.
392
+ ...COPILOT_PREFLIGHT_HINT,
322
393
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
323
394
  "anchor-malformed": "TMUX_PANE is not a native pane id",
324
395
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",