@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
@@ -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:
@@ -69,12 +69,14 @@ export class MetaRecordError extends Error {
69
69
  // Schema
70
70
  // ---------------------------------------------------------------------------
71
71
  /**
72
- * The three native meta-bridge backends, declared from the start so the
73
- * per-backend adapter seam is forced (a different native layout each — that
74
- * difference is the whole reason for a thin adapter). Discriminator field on
75
- * every record.
72
+ * Native meta-bridge backends. Discriminator on every record. Adding a member
73
+ * is a sibling-reader redeploy (`./run.sh install-meta-bridge` and the matching
74
+ * sibling install), not a store archive: writers certify the whole active store
75
+ * against THIS set, so a deployed plugin still carrying the old set refuses
76
+ * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
77
+ * stale deployed writer — run it after the change.
76
78
  */
77
- export const META_BACKENDS = ["claude-code", "antigravity", "codex"];
79
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot"];
78
80
  export const META_BACKEND_DESCRIPTORS = {
79
81
  "claude-code": {
80
82
  backend: "claude-code",
@@ -94,6 +96,47 @@ export const META_BACKEND_DESCRIPTORS = {
94
96
  deliveryLevel: "D6",
95
97
  nativeIdLabel: "threadId",
96
98
  },
99
+ // SELF-FETCH citizen since #82 RAIL 5. Copilot CLI 1.0.80 runs our plugin hook (birth,
100
+ // garden address, who-sent) AND forks our first-party extension, whose `joinSession()`
101
+ // holds an `fs.watch` on the garden mailbox signal and can `session.send()` a doorbell
102
+ // into an idle session. That is the same shape Claude's mailbox has, reached through a
103
+ // different vendor surface. Hence:
104
+ // wakeMode self-fetch — the doorbell announces, the model drains its own inbox with
105
+ // `entwurf_inbox_read`, and THAT read is the receipt. The extension never
106
+ // injects the body, so this label promises exactly what happens. It was
107
+ // `direct-inject` while no doorbell existed (measured 2026-08-20: no
108
+ // `FileChanged`, `asyncRewake` or `watchPaths` in the bundle) — that
109
+ // absence was never a claim about the vendor's other surfaces, and the
110
+ // extension rail is the one it missed. Copilot is still NOT a
111
+ // `nativePushSupported` backend and has no native-push adapter.
112
+ // D6 the PRODUCT grade, earned by the managed LIVE acceptance of 2026-08-23
113
+ // (garden 20260823T181316-d9f6ba, native 20fe30c8-b2bc-4600-91a0-8a409131be51,
114
+ // CLI 1.0.80): the receive log joins that native id and arms that garden id,
115
+ // the mailbox state stamps lastEnqueuedAt 09:23:41.235Z and lastReadAt
116
+ // 09:23:56.480Z, and the model answered on the SAME record/native/gid chain.
117
+ // This is still the PRODUCT grade and not the raw probe's: the earlier
118
+ // transport probe was evidence about the mechanism, and it did not move this
119
+ // number — a managed round trip did.
120
+ // D7 is PARTIAL, deliberately: the reply and the read receipt were observed,
121
+ // but the completion taxonomy as a whole and any long-haul operation were
122
+ // not, and the reply envelope itself reaches this comment as an INHERITED
123
+ // fact (it was not re-read from a transcript when this was written). D8 is
124
+ // unproven and D3 (managed second-session isolation) is pending — its
125
+ // decisive log was lost to a scratch cleanup before it could be preserved.
126
+ // The route stays fail-closed on both ends: with no armed receiver marker
127
+ // every dispatch is still refused `mailbox-undeliverable`, so replyability
128
+ // is a fact about a live marker and never a constant of this backend.
129
+ // sessionId the native join key, measured to be ONE id across all three surfaces:
130
+ // the hook envelope, `record.nativeSessionId`, and the SDK's
131
+ // `session.sessionId` (record 20260823T112003-9d069a ==
132
+ // `ARMED sessionId=4fc16d8d-473d-4258-a1fd-f99d3cb375e9`, CLI 1.0.80).
133
+ // That agreement is what lets the extension bind its arm to the record.
134
+ copilot: {
135
+ backend: "copilot",
136
+ wakeMode: "self-fetch",
137
+ deliveryLevel: "D6",
138
+ nativeIdLabel: "sessionId",
139
+ },
97
140
  };
98
141
  // ---------------------------------------------------------------------------
99
142
  // Validation helpers (crash, don't warn)
@@ -104,9 +147,9 @@ export function requireNonEmptyString(value, field) {
104
147
  }
105
148
  return value;
106
149
  }
107
- /** Validate the 3-backend NATIVE bridge axis (sender/receiver markers, capability
150
+ /** Validate the native-bridge backend axis (sender/receiver markers, capability
108
151
  * drift guard). Not a record-schema validator: identity records take
109
- * `requireCitizenBackend` (which admits `pi`). Markers stay native-3 because the
152
+ * `requireCitizenBackend` (which admits `pi`). Markers stay on this set because the
110
153
  * pi adapter carries its record-established garden id into children via env rather
111
154
  * than using the native-hook pid marker rail. */
112
155
  export function requireBackend(value) {
@@ -166,7 +209,7 @@ function isoNow(now) {
166
209
  /** The one live identity schema number. */
167
210
  export const META_SCHEMA_VERSION_V3 = 3;
168
211
  /** Every backend admitted by the one V3 record-citizen schema. */
169
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "pi"];
212
+ export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "pi"];
170
213
  export function requireCitizenBackend(value) {
171
214
  if (typeof value !== "string" || !META_CITIZEN_BACKENDS.includes(value)) {
172
215
  throw new MetaRecordError(`meta-record "backend" must be one of ${META_CITIZEN_BACKENDS.join(" | ")} (got ${describe(value)}).`);
@@ -523,18 +566,30 @@ export function metaCapabilitiesFilePath() {
523
566
  // record.delivery.wakeMode SLOT still existed. 3D-4 then deleted that slot with
524
567
  // the rest of `delivery{}`, so today the registry is the sole home.
525
568
  // ---------------------------------------------------------------------------
526
- /** Memoized packaged registry; the file is immutable at runtime, so caching is honest (not stateful lying). */
527
- let cachedMetaCapabilities = null;
528
569
  /**
529
- * Load + memoize the packaged capability registry — the live source of backend
530
- * honesty metadata as of 3D-3. A missing/corrupt file throws (the registry is a
531
- * packaged invariant; check-pack guarantees its presence).
570
+ * Load the packaged capability registry — the live source of backend honesty metadata
571
+ * as of 3D-3. A missing/corrupt file throws (the registry is a packaged invariant;
572
+ * check-pack guarantees its presence).
573
+ *
574
+ * NO CACHE, deliberately. This used to memoize into a process-lifetime singleton on the
575
+ * argument that "the file is immutable at runtime". That argument was false for the one
576
+ * process that matters: the entwurf-bridge MCP child lives as long as its harness
577
+ * session, so a grade or wakeMode moved by an install/upgrade was invisible to every
578
+ * already-running dispatcher until the operator restarted it — and nothing anywhere
579
+ * said so. The symptom is a citizen answering with last week's capability, which is the
580
+ * silent-wrong-answer class this repo refuses; "restart your session" is an instruction
581
+ * to work around a defect, not a fix for it (#82 RAIL 7).
582
+ *
583
+ * The subtraction is the whole repair, and it is deliberately not an invalidation
584
+ * scheme. Stat-based invalidation (mtime+size) cannot see an atomic same-size,
585
+ * same-timestamp replacement, so it would trade a certain staleness bug for an
586
+ * intermittent one; content hashing would have to read the file anyway. The registry is
587
+ * a few hundred bytes and both production callers — the v2 decider and production
588
+ * deliverability — reach it once per dispatch, not in a loop, so reading it there is
589
+ * cheaper than being wrong.
532
590
  */
533
591
  export function loadMetaCapabilityRegistry() {
534
- if (cachedMetaCapabilities === null) {
535
- cachedMetaCapabilities = parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
536
- }
537
- return cachedMetaCapabilities;
592
+ return parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
538
593
  }
539
594
  /**
540
595
  * The capability for one backend, from the registry (3D-3 live source). The
@@ -749,14 +804,17 @@ export function certifyActiveStoreDir(dir) {
749
804
  }
750
805
  /**
751
806
  * The refusal an uncertifiable ACTIVE store earns, in the words both the runtime
752
- * writers and the install doctor use. One prescription, both invocation forms —
753
- * every defect kind (previous generation, corruption, drift, duplicate, symlink)
754
- * is answered by archiving the generation, so there is nothing to branch on.
807
+ * writers and the install doctor use. Rotten records (previous generation,
808
+ * corruption, drift, duplicate, symlink) are answered by archiving. An
809
+ * unknown-backend defect is a stale reader, not a rotten store the listed
810
+ * defect says so; this headline tells the operator to distinguish. No branch:
811
+ * both sentences always print.
755
812
  */
756
813
  export function activeStoreRefusal(cert, shown = 3) {
757
814
  const headline = `meta-record store ${cert.dir} holds ${cert.defects.length} entry/entries this generation cannot certify — ` +
758
815
  `refusing to write (the active store is v3-only and carries no cross-generation continuity). ` +
759
- `Archive the generation and open a fresh one with ${FRESH_CUT_PRESCRIPTION}.`;
816
+ `Archive the generation and open a fresh one with ${FRESH_CUT_PRESCRIPTION}. ` +
817
+ `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.`;
760
818
  // `shown = 0` is for a caller that ALREADY printed every cause per entry (the
761
819
  // store-doctor): repeating them here doubles the wall on a large previous
762
820
  // generation, which is exactly the aggregation lesson F8 taught. The count and
@@ -1339,12 +1397,28 @@ export function readMetaSenderMarker(opts) {
1339
1397
  }
1340
1398
  // ── meta-receiver presence marker (SE-2 active-receiver signal) ──────────────
1341
1399
  /**
1342
- * The arm-capable hook events. Only these can emit watchPaths (and therefore arm
1343
- * the idle-wake), so only these write a receiver presence marker. UserPromptSubmit
1344
- * is deliberately absent: it can backfill the record but cannot re-arm the watch, so
1345
- * it must NOT mint or refresh an "active receiver" claim it cannot back.
1400
+ * The arm-capable events, across every backend that can arm an idle wake.
1401
+ *
1402
+ * CLAUDE (`session-start`, `cwd-changed`, `file-changed`): only these hook events can
1403
+ * emit watchPaths (and therefore arm the idle-wake), so only these write a receiver
1404
+ * presence marker. UserPromptSubmit is deliberately absent: it can backfill the record
1405
+ * but cannot re-arm the watch, so it must NOT mint or refresh an "active receiver"
1406
+ * claim it cannot back.
1407
+ *
1408
+ * COPILOT (`extension-join`): the same rule, one backend over. Copilot's arm is not a
1409
+ * hook at all — the CLI forks a first-party extension and the extension's own
1410
+ * `joinSession()` is what puts a live `fs.watch` on the garden mailbox signal and gives
1411
+ * it a `session.send()` channel to ring. The membership test is unchanged and is about
1412
+ * capability, not event vocabulary: this provenance is admitted because the process that
1413
+ * writes it holds the watch and can start a turn on an idle session. A Copilot hook event
1414
+ * has neither, which is why the birth hook still arms nothing (#82 RAIL 5).
1346
1415
  */
1347
- export const META_RECEIVER_ARM_PROVENANCES = ["session-start", "cwd-changed", "file-changed"];
1416
+ export const META_RECEIVER_ARM_PROVENANCES = [
1417
+ "session-start",
1418
+ "cwd-changed",
1419
+ "file-changed",
1420
+ "extension-join",
1421
+ ];
1348
1422
  function requireArmProvenance(value) {
1349
1423
  if (typeof value === "string" && META_RECEIVER_ARM_PROVENANCES.includes(value)) {
1350
1424
  return value;
@@ -46,26 +46,47 @@
46
46
  */
47
47
  import { randomBytes } from "node:crypto";
48
48
  import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
49
+ import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
49
50
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
50
51
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
51
- /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
52
- * not a config entry. */
53
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code"];
54
- /** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
52
+ /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
53
+ * not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract. */
54
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot"];
55
+ /**
56
+ * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
55
57
  * than a compiled-in location: the official binary is whatever the operator's environment gives
56
- * when they type the name. */
58
+ * when they type the name.
59
+ *
60
+ * `copilot` resolves `entwurf`, NOT the vendor CLI, and that is the contract rather than a
61
+ * convenience. Step 9 clause 1 requires ONE fixed MANAGED runtime path, and a bare `copilot`
62
+ * is not one: it would start without the `COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS` token
63
+ * whose absence skips the extension scan SILENTLY, so the sibling would look launched and could
64
+ * never be delivered to. `entwurf copilot` is the accepted managed invocation that owns that
65
+ * flag, its recursion fence and its receiver precondition; fresh call reaches Copilot only
66
+ * through it. The cost is named: a Copilot fresh call needs a current `entwurf` on PATH, the
67
+ * way a pi fresh call needs `pi`.
68
+ */
57
69
  export const FRESH_CALL_RUNTIME = {
58
70
  pi: "pi",
59
71
  "claude-code": "claude",
72
+ copilot: "entwurf",
60
73
  };
61
74
  /**
62
75
  * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
63
- * capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
64
- * bridge under its namespaced name. Naming the wrong one costs the whole first turn.
76
+ * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
77
+ * name that harness composes. Naming the wrong one costs the whole first turn.
78
+ *
79
+ * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
80
+ * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
81
+ * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
82
+ * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
83
+ * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
84
+ * sibling's spelling (`docs/adding-a-harness.md` step 5).
65
85
  */
66
86
  export const FRESH_CALL_CALLBACK_TOOL = {
67
87
  pi: "entwurf_v2",
68
88
  "claude-code": "mcp__entwurf-bridge__entwurf_v2",
89
+ copilot: "entwurf-bridge-entwurf_v2",
69
90
  };
70
91
  /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
71
92
  * delivery surface, not a claim that a task of this size was measured through tmux. An argv
@@ -88,12 +109,41 @@ export function isSafeFreshCallModel(model) {
88
109
  * Flag-first submitted no message; Pi rejects the equals form for `--model`.
89
110
  * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
90
111
  * for allowedTools is variadic and eats the prompt as an option value.
112
+ * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
113
+ * `--model`, value as two tokens, and the policy as the explicit `--yolo`
114
+ * token. Measured from `copilot --help` (1.0.80).
91
115
  *
92
- * Both failures looked identical from outside: window open, record and socket minted, no turn.
116
+ * Both pi/claude failures looked identical from outside: window open, record and socket minted,
117
+ * no turn.
93
118
  *
94
- * The equals form is NOT a permission guarantee — on the measured host the tool was already
95
- * permitted, so the option's effect was unobservable. What was observed is that it does no harm
96
- * to the argv. Permission stays a documented host precondition.
119
+ * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
120
+ * the tool was already permitted, so the option's effect was unobservable. What was observed is
121
+ * that it does no harm to the argv. Permission stays a documented host precondition there.
122
+ *
123
+ * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
124
+ * its argv facts are load-bearing:
125
+ *
126
+ * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
127
+ * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
128
+ * `scripts/copilot-launch.sh`.
129
+ * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
130
+ * which would close the window on a sibling that is supposed to stay open and be delivered
131
+ * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
132
+ * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
133
+ * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
134
+ * fresh composition to state its model and permission policy explicitly, never to rely
135
+ * invisibly on someone else's default.
136
+ *
137
+ * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
138
+ * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
139
+ * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
140
+ * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
141
+ * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
142
+ * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
143
+ * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
144
+ * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
145
+ * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
146
+ * example; it is a measured vendor fact even though this argv no longer uses it.
97
147
  */
98
148
  export function buildBackendArgs(backend, prompt, model) {
99
149
  switch (backend) {
@@ -101,6 +151,8 @@ export function buildBackendArgs(backend, prompt, model) {
101
151
  return [prompt, "--entwurf-control", "--model", model];
102
152
  case "claude-code":
103
153
  return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
154
+ case "copilot":
155
+ return ["copilot", "--interactive", prompt, "--model", model, "--yolo"];
104
156
  }
105
157
  }
106
158
  /**
@@ -204,6 +256,17 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
204
256
  return { ok: false, reason: err.reason };
205
257
  throw err;
206
258
  }
259
+ // Backend capability, still PRE-MUTATION (step 9 clause 3). It runs AFTER the runtime is
260
+ // proven, because "entwurf is not on PATH" is the more fundamental answer — telling an
261
+ // operator to run `entwurf install-copilot-bridge` when they have no `entwurf` at all sends
262
+ // them to the wrong repair. It runs BEFORE placement for the reason this whole ordering
263
+ // exists: a refusal here cannot leave a window behind, while the launcher's own equivalent
264
+ // check (receiver only, manual `entwurf copilot`) necessarily runs after one is open.
265
+ if (params.backend === "copilot") {
266
+ const missing = copilotFreshPreflight(env);
267
+ if (missing)
268
+ return { ok: false, reason: missing };
269
+ }
207
270
  const inspected = inspectPlacement(env);
208
271
  if (!inspected.ok)
209
272
  return { ok: false, reason: inspected.reason };
@@ -244,6 +307,9 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
244
307
  /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
245
308
  * they will guess about. */
246
309
  const REJECT_HINT = {
310
+ // The Copilot capability reasons keep their repair text on the leaf that decides them, so
311
+ // the sentence an operator reads cannot drift away from the predicate that produced it.
312
+ ...COPILOT_PREFLIGHT_HINT,
247
313
  "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
248
314
  "anchor-malformed": "TMUX_PANE is not a native pane id",
249
315
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
@@ -0,0 +1,288 @@
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
+ import * as fs from "node:fs";
80
+ import * as path from "node:path";
81
+ import { defaultMetaSessionsDir, isPlausibleOwnerPid, upsertMetaSession, writeMetaSenderMarker, } from "./lib/meta-session.js";
82
+ /**
83
+ * The launch-provenance handshake, held as LOCAL literals on purpose.
84
+ *
85
+ * The Claude payload declares the same pair privately, and `check-meta-receiver-marker`
86
+ * asserts the literal is present in THAT source — so hoisting the two strings into a
87
+ * shared module would drag a sibling rail's production file and its gate into this lane
88
+ * for no behavioral gain (PM decision, 2026-08-21).
89
+ *
90
+ * This is not an unguarded copy. The value already exists on both sides of a shell/TS
91
+ * boundary anyway (`copilot-hook-launch.sh` exports it), and `check-copilot-birth-hook`
92
+ * fires the REAL assembled launcher, so a drift between the two spellings shows up as a
93
+ * refused marker in that gate rather than as a silent mismatch.
94
+ */
95
+ const META_HOOK_LAUNCH_ENV = "ENTWURF_META_HOOK_LAUNCH";
96
+ const META_HOOK_LAUNCH_TOKEN = "hook-launch/v1";
97
+ /** Append a best-effort diagnostic line; swallow even its own failure. Same log file
98
+ * and same LEVEL vocabulary as the Claude unit, so one doctor grep covers both. */
99
+ function logLine(level, message) {
100
+ try {
101
+ const file = path.join(path.dirname(defaultMetaSessionsDir()), "meta-bridge-hook.log");
102
+ fs.mkdirSync(path.dirname(file), { recursive: true });
103
+ fs.appendFileSync(file, `${new Date().toISOString()} ${level} [copilot] ${message}\n`);
104
+ }
105
+ catch {
106
+ /* logging is best-effort; a broken log must not break the session */
107
+ }
108
+ }
109
+ /** Emit the neutral hook response and leave. Copilot has no watchPaths to arm, so
110
+ * there is never anything else to say. */
111
+ function emit() {
112
+ process.stdout.write("{}\n");
113
+ process.exit(0);
114
+ }
115
+ /**
116
+ * Reduce either envelope shape to the two fields birth needs, or refuse.
117
+ *
118
+ * ACCEPTING TWO SHAPES IS NOT THE SAME AS PREFERRING ONE. `sessionId ?? session_id`
119
+ * would silently pick a winner when the two disagree, and a disagreement is exactly
120
+ * the case worth refusing: it means the compat translator produced two different
121
+ * identities for one session, or something hand-built the envelope. A record minted
122
+ * from the loser would be a citizen no live session can be joined back to. So both
123
+ * present + equal is fine (`agreeing-both`); both present + different is a REFUSAL
124
+ * (cross-review, terra, 2026-08-20).
125
+ *
126
+ * `cwd` has NO process.cwd() fallback, unlike the Claude entry. There the hook runs
127
+ * in the session's own cwd, so the fallback is the same value by another route; here
128
+ * the record is the ONLY thing this unit ever writes and a guessed cwd would be a
129
+ * fact nothing later can correct. Both measured Copilot shapes carry `cwd`.
130
+ */
131
+ export function readBirthEnvelope(env) {
132
+ const nativeRaw = env.sessionId;
133
+ const compatRaw = env.session_id;
134
+ const native = typeof nativeRaw === "string" ? nativeRaw : "";
135
+ const compat = typeof compatRaw === "string" ? compatRaw : "";
136
+ if (nativeRaw !== undefined && native.length === 0)
137
+ return { refusal: "sessionId present but not a non-empty string" };
138
+ if (compatRaw !== undefined && compat.length === 0)
139
+ return { refusal: "session_id present but not a non-empty string" };
140
+ if (native && compat && native !== compat) {
141
+ return { refusal: `sessionId and session_id disagree (${native} vs ${compat})` };
142
+ }
143
+ const sessionId = native || compat;
144
+ if (!sessionId)
145
+ return { refusal: "no sessionId/session_id" };
146
+ const shape = native && compat ? "agreeing-both" : native ? "copilot-native" : "claude-compat";
147
+ if (typeof env.cwd !== "string" || env.cwd.length === 0)
148
+ return { refusal: "cwd missing or not a non-empty string" };
149
+ const cwd = env.cwd;
150
+ const eventLabel = typeof env.hook_event_name === "string" && env.hook_event_name.length > 0
151
+ ? env.hook_event_name
152
+ : typeof env.source === "string" && env.source.length > 0
153
+ ? `native(source=${env.source})`
154
+ : "unlabeled";
155
+ return { sessionId, cwd, shape, eventLabel };
156
+ }
157
+ /**
158
+ * Which pid does a sender marker written here belong to — or NONE.
159
+ *
160
+ * `copilot-hook-launch.sh` `exec`s this payload, so on the shipped path this process IS
161
+ * the launcher and its parent is the Copilot host that ran the hook. Two things that
162
+ * form cannot promise are asked anyway, and both fail CLOSED:
163
+ *
164
+ * 1. LAUNCH PROVENANCE. `process.ppid` only names the Copilot host when this payload
165
+ * was reached THROUGH the shipped launcher. A session still holding an older cached
166
+ * hook command, or a hand-run of the entry file, has some other parent — so the
167
+ * launcher stamps a token and this reads it. No token, no claim.
168
+ * 2. A PLAUSIBLE LIVE PARENT. `isPlausibleOwnerPid` is the predicate shared with every
169
+ * marker writer, reader and the generation cut. A marker naming init outlives every
170
+ * process that could refute it, and one such leftover once blocked a host's
171
+ * fresh-cut until it was deleted by hand (#53 A).
172
+ *
173
+ * Failing closed costs only who-sent, which this backend did not have five minutes ago,
174
+ * and the doctor reads the WARN. Claiming a transient wrapper as the owner would instead
175
+ * attribute a citizen's messages to a pid that never sent them.
176
+ *
177
+ * MEASUREMENT STATE OF THE JOIN ITSELF. `[측정]` Closed end to end on Copilot CLI 1.0.80,
178
+ * 2026-08-21, through the PRODUCTION events this unit wires: a live session's marker owner
179
+ * was the running Copilot native process, that same pid was the parent of the running
180
+ * entwurf MCP child, and a send from that session arrived carrying its own garden id with
181
+ * `origin: "meta-session"`. Marker owner == native host == MCP child's parent is therefore
182
+ * an observed fact on this backend, not an assumption inherited from the other two.
183
+ *
184
+ * The claim is still BOUNDED, and the bound is the vendor version: one CLI release is not a
185
+ * promise about the next, and this join is the only thing standing between a marker and the
186
+ * child that must find it. That is why the success line below prints the owner pid — it
187
+ * keeps the join re-checkable from the log alone, with no probe to re-run.
188
+ */
189
+ /**
190
+ * Arm who-sent for this host's MCP children, or say in the log why it could not be.
191
+ *
192
+ * BEST-EFFORT + LOG, exactly like the mint above it: a Copilot turn must never be broken
193
+ * or spammed by our bookkeeping, so nothing here throws and nothing reaches the terminal.
194
+ * The three outcomes get three distinct tokens because they need three different fixes,
195
+ * and the doctor greps this log:
196
+ *
197
+ * `sender marker <pid> -> <gid>` armed. The pid is printed because it is the join key
198
+ * AND the production-event measurement that is still
199
+ * open (see resolveCopilotHookOwnerPid).
200
+ * `sender-marker-refused` we declined to claim an owner: no launch provenance
201
+ * (reinstall + restart that session) or an implausible
202
+ * parent (a reparented orphan). Not an error — a
203
+ * fail-closed answer, so WARN.
204
+ * `sender-marker-failed` we tried and the write itself broke (permissions, a
205
+ * full disk, a refused owner pid). ERROR.
206
+ *
207
+ * Either non-success costs only who-sent: the citizen still exists, still appears in
208
+ * `entwurf_peers`, and can still be addressed BY others. Only its own outbound sends fall
209
+ * back to the bridge's default refusal.
210
+ */
211
+ function writeCopilotSenderMarker(gardenId, envelope) {
212
+ const ownerPid = resolveCopilotHookOwnerPid();
213
+ if (ownerPid === null) {
214
+ logLine("WARN", process.env[META_HOOK_LAUNCH_ENV] !== META_HOOK_LAUNCH_TOKEN
215
+ ? `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})`
216
+ : `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})`);
217
+ return;
218
+ }
219
+ try {
220
+ writeMetaSenderMarker({
221
+ backend: "copilot",
222
+ gardenId,
223
+ nativeSessionId: envelope.sessionId,
224
+ cwd: envelope.cwd,
225
+ ownerPid,
226
+ });
227
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${envelope.eventLabel})`);
228
+ }
229
+ catch (err) {
230
+ logLine("ERROR", `sender-marker-failed pid=${ownerPid} garden=${gardenId}: ${err instanceof Error ? err.message : String(err)}`);
231
+ }
232
+ }
233
+ function resolveCopilotHookOwnerPid() {
234
+ if (process.env[META_HOOK_LAUNCH_ENV] !== META_HOOK_LAUNCH_TOKEN)
235
+ return null;
236
+ const ownerPid = process.ppid;
237
+ if (!isPlausibleOwnerPid(ownerPid))
238
+ return null;
239
+ return ownerPid;
240
+ }
241
+ function main() {
242
+ let raw;
243
+ try {
244
+ raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin (the Copilot hook envelope)
245
+ }
246
+ catch (err) {
247
+ logLine("ERROR", `stdin read failed: ${err instanceof Error ? err.message : String(err)}`);
248
+ emit();
249
+ }
250
+ let env;
251
+ try {
252
+ const parsed = JSON.parse(raw);
253
+ if (typeof parsed !== "object" || parsed === null)
254
+ throw new Error("envelope is not an object");
255
+ env = parsed;
256
+ }
257
+ catch (err) {
258
+ logLine("ERROR", `envelope parse failed: ${err instanceof Error ? err.message : String(err)}`);
259
+ emit();
260
+ }
261
+ const envelope = readBirthEnvelope(env);
262
+ if ("refusal" in envelope) {
263
+ // A record keyed to a guess would be a citizen nobody can join back to a real
264
+ // session, so refuse — and make it ERROR, because this session did NOT become a
265
+ // garden citizen. The doctor reads this line.
266
+ logLine("ERROR", `degraded envelope: ${envelope.refusal} (keys=${Object.keys(env).sort().join(",")})`);
267
+ emit();
268
+ }
269
+ try {
270
+ // transcriptPath and model are omitted, not guessed: Copilot's envelope carries
271
+ // neither, and both axes are nullable at mint by design.
272
+ const result = upsertMetaSession({
273
+ input: { backend: "copilot", nativeSessionId: envelope.sessionId, cwd: envelope.cwd },
274
+ });
275
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${envelope.eventLabel}, shape=${envelope.shape}, native=${envelope.sessionId})`);
276
+ // RECORD AUTHORITY FIRST: the marker is only a pid->garden hint that the record
277
+ // must vouch for, so it is written INSIDE the success branch. A marker minted
278
+ // after a failed upsert would name a garden id with no record behind it — the
279
+ // resolver would read it, find nothing, and report "names nobody" for a citizen
280
+ // whose real failure was the mint. Same order as the agy writer.
281
+ writeCopilotSenderMarker(result.record.gardenId, envelope);
282
+ }
283
+ catch (err) {
284
+ logLine("ERROR", `upsert failed (event=${envelope.eventLabel}, native=${envelope.sessionId}): ${err instanceof Error ? err.message : String(err)}`);
285
+ }
286
+ emit();
287
+ }
288
+ main();