@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
@@ -9,10 +9,12 @@ only on Linux because its strict live-owner join uses `/proc`.
9
9
  | Component | Requirement | Needed for |
10
10
  |---|---|---|
11
11
  | Node | **`>=24.0.0`** | package and bridge runtime |
12
- | npm | bundled with Node | package installation |
13
- | entwurf | `@junghanacs/entwurf` | all lanes |
14
- | pi | optional, `@earendil-works/pi-coding-agent >=0.84.2 <0.85` | ACP provider, control sockets |
12
+ | npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
13
+ | Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot wiring writers |
14
+ | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
15
+ | pi | optional-by-presence, `>=0.84.3 <0.85` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
15
16
  | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
17
+ | GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
16
18
  | Antigravity `agy` | optional, operator-installed and authenticated | native-push citizen |
17
19
  | Cortex Code | optional, operator-installed and authenticated | Cortex ACP backend |
18
20
 
@@ -51,12 +53,62 @@ npx entwurf check-bridge
51
53
  `check-bridge` is auth-free. It proves the installed prebuilt MCP server boots and
52
54
  lists the seven garden tools; it does not prove a backend model turn or native hook.
53
55
 
56
+ Neither npm form installs a harness runtime. `pi`, Claude Code, Copilot CLI, agy, Cortex and their
57
+ authentication remain operator-owned optional prerequisites for the integrations that use them;
58
+ all may be absent on an Entwurf-only host. A source checkout's pinned Pi development packages are
59
+ for building and testing this repo, not a transitive product installation promise.
60
+
61
+ Maintainers using a source checkout do not install a second global entwurf package. Full source
62
+ setup currently requires Node 24, pnpm, and Python 3 on PATH; every harness — including pi — is
63
+ optional-by-presence (absent → explicit SKIP, detected but below the supported floor → named FAIL
64
+ with a nonzero setup result):
65
+
66
+ ```bash
67
+ git clone https://github.com/junghan0611/entwurf ~/repos/gh/entwurf
68
+ cd ~/repos/gh/entwurf
69
+ ./run.sh setup /path/to/consumer-project
70
+ ```
71
+
72
+ This owns `~/.local/bin/entwurf` as a symlink to that checkout's `run.sh` and fails if the
73
+ link is foreign, outside PATH, or shadowed by another command. It detects and wires
74
+ pi/Claude/agy/Copilot by presence and prints a computed per-component PASS/SKIP/FAIL summary — a
75
+ detected harness that cannot be completed makes setup exit nonzero. A detected `copilot`
76
+ composes all four native units (birth → MCP → receiver → visible footer) with independent
77
+ per-unit verdicts (#86 C3b); §4 keeps the explicit per-unit install/doctor/inverse surfaces for
78
+ repair.
79
+
80
+ ### 1.1 User-scope ownership (one shared registration, one recorded owner)
81
+
82
+ The GLOBAL pi user-scope registration (`~/.pi/agent/settings.json` `packages[]` plus the
83
+ `entwurfProvider.mcpServers.entwurf-bridge` key) is ONE shared entry across every install root, and
84
+ it carries a recorded owner: `packageRoot` in `$XDG_DATA_HOME/entwurf/pi-package/install-state.json`
85
+ and `installerRoot` in the provider install-state. Normal `install`/`setup` from a different root —
86
+ whether the recorded owner is live or missing — refuses with zero settings bytes written; the only
87
+ writer that moves the shared entry is the operator-explicit `entwurf takeover-user-scope`
88
+ (old→new reported). Every user-scope operation is ATOMIC across the two halves: both ownership
89
+ preflights run read-only first, so a refusal on either side leaves the other byte-identical. A
90
+ takeover over an operator's own provider override is a SPLIT verdict — the package owner moves,
91
+ the override is preserved and stays unowned (its stale ownership state is cleared), never a false
92
+ "both owned". A LEGACY provider state (no `installerRoot`) accepts no inverse: run `setup`/`install`
93
+ from the owning root first (named adoption), then remove. Both install-states also bind the exact
94
+ settings file they manage (`managedSettingsPath`): pointing an operation at a different, symlinked
95
+ or unparseable file refuses with zero writes before either half proceeds, and the owned/orphan
96
+ inverse removes only the recorded owner's exact `packages[]` entry (0 or 2+ exact entries refuse).
97
+ `entwurf doctor-pi-package` names the package-side verdict including the
98
+ packageRoot↔installerRoot coupling mismatch and a package/provider managed-path mismatch.
99
+
100
+ | Root shape | Package root written | Stable commands from | Project write | User write | Takeover trigger | Inverse | Stale/moved verdict |
101
+ |---|---|---|---|---|---|---|---|
102
+ | source checkout | the checkout dir | `dev-bin` symlinks (`setup`) | `<project>/.pi/settings.json` | shared entry + owner state | `takeover-user-scope` from the new checkout | `remove` (project) / `remove-user-scope` (global, same-owner-only) | `doctor-pi-package` → `missing-owner`; normal install still refuses |
103
+ | global npm (`npm i -g`) | the global `node_modules/@junghanacs/entwurf` | npm bin linking | same | same shared entry | same explicit action | same; a LIVE foreign owner always refuses | same |
104
+ | project-local npm | that project's `node_modules/@junghanacs/entwurf` | `node_modules/.bin` | same | same shared entry | same explicit action | same | same; a deleted root becomes the aligned `remove-user-scope` orphan cleanup (entry + package state + provider installerRoot must all name that missing root) |
105
+
54
106
  ## 2. Optional pi adapter / ACP plugin
55
107
 
56
108
  Install the exact release floor, then wire the project:
57
109
 
58
110
  ```bash
59
- npm install -g @earendil-works/pi-coding-agent@0.84.2
111
+ npm install -g @earendil-works/pi-coding-agent@0.84.3
60
112
  pi --version
61
113
 
62
114
  cd ~/entwurf-smoke
@@ -64,7 +116,7 @@ entwurf install .
64
116
  pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
65
117
  ```
66
118
 
67
- The supported range is `>=0.84.2 <0.85`. It is a hard minimum: installing this
119
+ The supported range is `>=0.84.3 <0.85`. It is a hard minimum: installing this
68
120
  release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
69
121
  minor. A host using only the external MCP bridge can skip pi until it needs a
70
122
  control socket; no delivery rail launches a pi process.
@@ -109,7 +161,31 @@ New macOS wiring is refused because the live join is not instrumented there. Dar
109
161
  uninstall remains available for cleaning an older managed install; this is an evidence
110
162
  boundary, not a permanent impossibility claim.
111
163
 
112
- ## 4. Optional Antigravity native citizen
164
+ ## 4. Optional GitHub Copilot CLI native citizen
165
+
166
+ Copilot has four independently owned surfaces. `setup` composes all four when `copilot` is on
167
+ PATH (#86 C3b); the commands below are the per-unit repair, doctor, and inverse surfaces. All
168
+ four must be green for supported visible fresh; a manual citizen may omit the footer, but fresh
169
+ refuses before opening a window when any required surface is absent.
170
+
171
+ ```bash
172
+ entwurf install-copilot-bridge
173
+ entwurf install-copilot-mcp
174
+ entwurf install-copilot-receive
175
+ entwurf install-copilot-statusline
176
+
177
+ entwurf doctor-copilot-bridge
178
+ entwurf doctor-copilot-mcp
179
+ entwurf doctor-copilot-receive
180
+ entwurf doctor-copilot-statusline
181
+ ```
182
+
183
+ Launch the supported invocation with `entwurf copilot`, not bare `copilot`. It enables extension
184
+ scanning for that process, checks the receiver, removes inherited pi identity carriers, and owns
185
+ the model/permission defaults. Birth occurs on the first prompt. `entwurf_fresh_call` uses this
186
+ same managed invocation and requires the birth, MCP, receiver, and visible-identity preflight.
187
+
188
+ ## 5. Optional Antigravity native citizen
113
189
 
114
190
  Install the three independently owned surfaces:
115
191
 
@@ -134,7 +210,7 @@ Real native-push acceptance needs an already-running conversation:
134
210
  LIVE=1 AGY_CONVERSATION_ID=<id> entwurf smoke-agy-native-push-live
135
211
  ```
136
212
 
137
- ## 5. Optional ACP backend turns
213
+ ## 6. Optional ACP backend turns
138
214
 
139
215
  Claude uses the operator's existing local Claude authentication:
140
216
 
@@ -153,25 +229,34 @@ LIVE=1 ENTWURF_ACP_CORTEX_CONNECTION=<conn> \
153
229
  The aggregate release gate is Claude-backed and does not run Cortex automatically.
154
230
  Its silence is not a Cortex PASS.
155
231
 
156
- ## 6. Upgrade and repair
232
+ ## 7. Upgrade and repair
157
233
 
158
234
  After upgrading the package, rerun the managed installers for every native harness
159
235
  in use and restart their existing processes. Native plugin caches are not live-reload
160
236
  safe across launch-contract changes.
161
237
 
162
238
  If install or doctor reports an unreadable/old active citizen generation, do not edit
163
- records by hand:
239
+ records by hand. Close pi, Claude, Copilot, and agy sessions first, run
240
+ `entwurf meta-bridge-fresh-cut`, and read its exit status before any install. Then choose the
241
+ installation mode you actually own:
164
242
 
165
243
  ```bash
166
- # close pi, Claude, and agy sessions first
167
- entwurf meta-bridge-fresh-cut
168
- entwurf setup ~/entwurf-smoke
244
+ # npm package consumer
245
+ entwurf install ~/entwurf-smoke
246
+ entwurf install-meta-bridge
247
+ # `entwurf setup` re-composes the four Copilot units when `copilot` is on PATH
248
+
249
+ # source maintainer — from the checkout
250
+ ./run.sh setup ~/entwurf-smoke
169
251
  ```
170
252
 
171
- Read the cut's exit status before chaining setup. The complete quiescence, archive,
172
- and exit-code contract is [fresh-cut-policy.md](./fresh-cut-policy.md).
253
+ The package-installed `entwurf setup` is the same consumer command in installed mode: it names
254
+ that mode first, never runs npm/pnpm inside `node_modules` (the frozen pnpm bootstrap is
255
+ source-checkout-only), and composes the detected harnesses with the same per-component
256
+ PASS/SKIP/FAIL summary. The complete quiescence, archive, and exit-code contract is
257
+ [fresh-cut-policy.md](./fresh-cut-policy.md).
173
258
 
174
- ## 7. Release acceptance versus host acceptance
259
+ ## 8. Release acceptance versus host acceptance
175
260
 
176
261
  - `entwurf check-bridge`: installed MCP bytes boot; no backend auth.
177
262
  - `pnpm check` / `pnpm run check:full`: tiered source deterministic floors (everyday
@@ -190,12 +275,27 @@ Run only the surfaces this host owns:
190
275
 
191
276
  ```bash
192
277
  entwurf uninstall-meta-bridge
278
+ entwurf uninstall-copilot-statusline
279
+ entwurf uninstall-copilot-receive
280
+ entwurf uninstall-copilot-mcp
281
+ entwurf uninstall-copilot-bridge
193
282
  entwurf uninstall-agy-hooks
194
283
  entwurf uninstall-agy-statusline
195
284
  entwurf uninstall-agy-bridge
196
- entwurf uninstall ~/entwurf-smoke
285
+ entwurf remove ~/entwurf-smoke
286
+ # only when no other project uses the shared user-scope pi registration:
287
+ entwurf remove-user-scope
197
288
  npm uninstall -g @junghanacs/entwurf
198
289
  ```
199
290
 
200
- Each managed surface has an honest inverse and preserves unrelated native-harness
201
- configuration.
291
+ The package `uninstall-*`/`remove` surfaces preserve unrelated native-harness configuration.
292
+ Copilot birth now has a package-owned inverse: `uninstall-copilot-bridge` removes exactly what
293
+ its install-state (`$XDG_DATA_HOME/entwurf/copilot-bridge/install-state.json`) records — the
294
+ qualified plugin, the local marketplace registration when it is owned and still at the recorded
295
+ path, and the recorded assembly — never with `--force`, never a bare plugin name that could
296
+ match somebody else's unit, and never the stale Claude unit. The complete ownership preflight
297
+ runs read-only before the first vendor write, so a marketplace under our name at another path,
298
+ a registration the state does not own, or a failing vendor list (UNKNOWN, never absence)
299
+ refuses the whole inverse with zero writes; the state is deleted last, so a partial failure
300
+ keeps a rerun-repair authority.
301
+ A legacy no-state installation is adopted by re-running `install-copilot-bridge` first.
@@ -562,22 +562,24 @@ server.tool("entwurf_register_native", "Register an ALREADY-RUNNING native conve
562
562
  // its own garden id, a fresh cell answered with its uuidv7 `PI_SESSION_ID` value read out of the
563
563
  // environment by an MCP server it had spawned itself — confidently, and wrong. A sibling launched
564
564
  // against that answer would call home to a garden id nobody holds.
565
- server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operator's own tmux session and hand it a first task. Two fixed " +
566
- "backends only: pi, claude-code. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
565
+ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operator's own tmux session and hand it a first task. Three fixed " +
566
+ "backends only: pi, claude-code, copilot. The sibling's FIRST action is a callback to you carrying a nonce, and the " +
567
567
  "sender envelope of that callback is its garden id — that is how you learn the address of something that " +
568
568
  "did not exist a moment ago. This returns a LAUNCH receipt (tmux window/pane plus that nonce) and nothing " +
569
569
  "else: it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls " +
570
570
  "for the callback; if it never arrives the window is visible and can be read directly. For EXISTING " +
571
571
  "citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and " +
572
- "is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code). An optional " +
572
+ "is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code; a model name " +
573
+ "or `auto` for copilot). A copilot launch goes through entwurf's own managed invocation and is refused " +
574
+ "BEFORE any window opens if this host lacks the Copilot birth, MCP, receiver or visible-footer units. An optional " +
573
575
  "cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) — never pick resume " +
574
576
  "for a dormant record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. " +
575
577
  "There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
576
578
  "same-user processes on this host. Requires that this agent itself runs " +
577
579
  "inside tmux: without a pane anchor there is no session to open a sibling beside.", {
578
580
  backend: z
579
- .enum(["pi", "claude-code"])
580
- .describe("Which fixed runtime to open. Only these two; there is no arbitrary command."),
581
+ .enum(["pi", "claude-code", "copilot"])
582
+ .describe("Which fixed runtime to open. Only these three; there is no arbitrary command."),
581
583
  model: z
582
584
  .string()
583
585
  .min(1)
@@ -591,7 +593,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
591
593
  // as an unused suppression while the escape goes back to being fixable.
592
594
  // biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
593
595
  .regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
594
- .describe("Required runtime model: canonical provider/model for pi, or a Claude Code model id/alias."),
596
+ .describe("Required runtime model: canonical provider/model for pi, a Claude Code model id/alias, or a Copilot model name (or auto)."),
595
597
  task: z
596
598
  .string()
597
599
  .min(1)
@@ -4,6 +4,7 @@
4
4
  "claude-code": { "wakeMode": "self-fetch", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" },
5
5
  "antigravity": { "wakeMode": "direct-inject", "deliveryLevel": "D6", "nativeIdLabel": "conversationId" },
6
6
  "codex": { "wakeMode": "direct-inject", "deliveryLevel": "D6", "nativeIdLabel": "threadId" },
7
+ "copilot": { "wakeMode": "self-fetch", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" },
7
8
  "pi": { "wakeMode": "direct-inject", "deliveryLevel": "D6", "nativeIdLabel": "sessionId" }
8
9
  }
9
10
  }
@@ -0,0 +1,253 @@
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
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
47
+ import * as path from "node:path";
48
+ const BIRTH_PLUGIN = "entwurf-meta-receive-copilot";
49
+ const RECEIVE_UNIT = "entwurf-receive";
50
+ const MCP_SERVER_KEY = "entwurf-bridge";
51
+ /** Same default and same env seam as `scripts/copilot-statusline-bridge.sh`. */
52
+ const DEFAULT_STATUSLINE_COMMAND = "entwurf-copilot-statusline";
53
+ function dataHome(env) {
54
+ const xdg = env.XDG_DATA_HOME;
55
+ if (typeof xdg === "string" && xdg.length > 0)
56
+ return xdg;
57
+ const home = env.HOME;
58
+ if (typeof home === "string" && home.length > 0)
59
+ return path.join(home, ".local", "share");
60
+ return null;
61
+ }
62
+ /** A JSON object or nothing. Failure to read, parse, or find an object is the SAME answer
63
+ * here — absent — because the caller's next move is identical in all of them: run the
64
+ * installer. Telling a corrupt state file apart from a missing one is the doctor's job. */
65
+ function readJsonObject(file) {
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
68
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
69
+ ? parsed
70
+ : null;
71
+ }
72
+ catch {
73
+ // Bounded environment probing, Hard Rule 15's stated exception.
74
+ return null;
75
+ }
76
+ }
77
+ function isDir(p) {
78
+ try {
79
+ return statSync(p).isDirectory();
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ function isSymlink(p) {
86
+ try {
87
+ return lstatSync(p).isSymbolicLink();
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /** Same DEST the managed launcher will arm from (`scripts/copilot-launch.sh`).
94
+ * `COPILOT_EXTENSIONS_DIR` wins; otherwise `$HOME/.copilot/extensions/<unit>`.
95
+ * String equality, not realpath: the launcher compares with `[ = ]`. */
96
+ function receiveDest(env) {
97
+ const override = env.COPILOT_EXTENSIONS_DIR;
98
+ if (typeof override === "string" && override.length > 0) {
99
+ return path.join(override, RECEIVE_UNIT);
100
+ }
101
+ const home = env.HOME;
102
+ if (typeof home !== "string" || home.length === 0)
103
+ return null;
104
+ return path.join(home, ".copilot", "extensions", RECEIVE_UNIT);
105
+ }
106
+ function isExecutableFile(p) {
107
+ try {
108
+ // `X_OK` for THIS user, which is what `command -v` answers and what Copilot will need.
109
+ // A mode-bit test would call a root-owned 0700 binary executable for everyone.
110
+ accessSync(p, constants.X_OK);
111
+ return statSync(p).isFile();
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * The statusline command as the shipped adapter resolves it: a value containing a separator is
119
+ * a path and must be executable; a bare name is looked up on PATH. This is a small local
120
+ * search rather than `mux-launch`'s resolver because the import fence keeps `mux-launch` to
121
+ * exactly two importers — a third would be a decision, and this leaf does not need one.
122
+ */
123
+ function commandResolvable(command, env) {
124
+ if (command.includes("/"))
125
+ return isExecutableFile(command);
126
+ const raw = env.PATH;
127
+ if (typeof raw !== "string" || raw.length === 0)
128
+ return false;
129
+ for (const dir of raw.split(path.delimiter)) {
130
+ if (dir.length === 0)
131
+ continue;
132
+ if (isExecutableFile(path.join(dir, command)))
133
+ return true;
134
+ }
135
+ return false;
136
+ }
137
+ /**
138
+ * The visible-identity axis, step 9 clause 4. The subject is the EFFECTIVE settings the vendor
139
+ * will read, not entwurf's ownership record: a host can carry a correct footer configuration
140
+ * with no install-state (measured on the acceptance host, 2026-08-24 — `doctor-copilot-statusline`
141
+ * reported `settings: configured (resolvable)` / `state: absent` / rc=0), and refusing that host
142
+ * would be refusing a working visible identity because of a missing receipt.
143
+ *
144
+ * The install-state, when present, is checked for the ONE thing it can still contradict: that
145
+ * it manages the very file Copilot reads. A state pointing somewhere else means two settings
146
+ * files disagree about who owns the footer, and which one wins is not decidable from here.
147
+ */
148
+ function visibleIdentityMissing(env, data) {
149
+ const command = env.COPILOT_STATUSLINE_COMMAND || DEFAULT_STATUSLINE_COMMAND;
150
+ const configured = env.COPILOT_SETTINGS_CONFIG;
151
+ let settingsPath;
152
+ if (typeof configured === "string" && configured.length > 0) {
153
+ settingsPath = configured;
154
+ }
155
+ else {
156
+ const home = env.HOME;
157
+ if (typeof home !== "string" || home.length === 0)
158
+ return true;
159
+ settingsPath = path.join(home, ".copilot", "settings.json");
160
+ }
161
+ // A symlinked settings file is somebody else's SSOT; the adapter refuses to touch it and
162
+ // this refuses to certify it.
163
+ if (isSymlink(settingsPath))
164
+ return true;
165
+ const settings = readJsonObject(settingsPath);
166
+ if (settings === null)
167
+ return true;
168
+ const statusLine = settings.statusLine;
169
+ if (typeof statusLine !== "object" || statusLine === null || Array.isArray(statusLine))
170
+ return true;
171
+ if (statusLine.command !== command)
172
+ return true;
173
+ const footer = settings.footer;
174
+ if (typeof footer !== "object" || footer === null || Array.isArray(footer))
175
+ return true;
176
+ // `showCustom` must be exactly true. A truthy string would render nothing.
177
+ if (footer.showCustom !== true)
178
+ return true;
179
+ if (!commandResolvable(command, env))
180
+ return true;
181
+ const state = readJsonObject(path.join(data, "entwurf", "copilot-statusline", "install-state.json"));
182
+ if (state === null)
183
+ return false; // absent state is not drift — see the doc comment above
184
+ const managed = state.managedSettingsPath;
185
+ if (typeof managed !== "string" || !path.isAbsolute(managed))
186
+ return true;
187
+ return path.resolve(managed) !== path.resolve(settingsPath);
188
+ }
189
+ /**
190
+ * Answer for ONE fresh Copilot launch. `null` means every required capability is in place on
191
+ * this filesystem; anything else is the first missing one, in the order the fresh contract
192
+ * consumes them: be born, hold the hand, be reachable, be visible.
193
+ */
194
+ export function copilotFreshPreflight(env = process.env) {
195
+ const data = dataHome(env);
196
+ // With no HOME and no XDG_DATA_HOME there is no place any of these could be installed.
197
+ // Report the FIRST capability rather than inventing a fifth reason for "no home".
198
+ if (data === null)
199
+ return "copilot-birth-unit-missing";
200
+ // 1. Birth. The assembly is what the installer bakes and what the birth doctor certifies
201
+ // statically; `hooks.json` is what makes it a hook unit rather than a directory. There is
202
+ // no install-state for this unit — the artifact IS its ownership record.
203
+ const birthUnit = path.join(data, "entwurf", "meta-bridge-copilot", ".assembled", BIRTH_PLUGIN);
204
+ if (!isDir(birthUnit) || !existsSync(path.join(birthUnit, "hooks", "hooks.json"))) {
205
+ return "copilot-birth-unit-missing";
206
+ }
207
+ // 2. MCP hand. The install-state names the config it owns, so the config path is READ from
208
+ // ownership truth rather than re-derived — a preflight that guessed would refuse a
209
+ // correctly installed host whose config lives somewhere else.
210
+ const mcpState = readJsonObject(path.join(data, "entwurf", "copilot-mcp", "install-state.json"));
211
+ if (mcpState === null || mcpState.serverKey !== MCP_SERVER_KEY)
212
+ return "copilot-mcp-hand-missing";
213
+ const managedConfigPath = mcpState.managedConfigPath;
214
+ if (typeof managedConfigPath !== "string" || !path.isAbsolute(managedConfigPath))
215
+ return "copilot-mcp-hand-missing";
216
+ const servers = readJsonObject(managedConfigPath)?.mcpServers;
217
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers))
218
+ return "copilot-mcp-hand-missing";
219
+ // The state can be current while the config drifted (hand-edited, restored from a backup,
220
+ // replaced by another tool). The server key present in the file the CLI actually reads is
221
+ // the fact the first turn depends on.
222
+ if (!Object.hasOwn(servers, MCP_SERVER_KEY))
223
+ return "copilot-mcp-hand-missing";
224
+ // 3. Receive. The same facts `scripts/copilot-launch.sh` checks — unit name, recorded
225
+ // path, DEST equality against this env's extensions root, then the entry file.
226
+ const recvState = readJsonObject(path.join(data, "entwurf", "copilot-receive", "install-state.json"));
227
+ if (recvState === null || recvState.unit !== RECEIVE_UNIT)
228
+ return "copilot-receive-unit-missing";
229
+ const recvPath = recvState.path;
230
+ if (typeof recvPath !== "string" || !path.isAbsolute(recvPath))
231
+ return "copilot-receive-unit-missing";
232
+ // Mirror the launcher's path-mismatch predicate PRE-MUTATION. A state that names a
233
+ // real unit in a different extensions root than this env will scan still opens a
234
+ // window today if we only check that the files exist — then `entwurf copilot`
235
+ // refuses inside it and the caller holds a launch receipt for a dead sibling.
236
+ const dest = receiveDest(env);
237
+ if (dest === null || recvPath !== dest)
238
+ return "copilot-receive-unit-missing";
239
+ if (!isDir(recvPath) || !existsSync(path.join(recvPath, "extension.mjs")))
240
+ return "copilot-receive-unit-missing";
241
+ // 4. Visible identity.
242
+ if (visibleIdentityMissing(env, data))
243
+ return "copilot-visible-identity-missing";
244
+ return null;
245
+ }
246
+ /** Repair text, one line per reason. A reason a caller cannot act on is a reason they will
247
+ * guess about — each names the exact command that installs the missing capability. */
248
+ export const COPILOT_PREFLIGHT_HINT = {
249
+ "copilot-birth-unit-missing": "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",
250
+ "copilot-mcp-hand-missing": "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",
251
+ "copilot-receive-unit-missing": "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",
252
+ "copilot-visible-identity-missing": "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",
253
+ };
@@ -37,8 +37,16 @@
37
37
  */
38
38
  import { isPlausibleOwnerPid, metaRecordExistsByGardenId, parentPid, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
39
39
  import { resolveNativePushAdapter } from "./native-push/adapter.js";
40
- /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
41
- export const META_SENDER_BACKENDS = ["claude-code", "antigravity"];
40
+ /** Every native backend that mints a garden-id from its own hook and writes a sender marker.
41
+ *
42
+ * WRITER AND READER OPEN TOGETHER OR NOT AT ALL. A backend listed here whose hook writes no
43
+ * marker costs one wasted directory read; a backend whose hook writes a marker but is absent
44
+ * here is INVISIBLE — the bridge holds the owner pid, never looks in that directory, and the
45
+ * citizen's sends are refused as anonymous for a reason nothing in the log names. That was
46
+ * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
47
+ * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
48
+ * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
49
+ export const META_SENDER_BACKENDS = ["claude-code", "antigravity", "copilot"];
42
50
  /**
43
51
  * Two live native sessions claim this MCP process as their own. We can SEE both identities but
44
52
  * cannot say which one is calling, so we send under neither. Choosing by lookup order, backend