@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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +79 -0
- package/DELIVERY.md +72 -59
- package/README.md +55 -4
- package/VERIFY.md +2 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +334 -16
- package/scripts/check-copilot-birth-hook.ts +497 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +7 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +4 -1
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/tsconfig.json +1 -0
package/docs/setup-clean-host.md
CHANGED
|
@@ -11,7 +11,7 @@ only on Linux because its strict live-owner join uses `/proc`.
|
|
|
11
11
|
| Node | **`>=24.0.0`** | package and bridge runtime |
|
|
12
12
|
| npm | bundled with Node | package installation |
|
|
13
13
|
| entwurf | `@junghanacs/entwurf` | all lanes |
|
|
14
|
-
| pi | optional, `@earendil-works/pi-coding-agent >=0.84.
|
|
14
|
+
| pi | optional, `@earendil-works/pi-coding-agent >=0.84.3 <0.85` | ACP provider, control sockets |
|
|
15
15
|
| Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
|
|
16
16
|
| Antigravity `agy` | optional, operator-installed and authenticated | native-push citizen |
|
|
17
17
|
| Cortex Code | optional, operator-installed and authenticated | Cortex ACP backend |
|
|
@@ -56,7 +56,7 @@ lists the seven garden tools; it does not prove a backend model turn or native h
|
|
|
56
56
|
Install the exact release floor, then wire the project:
|
|
57
57
|
|
|
58
58
|
```bash
|
|
59
|
-
npm install -g @earendil-works/pi-coding-agent@0.84.
|
|
59
|
+
npm install -g @earendil-works/pi-coding-agent@0.84.3
|
|
60
60
|
pi --version
|
|
61
61
|
|
|
62
62
|
cd ~/entwurf-smoke
|
|
@@ -64,7 +64,7 @@ entwurf install .
|
|
|
64
64
|
pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The supported range is `>=0.84.
|
|
67
|
+
The supported range is `>=0.84.3 <0.85`. It is a hard minimum: installing this
|
|
68
68
|
release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
|
|
69
69
|
minor. A host using only the external MCP bridge can skip pi until it needs a
|
|
70
70
|
control socket; no delivery rail launches a pi process.
|
|
@@ -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.
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
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
|
|
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
|
|
530
|
-
*
|
|
531
|
-
*
|
|
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
|
-
|
|
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.
|
|
753
|
-
*
|
|
754
|
-
*
|
|
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
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
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 = [
|
|
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;
|