@junghanacs/entwurf 0.15.0 → 0.16.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 +1 -0
- package/CHANGELOG.md +299 -0
- package/DELIVERY.md +4 -3
- package/README.md +99 -67
- package/VERIFY.md +13 -7
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +221 -16
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
- package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -19
- package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
- package/mcp/tsconfig.json +10 -0
- package/package.json +14 -4
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
- package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
- package/pi-extensions/entwurf-control.ts +12 -9
- package/pi-extensions/lib/entwurf-self-address.ts +18 -7
- package/pi-extensions/lib/meta-sender-identity.ts +1 -1
- package/pi-extensions/lib/meta-session.ts +219 -5
- package/pi-extensions/lib/mux-fresh-call.ts +171 -17
- package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
- package/pi-extensions/meta-bridge-omp.ts +1244 -0
- package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
- package/run.sh +1041 -174
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +700 -23
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-fresh-cut-gate.sh +12 -4
- package/scripts/check-gate-qualification.ts +8 -2
- package/scripts/check-harness-admission-parity.ts +143 -0
- package/scripts/check-meta-doctor-oracle.sh +20 -0
- package/scripts/check-omp-birth-hook.ts +1049 -0
- package/scripts/check-omp-fresh-preflight.ts +181 -0
- package/scripts/check-omp-receive-arm.ts +516 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +104 -71
- package/scripts/copilot-bridge-install.sh +180 -20
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/dev-bin.sh +50 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/copilot-birth.json +96 -5
- package/scripts/mutants/mux-fresh-call.json +22 -22
- package/scripts/mutants/omp-birth.json +173 -0
- package/scripts/mutants/omp-fresh.json +300 -0
- package/scripts/mutants/omp-receive.json +135 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/omp-bridge-doctor.sh +315 -0
- package/scripts/omp-bridge-install.sh +221 -0
- package/scripts/omp-bridge-oracle.sh +154 -0
- package/scripts/omp-bridge-uninstall.sh +57 -0
- package/scripts/omp-mcp-bridge.sh +320 -0
- package/scripts/omp-mcp-config.py +392 -0
- package/scripts/omp-receive-doctor.sh +246 -0
- package/scripts/omp-receive-facts.ts +106 -0
- package/scripts/omp-receive-install.sh +228 -0
- package/scripts/omp-receive-uninstall.sh +60 -0
- package/scripts/omp-tool-surface.py +349 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-omp-measure/README.md +420 -0
- package/scripts/raw-omp-measure/probe-extension.ts +76 -0
- package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
- package/scripts/raw-omp-measure/source-audit.md +414 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-omp-bridge-state.sh +221 -0
- package/scripts/smoke-omp-fresh-live.ts +497 -0
- package/scripts/smoke-omp-mcp-state.sh +327 -0
- package/scripts/smoke-omp-receive-live.ts +340 -0
- package/scripts/smoke-omp-receive-state.sh +196 -0
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- package/scripts/tsconfig.json +2 -0
|
@@ -49,9 +49,13 @@ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
|
|
|
49
49
|
import { COPILOT_PREFLIGHT_HINT, copilotFreshPreflight, } from "./copilot-fresh-preflight.js";
|
|
50
50
|
import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
|
|
51
51
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
52
|
+
import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
|
|
52
53
|
/** 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
|
-
|
|
54
|
+
* not a config entry. `copilot` was added by #82 RAIL 9 under the step 9 admission contract, and
|
|
55
|
+
* `omp` by #87 Bundle C under the same one. The set is joined to the citizen backends by
|
|
56
|
+
* `check-harness-admission-parity`: a harness that mints records but is missing HERE is not an
|
|
57
|
+
* unwired convenience, it is a release blocker. */
|
|
58
|
+
export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp"];
|
|
55
59
|
/**
|
|
56
60
|
* The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
|
|
57
61
|
* than a compiled-in location: the official binary is whatever the operator's environment gives
|
|
@@ -65,11 +69,20 @@ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot"];
|
|
|
65
69
|
* flag, its recursion fence and its receiver precondition; fresh call reaches Copilot only
|
|
66
70
|
* through it. The cost is named: a Copilot fresh call needs a current `entwurf` on PATH, the
|
|
67
71
|
* way a pi fresh call needs `pi`.
|
|
72
|
+
*
|
|
73
|
+
* `omp` resolves the BARE vendor, and that difference is a measured one rather than an
|
|
74
|
+
* inconsistency. Copilot needs a managed wrapper because the bare CLI starts without
|
|
75
|
+
* `COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS` and skips its extension scan silently — there
|
|
76
|
+
* is a flag only a launcher can carry. omp has no such flag: it always scans its extensions
|
|
77
|
+
* directory, and the one thing it needs beyond that (`tools.xdev: false`) lives in the operator
|
|
78
|
+
* config, which is a PREFLIGHT fact and not something a launcher could supply. Inventing an
|
|
79
|
+
* `entwurf omp` verb here would add a managed surface with nothing to manage.
|
|
68
80
|
*/
|
|
69
81
|
export const FRESH_CALL_RUNTIME = {
|
|
70
82
|
pi: "pi",
|
|
71
83
|
"claude-code": "claude",
|
|
72
84
|
copilot: "entwurf",
|
|
85
|
+
omp: "omp",
|
|
73
86
|
};
|
|
74
87
|
/**
|
|
75
88
|
* The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
|
|
@@ -82,18 +95,28 @@ export const FRESH_CALL_RUNTIME = {
|
|
|
82
95
|
* `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
|
|
83
96
|
* `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
|
|
84
97
|
* sibling's spelling (`docs/adding-a-harness.md` step 5).
|
|
98
|
+
*
|
|
99
|
+
* `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
|
|
100
|
+
* `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
|
|
101
|
+
* `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
|
|
102
|
+
* hyphen in the server key becomes an underscore — the model-facing name is
|
|
103
|
+
* `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
|
|
104
|
+
* Confirmed against a live tool dump of all seven bridge tools and a real session transcript
|
|
105
|
+
* (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
|
|
106
|
+
* permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
|
|
85
107
|
*/
|
|
86
108
|
export const FRESH_CALL_CALLBACK_TOOL = {
|
|
87
109
|
pi: "entwurf_v2",
|
|
88
110
|
"claude-code": "mcp__entwurf-bridge__entwurf_v2",
|
|
89
111
|
copilot: "entwurf-bridge-entwurf_v2",
|
|
112
|
+
omp: "mcp__entwurf_bridge_entwurf_v",
|
|
90
113
|
};
|
|
91
114
|
/** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
|
|
92
115
|
* delivery surface, not a claim that a task of this size was measured through tmux. An argv
|
|
93
116
|
* that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
|
|
94
117
|
export const TASK_MAX_CHARS = 16000;
|
|
95
118
|
export const MODEL_MAX_CHARS = 200;
|
|
96
|
-
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._
|
|
119
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
|
|
97
120
|
/** A model is an explicit launch input, not ambient process state. The grammar admits canonical
|
|
98
121
|
* pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
|
|
99
122
|
* whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
|
|
@@ -112,6 +135,11 @@ export function isSafeFreshCallModel(model) {
|
|
|
112
135
|
* copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
|
|
113
136
|
* `--model`, value as two tokens, and the policy as the explicit `--yolo`
|
|
114
137
|
* token. Measured from `copilot --help` (1.0.80).
|
|
138
|
+
* omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
|
|
139
|
+
* flag the installed birth extension registers, then `--model`, value as two
|
|
140
|
+
* tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
|
|
141
|
+
* the flag this argv must never carry — it processes a prompt and EXITS,
|
|
142
|
+
* closing the window on a sibling that has to stay open to be delivered to.
|
|
115
143
|
*
|
|
116
144
|
* Both pi/claude failures looked identical from outside: window open, record and socket minted,
|
|
117
145
|
* no turn.
|
|
@@ -144,17 +172,89 @@ export function isSafeFreshCallModel(model) {
|
|
|
144
172
|
* `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
|
|
145
173
|
* model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
|
|
146
174
|
* example; it is a measured vendor fact even though this argv no longer uses it.
|
|
175
|
+
*
|
|
176
|
+
* OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
|
|
177
|
+
* `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
|
|
178
|
+
* (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
|
|
179
|
+
* acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
|
|
180
|
+
* still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
|
|
181
|
+
* default and on whatever the operator's config happens to say. That is exactly the drift step 9
|
|
182
|
+
* clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
|
|
183
|
+
* launcher's injected default" — and the reason the width is stated here even though the host
|
|
184
|
+
* would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
|
|
185
|
+
* operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
|
|
186
|
+
* sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
|
|
187
|
+
* grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
|
|
188
|
+
* so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
|
|
189
|
+
* `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
|
|
190
|
+
* the space form is used for symmetry with `--model`.
|
|
191
|
+
*
|
|
192
|
+
* WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
|
|
193
|
+
* `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
|
|
194
|
+
* positional. The window opened, the record minted (garden `20260830T181342-452167`), the
|
|
195
|
+
* prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
|
|
196
|
+
* the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
|
|
197
|
+
* `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
|
|
198
|
+
* `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
|
|
199
|
+
* `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
|
|
200
|
+
* `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
|
|
201
|
+
* entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
|
|
202
|
+
* the tool it was told to call existed. No argv can close that gap, because the gap is a race
|
|
203
|
+
* inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
|
|
204
|
+
* in-process birth extension — which can see when the tool becomes callable — owns the first
|
|
205
|
+
* two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
|
|
206
|
+
*
|
|
207
|
+
* The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
|
|
208
|
+
* extension that registers a flag receives the operator's argv value byte-identical — quotes,
|
|
209
|
+
* `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
|
|
210
|
+
* load before argv classification and the reparse writes the registered map
|
|
211
|
+
* (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
|
|
212
|
+
* have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
|
|
213
|
+
* all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
|
|
214
|
+
* carrier would hand callers the launch-shaping power this rail exists to refuse.
|
|
147
215
|
*/
|
|
148
|
-
export function buildBackendArgs(backend,
|
|
216
|
+
export function buildBackendArgs(backend, composition, model) {
|
|
149
217
|
switch (backend) {
|
|
150
218
|
case "pi":
|
|
151
|
-
return [prompt, "--entwurf-control", "--model", model];
|
|
219
|
+
return [composition.prompt, "--entwurf-control", "--model", model];
|
|
152
220
|
case "claude-code":
|
|
153
|
-
return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
|
|
221
|
+
return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
|
|
154
222
|
case "copilot":
|
|
155
|
-
return ["copilot", "--interactive", prompt, "--model", model, "--yolo"];
|
|
223
|
+
return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
|
|
224
|
+
case "omp":
|
|
225
|
+
return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
|
|
156
226
|
}
|
|
157
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
|
|
230
|
+
* name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
|
|
231
|
+
*
|
|
232
|
+
* Held equal to the installed extension's own constant by
|
|
233
|
+
* `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
|
|
234
|
+
* ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
|
|
235
|
+
* module; the gate is what keeps the duplication from becoming drift.
|
|
236
|
+
*/
|
|
237
|
+
export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
|
|
238
|
+
/** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
|
|
239
|
+
* unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
|
|
240
|
+
export const OMP_BOOTSTRAP_VERSION = 1;
|
|
241
|
+
/**
|
|
242
|
+
* The whole of what a fresh omp sibling is launched with.
|
|
243
|
+
*
|
|
244
|
+
* THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
|
|
245
|
+
* contract: who to call back, the nonce that proves it is this call, and the task that is
|
|
246
|
+
* released only after that callback succeeds. There is no command here, no path, no env name
|
|
247
|
+
* and no model — the model is already an explicit argv token, and a second copy of it inside
|
|
248
|
+
* a payload would be a second place for it to disagree with the launch.
|
|
249
|
+
*/
|
|
250
|
+
export function buildOmpBootstrapPayload(params) {
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
v: OMP_BOOTSTRAP_VERSION,
|
|
253
|
+
target: params.callerGardenId,
|
|
254
|
+
nonce: params.nonce,
|
|
255
|
+
task: params.task,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
158
258
|
/**
|
|
159
259
|
* The first-turn framing. Order is the contract: the callback is the FIRST action and the task
|
|
160
260
|
* follows it, so a sibling that gets stuck in the task has already told the caller who it is.
|
|
@@ -189,9 +289,34 @@ export function mintNonce(randomHex = defaultRandomHex) {
|
|
|
189
289
|
function defaultRandomHex() {
|
|
190
290
|
return randomBytes(12).toString("hex");
|
|
191
291
|
}
|
|
192
|
-
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
292
|
+
/**
|
|
293
|
+
* The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
|
|
294
|
+
*
|
|
295
|
+
* `[측정]` 2026-08-30, private tmux server: a `new-window` pane inherits the tmux SERVER's
|
|
296
|
+
* environment, not the caller's. A server started from a shell that exported `PI_SESSION_ID`
|
|
297
|
+
* hands that value to every window it will ever open — the control run printed
|
|
298
|
+
* `SID=[leaked-uuid]` in a pane the caller never touched. Nothing about the fresh call creates
|
|
299
|
+
* that leak and nothing about it notices: the sibling's own MCP child would read the STALE pair
|
|
300
|
+
* as its authoritative identity and call home as a citizen it is not (`mcp/entwurf-bridge/
|
|
301
|
+
* src/index.ts:692-698` keeps the measured incident — a fresh cell answering with the uuidv7 it
|
|
302
|
+
* found in the environment, confidently and wrong).
|
|
303
|
+
*
|
|
304
|
+
* `-e VAR=` sets the variable EMPTY rather than unsetting it, which tmux has no per-window form
|
|
305
|
+
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
306
|
+
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
307
|
+
*
|
|
308
|
+
* It is applied to all four backends because the leak is a property of tmux, not of a vendor. A
|
|
309
|
+
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
310
|
+
* three are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
311
|
+
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
312
|
+
* exactly that after this argv has run. This is a fixed two-variable seam and deliberately NOT a
|
|
313
|
+
* general env carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping
|
|
314
|
+
* power this rail exists to refuse.
|
|
315
|
+
*/
|
|
316
|
+
const SCRUBBED_INHERITED_ENV = ["PI_SESSION_ID=", "PI_AGENT_ID="];
|
|
317
|
+
/** Launch argv: the leaf's detached-append shape, the identity scrub, optionally `-c` at the
|
|
318
|
+
* resume-symmetric token position (after `-t`, before `-P -F`), the runtime, then the backend's
|
|
319
|
+
* dialect. An omitted cwd adds no `-c` carrier at all. */
|
|
195
320
|
export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
|
|
196
321
|
assertSelector("session", placement.sessionId);
|
|
197
322
|
assertLaunchTarget(runtimePath);
|
|
@@ -204,6 +329,7 @@ export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
|
|
|
204
329
|
"new-window",
|
|
205
330
|
"-d",
|
|
206
331
|
"-a",
|
|
332
|
+
...SCRUBBED_INHERITED_ENV.flatMap((assignment) => ["-e", assignment]),
|
|
207
333
|
"-t",
|
|
208
334
|
`${placement.sessionId}:{end}`,
|
|
209
335
|
...(cwd === undefined ? [] : ["-c", cwd]),
|
|
@@ -267,18 +393,26 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
|
|
|
267
393
|
if (missing)
|
|
268
394
|
return { ok: false, reason: missing };
|
|
269
395
|
}
|
|
396
|
+
if (params.backend === "omp") {
|
|
397
|
+
const missing = ompFreshPreflight(env);
|
|
398
|
+
if (missing)
|
|
399
|
+
return { ok: false, reason: missing };
|
|
400
|
+
}
|
|
270
401
|
const inspected = inspectPlacement(env);
|
|
271
402
|
if (!inspected.ok)
|
|
272
403
|
return { ok: false, reason: inspected.reason };
|
|
273
404
|
const placement = inspected.placement;
|
|
274
405
|
requireSameContext("freshCall", placement, env);
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
406
|
+
const composition = {
|
|
407
|
+
prompt: buildFreshCallPrompt({
|
|
408
|
+
backend: params.backend,
|
|
409
|
+
task,
|
|
410
|
+
callerGardenId: params.callerGardenId,
|
|
411
|
+
nonce,
|
|
412
|
+
}),
|
|
413
|
+
bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
|
|
414
|
+
};
|
|
415
|
+
const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, composition, model), cwd), env);
|
|
282
416
|
assertTmuxOk("new-window", run);
|
|
283
417
|
let fields;
|
|
284
418
|
try {
|
|
@@ -310,6 +444,7 @@ const REJECT_HINT = {
|
|
|
310
444
|
// The Copilot capability reasons keep their repair text on the leaf that decides them, so
|
|
311
445
|
// the sentence an operator reads cannot drift away from the predicate that produced it.
|
|
312
446
|
...COPILOT_PREFLIGHT_HINT,
|
|
447
|
+
...OMP_PREFLIGHT_HINT,
|
|
313
448
|
"no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
|
|
314
449
|
"anchor-malformed": "TMUX_PANE is not a native pane id",
|
|
315
450
|
"anchor-unresolved": "tmux resolved no pane for this agent's anchor",
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omp-fresh-preflight — the pre-mutation capability check an OMP fresh call needs
|
|
3
|
+
* (#87 Bundle C, `docs/adding-a-harness.md` step 9 clauses 3, 4 and 5).
|
|
4
|
+
*
|
|
5
|
+
* Same shape and the same reasons as `copilot-fresh-preflight.ts`: a doctor answers "is this
|
|
6
|
+
* host correctly wired?" and may be slow, spawn the vendor and read live processes. This is not
|
|
7
|
+
* that. It answers ONE narrower question at ONE moment — before `mux-fresh-call` mutates the
|
|
8
|
+
* operator's tmux session, are the things the fresh contract requires actually in place on this
|
|
9
|
+
* filesystem?
|
|
10
|
+
*
|
|
11
|
+
* ── Why OMP has FIVE axes where Copilot has four ──
|
|
12
|
+
*
|
|
13
|
+
* birth — without the birth extension the session mints no record, so the callback
|
|
14
|
+
* carries no garden id and the sibling never becomes addressable.
|
|
15
|
+
* MCP hand — without the native `entwurf-bridge` server the callback tool does not
|
|
16
|
+
* exist in that session and the first turn has nothing to call.
|
|
17
|
+
* receive — without the receiver extension the sibling can be launched and can call
|
|
18
|
+
* home, and then nothing can ever be delivered TO it.
|
|
19
|
+
* visible identity — the garden id must be on the harness's own persistent surface.
|
|
20
|
+
* callback callable — THE OMP-SPECIFIC ONE. `tools.xdev` defaults to TRUE, which mounts MCP
|
|
21
|
+
* tools as `xd://` devices whose schemas never reach the prompt. The tool
|
|
22
|
+
* would be configured, the bridge would be running, and the model still
|
|
23
|
+
* could not call `mcp__entwurf_bridge_entwurf_v`. Step 9 clause 5 makes the
|
|
24
|
+
* callback the FIRST action, so a fresh call onto a default-config host
|
|
25
|
+
* opens a window that can never name itself. `[측정]` #87 A-lane: the
|
|
26
|
+
* vendor default wrapped the send tool and produced a false delivery report.
|
|
27
|
+
*
|
|
28
|
+
* Copilot's fourth axis is a statusline COMMAND that must resolve on PATH. OMP has no such
|
|
29
|
+
* surface: `ctx.ui.setStatus` inside the birth extension is the only thing that renders
|
|
30
|
+
* extension-owned text on a v18 TUI (`pi-extensions/meta-bridge-omp.ts:163-169`), and the
|
|
31
|
+
* vendor gates it on `statusLine.showHookStatus` (default true). So visible identity here is
|
|
32
|
+
* "the birth extension is installed AND the operator has not turned hook status off" — a
|
|
33
|
+
* different predicate for the same clause, derived rather than copied.
|
|
34
|
+
*
|
|
35
|
+
* ── What this deliberately does NOT claim ──
|
|
36
|
+
*
|
|
37
|
+
* Ownership/configuration truth only. It does NOT prove omp loaded the extensions, connected
|
|
38
|
+
* the MCP server, or rendered a garden id — that is runtime truth and belongs to
|
|
39
|
+
* `doctor-omp-bridge` / `doctor-omp-receive` / `doctor-omp-mcp` and to the clause 7 LIVE
|
|
40
|
+
* receipt. A green preflight is a statement about this filesystem, not a prediction about the
|
|
41
|
+
* next process.
|
|
42
|
+
*
|
|
43
|
+
* No vendor spawn, no network, no await, no mutation.
|
|
44
|
+
*/
|
|
45
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
46
|
+
import * as path from "node:path";
|
|
47
|
+
const BIRTH_UNIT = "entwurf-meta-omp";
|
|
48
|
+
const RECEIVE_UNIT = "entwurf-receive-omp";
|
|
49
|
+
const MCP_SERVER_KEY = "entwurf-bridge";
|
|
50
|
+
/** Vendor load order, `utils/src/dirs.ts` MAIN_CONFIG_FILENAMES. */
|
|
51
|
+
const MAIN_CONFIG_FILENAMES = ["config.yml", "config.yaml"];
|
|
52
|
+
/** `utils/src/dirs.ts` PROFILE_NAME_RE, reproduced from `scripts/omp-bridge-oracle.sh`. */
|
|
53
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
54
|
+
/**
|
|
55
|
+
* The agent directory omp itself would read, or `null` for REFUSE.
|
|
56
|
+
*
|
|
57
|
+
* This is `omp_agent_dir` from `scripts/omp-bridge-oracle.sh`, expressed in the language this
|
|
58
|
+
* half is written in. It is a reproduction on purpose and not a spawn of that script: the fresh
|
|
59
|
+
* lane runs inside the pi extension and inside the bundled MCP child, and resolving a sibling
|
|
60
|
+
* shell script by relative path from two different emit depths is the exact arithmetic
|
|
61
|
+
* `check-capability-bundle-reach` exists to catch. The AGREEMENT of the two implementations is
|
|
62
|
+
* pinned by `check-omp-fresh-preflight`, which drives both over the same environments.
|
|
63
|
+
*
|
|
64
|
+
* A `PI_*` knob is a refusal and never a lookup (#87 ledger M6): omp is a pi fork that kept pi's
|
|
65
|
+
* env vocabulary, so `PI_CODING_AGENT_DIR` steers TWO harnesses and a value in the environment
|
|
66
|
+
* does not say which one it is addressing. Guessing here would aim the preflight at a directory
|
|
67
|
+
* no live omp reads, and it would report green off an empty one.
|
|
68
|
+
*/
|
|
69
|
+
export function ompAgentDir(env) {
|
|
70
|
+
const explicit = env.ENTWURF_OMP_AGENT_DIR;
|
|
71
|
+
if (typeof explicit === "string" && explicit.length > 0) {
|
|
72
|
+
const home = env.HOME;
|
|
73
|
+
const expanded = explicit === "~" || explicit.startsWith("~/")
|
|
74
|
+
? typeof home === "string" && home.length > 0
|
|
75
|
+
? path.join(home, explicit.slice(1))
|
|
76
|
+
: null
|
|
77
|
+
: explicit;
|
|
78
|
+
return expanded === null ? null : path.resolve(expanded);
|
|
79
|
+
}
|
|
80
|
+
if (typeof env.PI_CODING_AGENT_DIR === "string" && env.PI_CODING_AGENT_DIR.length > 0)
|
|
81
|
+
return null;
|
|
82
|
+
if (typeof env.PI_CONFIG_DIR === "string" && env.PI_CONFIG_DIR.length > 0)
|
|
83
|
+
return null;
|
|
84
|
+
const ompProfile = env.OMP_PROFILE;
|
|
85
|
+
const hasOmpProfile = typeof ompProfile === "string" && ompProfile.length > 0;
|
|
86
|
+
if (typeof env.PI_PROFILE === "string" && env.PI_PROFILE.length > 0 && !hasOmpProfile)
|
|
87
|
+
return null;
|
|
88
|
+
const home = env.HOME;
|
|
89
|
+
if (typeof home !== "string" || home.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
if (hasOmpProfile) {
|
|
92
|
+
if (!PROFILE_NAME_RE.test(ompProfile))
|
|
93
|
+
return null;
|
|
94
|
+
return path.join(home, ".omp", "profiles", ompProfile, "agent");
|
|
95
|
+
}
|
|
96
|
+
return path.join(home, ".omp", "agent");
|
|
97
|
+
}
|
|
98
|
+
function isDir(p) {
|
|
99
|
+
try {
|
|
100
|
+
return statSync(p).isDirectory();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Bounded environment probing, Hard Rule 15's stated exception.
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** A JSON object or nothing. Unreadable, unparseable and not-an-object are the SAME answer,
|
|
108
|
+
* because the caller's next move is identical in all three: run the installer. */
|
|
109
|
+
function readJsonObject(file) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
112
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
113
|
+
? parsed
|
|
114
|
+
: null;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The effective value of one TOP-LEVEL-then-one-key path in omp's config, as a tri-state:
|
|
122
|
+
* `true` / `false` / `null` for "not stated, or not readable with confidence".
|
|
123
|
+
*
|
|
124
|
+
* A deliberately NARROW block-YAML reader — it understands exactly the shape these two settings
|
|
125
|
+
* are written in (`tools:` newline, two-space `xdev: false`) and answers `null` for everything
|
|
126
|
+
* else. It mirrors `scripts/omp-tool-surface.py`'s scalar vocabulary for booleans, including its
|
|
127
|
+
* `true/yes/on/y` word set and its comment stripping, but it does NOT try to be that parser: the
|
|
128
|
+
* Python leaf reports a full verdict for a doctor, this one answers one question for a refusal.
|
|
129
|
+
*
|
|
130
|
+
* The tri-state is what makes both callers able to fail in the direction their axis needs, so
|
|
131
|
+
* neither has to invent a default here.
|
|
132
|
+
*/
|
|
133
|
+
export function readOmpConfigFlag(agentDir, section, key) {
|
|
134
|
+
let text = null;
|
|
135
|
+
for (const name of MAIN_CONFIG_FILENAMES) {
|
|
136
|
+
const candidate = path.join(agentDir, name);
|
|
137
|
+
if (!existsSync(candidate))
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
if (statSync(candidate).isDirectory())
|
|
141
|
+
return null;
|
|
142
|
+
text = readFileSync(candidate, "utf8");
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (text === null)
|
|
150
|
+
return null;
|
|
151
|
+
// A tab anywhere in the body makes this not-YAML for the vendor's own reader; refuse rather
|
|
152
|
+
// than guess which indentation the vendor would have seen.
|
|
153
|
+
let inSection = false;
|
|
154
|
+
// The indent of the section's IMMEDIATE children, learned from the first one. Matching a key
|
|
155
|
+
// at any deeper indent would read `tools.nested.xdev` as `tools.xdev` — a fail-OPEN misread
|
|
156
|
+
// that would preflight green off a config which never set the flag. Caught by
|
|
157
|
+
// `check-omp-fresh-preflight` when this reader was first compared against the python leaf.
|
|
158
|
+
let childIndent = -1;
|
|
159
|
+
for (const raw of text.split("\n")) {
|
|
160
|
+
const body = stripComment(raw);
|
|
161
|
+
if (body.trim() === "")
|
|
162
|
+
continue;
|
|
163
|
+
if (body.includes("\t"))
|
|
164
|
+
return null;
|
|
165
|
+
const indent = body.length - body.trimStart().length;
|
|
166
|
+
const trimmed = body.trim();
|
|
167
|
+
if (!inSection) {
|
|
168
|
+
if (indent === 0 && trimmed === `${section}:`)
|
|
169
|
+
inSection = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (indent === 0)
|
|
173
|
+
break; // the section ended at the next top-level key
|
|
174
|
+
if (childIndent === -1)
|
|
175
|
+
childIndent = indent;
|
|
176
|
+
if (indent !== childIndent)
|
|
177
|
+
continue; // a grandchild, or a mis-indented line: not our key
|
|
178
|
+
const colon = trimmed.indexOf(":");
|
|
179
|
+
if (colon <= 0)
|
|
180
|
+
continue;
|
|
181
|
+
if (trimmed.slice(0, colon).trim() !== key)
|
|
182
|
+
continue;
|
|
183
|
+
return parseBool(trimmed.slice(colon + 1).trim());
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
/** Comment stripping with the same quote awareness as `omp-tool-surface.py:strip_comment`. */
|
|
188
|
+
function stripComment(raw) {
|
|
189
|
+
let inSingle = false;
|
|
190
|
+
let inDouble = false;
|
|
191
|
+
let escaped = false;
|
|
192
|
+
for (let i = 0; i < raw.length; i++) {
|
|
193
|
+
const ch = raw[i];
|
|
194
|
+
if (escaped) {
|
|
195
|
+
escaped = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (ch === "\\" && inDouble) {
|
|
199
|
+
escaped = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "'" && !inDouble)
|
|
203
|
+
inSingle = !inSingle;
|
|
204
|
+
else if (ch === '"' && !inSingle)
|
|
205
|
+
inDouble = !inDouble;
|
|
206
|
+
else if (ch === "#" && !inSingle && !inDouble)
|
|
207
|
+
return raw.slice(0, i).trimEnd();
|
|
208
|
+
}
|
|
209
|
+
return raw.trimEnd();
|
|
210
|
+
}
|
|
211
|
+
/** `omp-tool-surface.py:parse_scalar`'s boolean vocabulary; anything else is "not a boolean". */
|
|
212
|
+
function parseBool(text) {
|
|
213
|
+
const folded = text.toLowerCase();
|
|
214
|
+
if (["true", "yes", "on", "y"].includes(folded))
|
|
215
|
+
return true;
|
|
216
|
+
if (["false", "no", "off", "n"].includes(folded))
|
|
217
|
+
return false;
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The five axes, decided in the order an operator should repair them. Returns the FIRST missing
|
|
222
|
+
* capability, or `null` when every one is in place.
|
|
223
|
+
*
|
|
224
|
+
* Order is not cosmetic. The agent dir comes first because every other predicate is a path under
|
|
225
|
+
* it — reporting "birth unit missing" while the directory itself is ambiguous would send the
|
|
226
|
+
* operator to reinstall into a directory omp may never read. Birth precedes the rest because a
|
|
227
|
+
* host with no birth unit has nothing else worth checking, and visible identity depends on the
|
|
228
|
+
* same unit.
|
|
229
|
+
*/
|
|
230
|
+
export function ompFreshPreflight(env) {
|
|
231
|
+
const agentDir = ompAgentDir(env);
|
|
232
|
+
if (agentDir === null)
|
|
233
|
+
return "omp-agent-dir-ambiguous";
|
|
234
|
+
const extensions = path.join(agentDir, "extensions");
|
|
235
|
+
if (!isDir(path.join(extensions, BIRTH_UNIT)))
|
|
236
|
+
return "omp-birth-unit-missing";
|
|
237
|
+
// The native MCP entry omp reads, at the ONE non-configurable path the installer owns
|
|
238
|
+
// (`scripts/omp-mcp-bridge.sh`: `<resolved omp agent dir>/mcp.json`).
|
|
239
|
+
const mcp = readJsonObject(path.join(agentDir, "mcp.json"));
|
|
240
|
+
const servers = mcp?.mcpServers;
|
|
241
|
+
const hand = typeof servers === "object" && servers !== null && !Array.isArray(servers)
|
|
242
|
+
? servers[MCP_SERVER_KEY]
|
|
243
|
+
: undefined;
|
|
244
|
+
if (typeof hand !== "object" || hand === null || Array.isArray(hand))
|
|
245
|
+
return "omp-mcp-hand-missing";
|
|
246
|
+
if (!isDir(path.join(extensions, RECEIVE_UNIT)))
|
|
247
|
+
return "omp-receive-unit-missing";
|
|
248
|
+
// Visible identity: the vendor default is TRUE, so only an explicit false refuses. An
|
|
249
|
+
// unreadable config is not a refusal HERE — it is one on the axis below, which needs proof
|
|
250
|
+
// rather than absence, and reporting the same file twice under two names would send an
|
|
251
|
+
// operator looking for two problems.
|
|
252
|
+
if (readOmpConfigFlag(agentDir, "statusLine", "showHookStatus") === false)
|
|
253
|
+
return "omp-visible-identity-missing";
|
|
254
|
+
// Callback callable: the vendor default is TRUE and true is the BROKEN state, so this axis
|
|
255
|
+
// requires positive proof of `false`. Absent file, absent key and unparseable config all
|
|
256
|
+
// refuse — that is the fail-closed direction, and it is the opposite of the axis above for
|
|
257
|
+
// the same reason: each fails toward the value the vendor would actually apply.
|
|
258
|
+
if (readOmpConfigFlag(agentDir, "tools", "xdev") !== false)
|
|
259
|
+
return "omp-callback-tool-uncallable";
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
/** Repair text lives on the leaf that decides the predicate, so the sentence an operator reads
|
|
263
|
+
* cannot drift away from the check that produced it. */
|
|
264
|
+
export const OMP_PREFLIGHT_HINT = {
|
|
265
|
+
"omp-agent-dir-ambiguous": "an inherited PI_CODING_AGENT_DIR / PI_CONFIG_DIR / PI_PROFILE makes it ambiguous which agent directory omp would read (omp is a pi fork and shares those names) — unset it, or set ENTWURF_OMP_AGENT_DIR explicitly; guessing would preflight a directory no live omp reads",
|
|
266
|
+
"omp-birth-unit-missing": "this host has no OMP birth extension, so the sibling would mint no record and its callback would carry no garden id — run `entwurf install-omp-bridge`",
|
|
267
|
+
"omp-mcp-hand-missing": "this host has no entwurf-bridge server in omp's own mcp.json, so the callback tool would not exist in that session — run `entwurf install-omp-mcp`",
|
|
268
|
+
"omp-receive-unit-missing": "this host has no OMP receiver extension, so the sibling could call home and nothing could ever be delivered to it — run `entwurf install-omp-receive`",
|
|
269
|
+
"omp-visible-identity-missing": "omp's statusLine.showHookStatus is set to false, so the citizen's garden id would render nowhere on its own TUI — remove that setting from the omp config",
|
|
270
|
+
"omp-callback-tool-uncallable": "omp's tools.xdev is not set to false, so MCP tools mount as xd:// devices whose schemas never reach the prompt and the model cannot call the callback tool — set `tools: xdev: false` in the omp agent config",
|
|
271
|
+
};
|