@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,323 @@
1
+ /**
2
+ * meta-bridge-hook-copilot — the GitHub Copilot CLI native-session BIRTH entry (#82).
3
+ *
4
+ * WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT
5
+ *
6
+ * It mints, then names the minted citizen as this host's SENDER:
7
+ *
8
+ * stdin {sessionId|session_id, cwd} (Copilot hook envelope, either shape)
9
+ * -> upsertMetaSession(copilot) idempotent create/attach the record
10
+ * -> gardenId the session's garden address
11
+ * -> writeMetaSenderMarker(ppid) who-sent join for this host's MCP children
12
+ *
13
+ * No receiver marker, no mailbox arm, no watchPaths — and that is still the contract now
14
+ * that Copilot HAS a doorbell (#82 RAIL 5). The reason changed, the rule did not. A
15
+ * receiver marker is a claim that a LIVE process holds a watch, and the process that holds
16
+ * Copilot's watch is the forked extension (`pi/copilot-receive/`), not this hook. Minted
17
+ * here it would name the Copilot host pid as the owner of a watch that pid does not hold,
18
+ * and the citizen would read deliverable, wired to nothing, for as long as the TUI stayed
19
+ * open. Arming belongs to the watcher; birth belongs here.
20
+ *
21
+ * THE SENDER MARKER IS A DIFFERENT FACT, AND THIS UNIT USED TO CONFLATE THEM. Until
22
+ * #82 RAIL 5b this file wrote no marker of EITHER kind, and gave the doorbell's
23
+ * absence as the reason for both. That reasoning is only valid for the receiver:
24
+ *
25
+ * receiver marker = "a live session is ARMED to be woken" -> needs a doorbell
26
+ * sender marker = "who owns this MCP child process" -> needs a shared parent
27
+ *
28
+ * `lib/meta-sender-identity.ts` states the sender join in one line — the hook writes a
29
+ * marker keyed by ITS parent pid, the bridge child looks one up under its own parent,
30
+ * and that shared ancestor IS the join key. Nothing in it consults a doorbell. So a
31
+ * Copilot citizen with the MCP hand installed could read (`entwurf_peers`) but never
32
+ * send: the bridge held the owner pid and found no marker to look up. Writing one is
33
+ * what closes that, and it changes exactly one thing for the receiver of a message —
34
+ * it learns WHO sent it.
35
+ *
36
+ * IT DOES NOT MAKE THIS CITIZEN REPLYABLE, and must never be described as if it did.
37
+ * `mcp/entwurf-bridge/src/index.ts` picks the reply rail from `nativePushSupported`;
38
+ * copilot is not native-push, so it lands in the self-fetch domain, where `replyable`
39
+ * comes from a RECEIVER marker written by the extension when it joins the session. A
40
+ * citizen that is born but whose receiver has not armed is `replyable: false` with a real
41
+ * garden identity — the honest answer, and it becomes true on its own once the extension
42
+ * binds. What this file must never do is anticipate that answer.
43
+ *
44
+ * WHY A SECOND ENTRY FILE. `meta-bridge-hook.ts` hardcodes `backend: "claude-code"`
45
+ * at mint and then does the marker/watch work above, and two gates pin that unit to
46
+ * Claude's exec form and its four PascalCase events. Copilot's schema has no `args`
47
+ * key at all, its exec form is a single `exec` string and its events are camelCase,
48
+ * so the two units cannot share a manifest — and once the manifests are separate,
49
+ * sharing an entry would only buy a runtime branch that the gates would then have to
50
+ * unpick. The shared half lives where it belongs: `lib/meta-session.ts`.
51
+ *
52
+ * BIRTH IS ON THE FIRST PROMPT, NOT AT SESSION OPEN. Measured: opening the TUI
53
+ * registers a session and fires NO hook (11:17:19.920Z registration, three idle
54
+ * minutes, zero hook lines, `Session: 0 AIC used`). The first prompt then fires
55
+ * `userPromptSubmitted` (11:20:29.476Z) -> `sessionStart` (11:20:32.102Z). Both are
56
+ * wired to this entry and the upsert is idempotent, so the earlier of the two mints
57
+ * and the later attaches. Do not describe a Copilot citizen as born when its window
58
+ * opens; it is born when it is first spoken to.
59
+ *
60
+ * ENVELOPE SHAPE — TWO ACCEPTED, ON PURPOSE. Copilot ships a Claude-compat envelope
61
+ * translator: a Claude-form hook declaration receives `{hook_event_name, session_id,
62
+ * cwd, ...}` (measured), while its native declarations carry `{sessionId, timestamp,
63
+ * cwd, source, ...}`. This unit declares the NATIVE form, and which of the two shapes
64
+ * a native declaration puts on stdin was not measured. Reading both is not sloppiness
65
+ * about which is true — it is refusing to bet a citizen's existence on an unmeasured
66
+ * key name when the two shapes are trivially distinguishable and the log line records
67
+ * which one actually arrived. That log line is how the doctor turns the guess into a
68
+ * measurement on the first real session.
69
+ *
70
+ * FAILURE POLICY, inherited from the Claude unit: BEST-EFFORT + LOG. Never scream
71
+ * into the operator's terminal, never block the turn. On any error, append a level-
72
+ * tagged line to `<pi-agent-dir>/meta-bridge-hook.log` and exit 0 with `{}` on
73
+ * stdout. The fail-loud surface is the doctor, which reads that log.
74
+ *
75
+ * LAUNCH: never invoked directly by Copilot. `hooks/hooks.json` declares
76
+ * `exec: <baked abspath of scripts/copilot-hook-launch.sh>` and that launcher `exec`s
77
+ * this payload. See copilot-hook-launch.sh for why it cannot be the Claude launcher.
78
+ */
79
+
80
+ import * as fs from "node:fs";
81
+ import * as path from "node:path";
82
+ import {
83
+ defaultMetaSessionsDir,
84
+ isPlausibleOwnerPid,
85
+ upsertMetaSession,
86
+ writeMetaSenderMarker,
87
+ } from "./lib/meta-session.ts";
88
+
89
+ /**
90
+ * The launch-provenance handshake, held as LOCAL literals on purpose.
91
+ *
92
+ * The Claude payload declares the same pair privately, and `check-meta-receiver-marker`
93
+ * asserts the literal is present in THAT source — so hoisting the two strings into a
94
+ * shared module would drag a sibling rail's production file and its gate into this lane
95
+ * for no behavioral gain (PM decision, 2026-08-21).
96
+ *
97
+ * This is not an unguarded copy. The value already exists on both sides of a shell/TS
98
+ * boundary anyway (`copilot-hook-launch.sh` exports it), and `check-copilot-birth-hook`
99
+ * fires the REAL assembled launcher, so a drift between the two spellings shows up as a
100
+ * refused marker in that gate rather than as a silent mismatch.
101
+ */
102
+ const META_HOOK_LAUNCH_ENV = "ENTWURF_META_HOOK_LAUNCH";
103
+ const META_HOOK_LAUNCH_TOKEN = "hook-launch/v1";
104
+
105
+ type LogLevel = "INFO" | "WARN" | "ERROR";
106
+
107
+ /** Append a best-effort diagnostic line; swallow even its own failure. Same log file
108
+ * and same LEVEL vocabulary as the Claude unit, so one doctor grep covers both. */
109
+ function logLine(level: LogLevel, message: string): void {
110
+ try {
111
+ const file = path.join(path.dirname(defaultMetaSessionsDir()), "meta-bridge-hook.log");
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ fs.appendFileSync(file, `${new Date().toISOString()} ${level} [copilot] ${message}\n`);
114
+ } catch {
115
+ /* logging is best-effort; a broken log must not break the session */
116
+ }
117
+ }
118
+
119
+ /** Emit the neutral hook response and leave. Copilot has no watchPaths to arm, so
120
+ * there is never anything else to say. */
121
+ function emit(): never {
122
+ process.stdout.write("{}\n");
123
+ process.exit(0);
124
+ }
125
+
126
+ /**
127
+ * The envelope's two accepted shapes, reduced to the two fields birth needs. The
128
+ * event label is diagnostic only — mint does not branch on it, because both wired
129
+ * events mean the same thing here (the session exists and has spoken once).
130
+ */
131
+ interface CopilotBirthEnvelope {
132
+ sessionId: string;
133
+ cwd: string;
134
+ /** Which shape actually arrived — recorded so the doctor can retire the guess. */
135
+ shape: "copilot-native" | "claude-compat" | "agreeing-both";
136
+ eventLabel: string;
137
+ }
138
+
139
+ /**
140
+ * Reduce either envelope shape to the two fields birth needs, or refuse.
141
+ *
142
+ * ACCEPTING TWO SHAPES IS NOT THE SAME AS PREFERRING ONE. `sessionId ?? session_id`
143
+ * would silently pick a winner when the two disagree, and a disagreement is exactly
144
+ * the case worth refusing: it means the compat translator produced two different
145
+ * identities for one session, or something hand-built the envelope. A record minted
146
+ * from the loser would be a citizen no live session can be joined back to. So both
147
+ * present + equal is fine (`agreeing-both`); both present + different is a REFUSAL
148
+ * (cross-review, terra, 2026-08-20).
149
+ *
150
+ * `cwd` has NO process.cwd() fallback, unlike the Claude entry. There the hook runs
151
+ * in the session's own cwd, so the fallback is the same value by another route; here
152
+ * the record is the ONLY thing this unit ever writes and a guessed cwd would be a
153
+ * fact nothing later can correct. Both measured Copilot shapes carry `cwd`.
154
+ */
155
+ export function readBirthEnvelope(env: Record<string, unknown>): CopilotBirthEnvelope | { refusal: string } {
156
+ const nativeRaw = env.sessionId;
157
+ const compatRaw = env.session_id;
158
+ const native = typeof nativeRaw === "string" ? nativeRaw : "";
159
+ const compat = typeof compatRaw === "string" ? compatRaw : "";
160
+ if (nativeRaw !== undefined && native.length === 0)
161
+ return { refusal: "sessionId present but not a non-empty string" };
162
+ if (compatRaw !== undefined && compat.length === 0)
163
+ return { refusal: "session_id present but not a non-empty string" };
164
+ if (native && compat && native !== compat) {
165
+ return { refusal: `sessionId and session_id disagree (${native} vs ${compat})` };
166
+ }
167
+ const sessionId = native || compat;
168
+ if (!sessionId) return { refusal: "no sessionId/session_id" };
169
+ const shape = native && compat ? "agreeing-both" : native ? "copilot-native" : "claude-compat";
170
+ if (typeof env.cwd !== "string" || env.cwd.length === 0) return { refusal: "cwd missing or not a non-empty string" };
171
+ const cwd = env.cwd;
172
+ const eventLabel =
173
+ typeof env.hook_event_name === "string" && env.hook_event_name.length > 0
174
+ ? env.hook_event_name
175
+ : typeof env.source === "string" && env.source.length > 0
176
+ ? `native(source=${env.source})`
177
+ : "unlabeled";
178
+ return { sessionId, cwd, shape, eventLabel };
179
+ }
180
+
181
+ /**
182
+ * Which pid does a sender marker written here belong to — or NONE.
183
+ *
184
+ * `copilot-hook-launch.sh` `exec`s this payload, so on the shipped path this process IS
185
+ * the launcher and its parent is the Copilot host that ran the hook. Two things that
186
+ * form cannot promise are asked anyway, and both fail CLOSED:
187
+ *
188
+ * 1. LAUNCH PROVENANCE. `process.ppid` only names the Copilot host when this payload
189
+ * was reached THROUGH the shipped launcher. A session still holding an older cached
190
+ * hook command, or a hand-run of the entry file, has some other parent — so the
191
+ * launcher stamps a token and this reads it. No token, no claim.
192
+ * 2. A PLAUSIBLE LIVE PARENT. `isPlausibleOwnerPid` is the predicate shared with every
193
+ * marker writer, reader and the generation cut. A marker naming init outlives every
194
+ * process that could refute it, and one such leftover once blocked a host's
195
+ * fresh-cut until it was deleted by hand (#53 A).
196
+ *
197
+ * Failing closed costs only who-sent, which this backend did not have five minutes ago,
198
+ * and the doctor reads the WARN. Claiming a transient wrapper as the owner would instead
199
+ * attribute a citizen's messages to a pid that never sent them.
200
+ *
201
+ * MEASUREMENT STATE OF THE JOIN ITSELF. `[측정]` Closed end to end on Copilot CLI 1.0.80,
202
+ * 2026-08-21, through the PRODUCTION events this unit wires: a live session's marker owner
203
+ * was the running Copilot native process, that same pid was the parent of the running
204
+ * entwurf MCP child, and a send from that session arrived carrying its own garden id with
205
+ * `origin: "meta-session"`. Marker owner == native host == MCP child's parent is therefore
206
+ * an observed fact on this backend, not an assumption inherited from the other two.
207
+ *
208
+ * The claim is still BOUNDED, and the bound is the vendor version: one CLI release is not a
209
+ * promise about the next, and this join is the only thing standing between a marker and the
210
+ * child that must find it. That is why the success line below prints the owner pid — it
211
+ * keeps the join re-checkable from the log alone, with no probe to re-run.
212
+ */
213
+ /**
214
+ * Arm who-sent for this host's MCP children, or say in the log why it could not be.
215
+ *
216
+ * BEST-EFFORT + LOG, exactly like the mint above it: a Copilot turn must never be broken
217
+ * or spammed by our bookkeeping, so nothing here throws and nothing reaches the terminal.
218
+ * The three outcomes get three distinct tokens because they need three different fixes,
219
+ * and the doctor greps this log:
220
+ *
221
+ * `sender marker <pid> -> <gid>` armed. The pid is printed because it is the join key
222
+ * AND the production-event measurement that is still
223
+ * open (see resolveCopilotHookOwnerPid).
224
+ * `sender-marker-refused` we declined to claim an owner: no launch provenance
225
+ * (reinstall + restart that session) or an implausible
226
+ * parent (a reparented orphan). Not an error — a
227
+ * fail-closed answer, so WARN.
228
+ * `sender-marker-failed` we tried and the write itself broke (permissions, a
229
+ * full disk, a refused owner pid). ERROR.
230
+ *
231
+ * Either non-success costs only who-sent: the citizen still exists, still appears in
232
+ * `entwurf_peers`, and can still be addressed BY others. Only its own outbound sends fall
233
+ * back to the bridge's default refusal.
234
+ */
235
+ function writeCopilotSenderMarker(gardenId: string, envelope: CopilotBirthEnvelope): void {
236
+ const ownerPid = resolveCopilotHookOwnerPid();
237
+ if (ownerPid === null) {
238
+ logLine(
239
+ "WARN",
240
+ process.env[META_HOOK_LAUNCH_ENV] !== META_HOOK_LAUNCH_TOKEN
241
+ ? `sender-marker-refused garden=${gardenId}: exec-launch provenance missing (${META_HOOK_LAUNCH_ENV}); this hook was NOT reached through copilot-hook-launch.sh, so its parent is not a trustworthy owner. Reinstall the Copilot bridge and RESTART that session. This citizen exists but cannot send (event=${envelope.eventLabel})`
242
+ : `sender-marker-refused garden=${gardenId}: parent pid ${process.ppid} is not a plausible owner (reparented orphan?); this citizen exists but cannot send (event=${envelope.eventLabel})`,
243
+ );
244
+ return;
245
+ }
246
+ try {
247
+ writeMetaSenderMarker({
248
+ backend: "copilot",
249
+ gardenId,
250
+ nativeSessionId: envelope.sessionId,
251
+ cwd: envelope.cwd,
252
+ ownerPid,
253
+ });
254
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${envelope.eventLabel})`);
255
+ } catch (err) {
256
+ logLine(
257
+ "ERROR",
258
+ `sender-marker-failed pid=${ownerPid} garden=${gardenId}: ${err instanceof Error ? err.message : String(err)}`,
259
+ );
260
+ }
261
+ }
262
+
263
+ function resolveCopilotHookOwnerPid(): number | null {
264
+ if (process.env[META_HOOK_LAUNCH_ENV] !== META_HOOK_LAUNCH_TOKEN) return null;
265
+ const ownerPid = process.ppid;
266
+ if (!isPlausibleOwnerPid(ownerPid)) return null;
267
+ return ownerPid;
268
+ }
269
+
270
+ function main(): void {
271
+ let raw: string;
272
+ try {
273
+ raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin (the Copilot hook envelope)
274
+ } catch (err) {
275
+ logLine("ERROR", `stdin read failed: ${err instanceof Error ? err.message : String(err)}`);
276
+ emit();
277
+ }
278
+
279
+ let env: Record<string, unknown>;
280
+ try {
281
+ const parsed = JSON.parse(raw);
282
+ if (typeof parsed !== "object" || parsed === null) throw new Error("envelope is not an object");
283
+ env = parsed as Record<string, unknown>;
284
+ } catch (err) {
285
+ logLine("ERROR", `envelope parse failed: ${err instanceof Error ? err.message : String(err)}`);
286
+ emit();
287
+ }
288
+
289
+ const envelope = readBirthEnvelope(env);
290
+ if ("refusal" in envelope) {
291
+ // A record keyed to a guess would be a citizen nobody can join back to a real
292
+ // session, so refuse — and make it ERROR, because this session did NOT become a
293
+ // garden citizen. The doctor reads this line.
294
+ logLine("ERROR", `degraded envelope: ${envelope.refusal} (keys=${Object.keys(env).sort().join(",")})`);
295
+ emit();
296
+ }
297
+
298
+ try {
299
+ // transcriptPath and model are omitted, not guessed: Copilot's envelope carries
300
+ // neither, and both axes are nullable at mint by design.
301
+ const result = upsertMetaSession({
302
+ input: { backend: "copilot", nativeSessionId: envelope.sessionId, cwd: envelope.cwd },
303
+ });
304
+ logLine(
305
+ "INFO",
306
+ `${result.action} record ${path.basename(result.path)} (event=${envelope.eventLabel}, shape=${envelope.shape}, native=${envelope.sessionId})`,
307
+ );
308
+ // RECORD AUTHORITY FIRST: the marker is only a pid->garden hint that the record
309
+ // must vouch for, so it is written INSIDE the success branch. A marker minted
310
+ // after a failed upsert would name a garden id with no record behind it — the
311
+ // resolver would read it, find nothing, and report "names nobody" for a citizen
312
+ // whose real failure was the mint. Same order as the agy writer.
313
+ writeCopilotSenderMarker(result.record.gardenId, envelope);
314
+ } catch (err) {
315
+ logLine(
316
+ "ERROR",
317
+ `upsert failed (event=${envelope.eventLabel}, native=${envelope.sessionId}): ${err instanceof Error ? err.message : String(err)}`,
318
+ );
319
+ }
320
+ emit();
321
+ }
322
+
323
+ main();