@junghanacs/entwurf 0.15.1 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/DELIVERY.md +3 -2
  3. package/README.md +70 -87
  4. package/VERIFY.md +4 -1
  5. package/docs/acp-backend-rail.md +1 -1
  6. package/docs/external-mcp-host.md +147 -9
  7. package/docs/setup-clean-host.md +105 -0
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  9. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  17. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  18. package/mcp/entwurf-bridge/src/index.ts +65 -19
  19. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  20. package/mcp/tsconfig.json +10 -0
  21. package/package.json +13 -3
  22. package/pi/entwurf-capabilities.json +1 -0
  23. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  24. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  25. package/pi-extensions/entwurf-control.ts +12 -9
  26. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  27. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  28. package/pi-extensions/lib/meta-session.ts +219 -5
  29. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  30. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  31. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  32. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  33. package/run.sh +296 -4
  34. package/scripts/check-entwurf-self-address.ts +31 -0
  35. package/scripts/check-gate-qualification.ts +4 -1
  36. package/scripts/check-harness-admission-parity.ts +143 -0
  37. package/scripts/check-meta-doctor-oracle.sh +20 -0
  38. package/scripts/check-omp-birth-hook.ts +1049 -0
  39. package/scripts/check-omp-fresh-preflight.ts +181 -0
  40. package/scripts/check-omp-receive-arm.ts +516 -0
  41. package/scripts/inventory-verification-surface.ts +1 -1
  42. package/scripts/meta-bridge-hook-log.sh +9 -1
  43. package/scripts/mutants/copilot-birth.json +7 -5
  44. package/scripts/mutants/mux-fresh-call.json +22 -22
  45. package/scripts/mutants/omp-birth.json +173 -0
  46. package/scripts/mutants/omp-fresh.json +300 -0
  47. package/scripts/mutants/omp-receive.json +135 -0
  48. package/scripts/mutants/self-address.json +34 -0
  49. package/scripts/omp-bridge-doctor.sh +315 -0
  50. package/scripts/omp-bridge-install.sh +221 -0
  51. package/scripts/omp-bridge-oracle.sh +154 -0
  52. package/scripts/omp-bridge-uninstall.sh +57 -0
  53. package/scripts/omp-mcp-bridge.sh +320 -0
  54. package/scripts/omp-mcp-config.py +392 -0
  55. package/scripts/omp-receive-doctor.sh +246 -0
  56. package/scripts/omp-receive-facts.ts +106 -0
  57. package/scripts/omp-receive-install.sh +228 -0
  58. package/scripts/omp-receive-uninstall.sh +60 -0
  59. package/scripts/omp-tool-surface.py +349 -0
  60. package/scripts/raw-omp-measure/README.md +420 -0
  61. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  62. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  63. package/scripts/raw-omp-measure/source-audit.md +414 -0
  64. package/scripts/smoke-omp-bridge-state.sh +221 -0
  65. package/scripts/smoke-omp-fresh-live.ts +497 -0
  66. package/scripts/smoke-omp-mcp-state.sh +327 -0
  67. package/scripts/smoke-omp-receive-live.ts +340 -0
  68. package/scripts/smoke-omp-receive-state.sh +196 -0
  69. package/scripts/tsconfig.json +2 -0
@@ -76,7 +76,7 @@ export class MetaRecordError extends Error {
76
76
  * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
77
77
  * stale deployed writer — run it after the change.
78
78
  */
79
- export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot"];
79
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp"];
80
80
  export const META_BACKEND_DESCRIPTORS = {
81
81
  "claude-code": {
82
82
  backend: "claude-code",
@@ -137,6 +137,58 @@ export const META_BACKEND_DESCRIPTORS = {
137
137
  deliveryLevel: "D6",
138
138
  nativeIdLabel: "sessionId",
139
139
  },
140
+ // SELF-FETCH citizen since #87 bundle B (oh-my-pi v18.0.0). An omp "hook" is an
141
+ // in-process EXTENSION (`--hook` aliases `--extension`), so BOTH units run inside the
142
+ // omp host: the birth unit mints on `session_start`/`session_switch`, and the receiver
143
+ // unit arms a mailbox watch in the same process and rings the doorbell.
144
+ // self-fetch The doorbell announces and the model drains its own inbox with
145
+ // `entwurf_inbox_read` — that read is the receipt, and the notice never
146
+ // carries the body. `[LIVE 2026-08-30, oracle, omp 18.0.0]` the vendor's
147
+ // own wake surface was measured rather than inherited:
148
+ // `pi.sendUserMessage(text,{deliverAs:"user"})` (on the FACTORY object, not
149
+ // the event ctx) called on an IDLE tui session with zero typing started a
150
+ // real turn — `agent_start` +31ms, `turn_start` +64ms, `turn_end` +2.45s,
151
+ // model reply in the transcript. It was `direct-inject` while no process
152
+ // held a watch; that label was the honest "not a drainable mailbox" bucket,
153
+ // and it stopped being true when the receiver unit landed.
154
+ // Replyability is still a fact about a LIVE marker, never a constant of this
155
+ // backend: with no armed receiver every dispatch is refused
156
+ // `mailbox-undeliverable`, which is exactly what an omp host with the birth
157
+ // unit and no receiver unit still gets.
158
+ // D6 the PRODUCT grade, earned by the LIVE roundtrip of 2026-08-30 on oracle
159
+ // (omp 18.0.0). The code landing was NOT the evidence — this number moved
160
+ // when the receipt did (`adding-a-harness.md` step 8(c)):
161
+ // garden `20260830T140819-116f6a`, `lastEnqueuedAt 05:08:20.555Z` /
162
+ // `lastReadAt 05:08:23.958Z`, doorbell rung on an idle session with zero
163
+ // typing, and the citizen's OWN transcript carrying
164
+ // `mcp__entwurf_bridge_entwurf_inbox_read` for its own garden id — the join
165
+ // that makes the drain a fact about THAT session rather than about the clock.
166
+ // D3 is PROVEN here rather than pending: with two live omp citizens armed
167
+ // (pids 3154765 / 3154835) a single addressed enqueue rang exactly one
168
+ // doorbell, and the sibling persisted no transcript and kept an empty
169
+ // mailbox. That is the cell the Copilot row still lists as PENDING.
170
+ // D7 is PARTIAL, deliberately: `lastReadAt` is observable without scraping,
171
+ // but the reply itself is only visible in the transcript, and no completion
172
+ // taxonomy or long-haul operation was measured.
173
+ // D8 is PARTIAL: fresh-only ring (dedupe), `/new` unarm, watch-error unarm,
174
+ // vanished-signal unarm and the identity-guarded teardown are implemented and
175
+ // pinned hermetically by `check-omp-receive-arm`; ordering under load, loop
176
+ // guards and crash recovery are not measured.
177
+ // Replyability stays a fact about a LIVE marker: with no armed receiver every
178
+ // dispatch is still refused `mailbox-undeliverable` — re-proved on the same
179
+ // day against a garden id whose session had just been replaced by `/new`.
180
+ // sessionId the native join key: `ReadonlySessionManager.getSessionId()`
181
+ // (`session-manager.ts:1946-1948`), a UUIDv7 minted at
182
+ // `mintSessionId()` `:95-97` — NOT the transcript filename, which is
183
+ // `<iso>_<uuidv7>.jsonl` (`:1134-1137`). Measured LIVE 2026-08-27:
184
+ // host `01a042da-537a-7770-a275-7b8162eecca4`
185
+ // (`scripts/raw-omp-measure/README.md` M1).
186
+ omp: {
187
+ backend: "omp",
188
+ wakeMode: "self-fetch",
189
+ deliveryLevel: "D6",
190
+ nativeIdLabel: "sessionId",
191
+ },
140
192
  };
141
193
  // ---------------------------------------------------------------------------
142
194
  // Validation helpers (crash, don't warn)
@@ -209,7 +261,7 @@ function isoNow(now) {
209
261
  /** The one live identity schema number. */
210
262
  export const META_SCHEMA_VERSION_V3 = 3;
211
263
  /** Every backend admitted by the one V3 record-citizen schema. */
212
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "pi"];
264
+ export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi"];
213
265
  export function requireCitizenBackend(value) {
214
266
  if (typeof value !== "string" || !META_CITIZEN_BACKENDS.includes(value)) {
215
267
  throw new MetaRecordError(`meta-record "backend" must be one of ${META_CITIZEN_BACKENDS.join(" | ")} (got ${describe(value)}).`);
@@ -1015,13 +1067,16 @@ export function decideUpsert(existing, input, now = new Date()) {
1015
1067
  // core. Only node builtins beyond the pure layer, so the deterministic gate
1016
1068
  // stays strip-types clean (see module header for why this is not a sibling file).
1017
1069
  // ---------------------------------------------------------------------------
1018
- function expandTilde(p) {
1070
+ function expandTildeIn(p, home) {
1019
1071
  if (p === "~")
1020
- return os.homedir();
1072
+ return home;
1021
1073
  if (p.startsWith("~/"))
1022
- return path.join(os.homedir(), p.slice(2));
1074
+ return path.join(home, p.slice(2));
1023
1075
  return p;
1024
1076
  }
1077
+ function expandTilde(p) {
1078
+ return expandTildeIn(p, os.homedir());
1079
+ }
1025
1080
  /**
1026
1081
  * The pi agent dir — the persistence root pi owns. `PI_CODING_AGENT_DIR` lets an
1027
1082
  * isolated install / test relocate it (symmetric with how pi's own sessions
@@ -1101,6 +1156,118 @@ export function defaultMetaReceiversDir() {
1101
1156
  return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
1102
1157
  return path.join(piAgentDir(), "meta-receivers");
1103
1158
  }
1159
+ /**
1160
+ * The exact provenance label the omp-native MCP entry carries
1161
+ * (`scripts/omp-mcp-config.py` `EXTERNAL_AGENT_ID`). It is what lets a bridge CHILD know
1162
+ * it is an OMP child — the same string both sides pin, never re-spelled.
1163
+ */
1164
+ export const OMP_BRIDGE_PROVENANCE_LABEL = "external-mcp/omp";
1165
+ /** `HOME` as the process that owns this env sees it; `os.homedir()` returns exactly this
1166
+ * on POSIX when HOME is set, so a real process and a composed child env agree. */
1167
+ function metaRootHome(env) {
1168
+ const home = env.HOME;
1169
+ return home !== undefined && home.length > 0 ? home : os.homedir();
1170
+ }
1171
+ /**
1172
+ * THE shared OMP root policy — one pure function, two consumers (the in-process birth
1173
+ * extension and the OMP-labeled bridge child). Agreement is by CONSTRUCTION, not by two
1174
+ * places computing the same thing: both read this leaf, and both see the same HOME and the
1175
+ * same four overrides because the vendor composes the child env as parent + entry env
1176
+ * (`oh-my-pi` `packages/coding-agent/src/mcp/transports/stdio.ts:575-607`) and that entry
1177
+ * env is provenance-only.
1178
+ */
1179
+ export class MetaRootPolicyError extends Error {
1180
+ constructor(message) {
1181
+ super(message);
1182
+ this.name = "MetaRootPolicyError";
1183
+ }
1184
+ }
1185
+ /**
1186
+ * The unambiguous base the OMP bundle hangs off when no override is set. Separate from
1187
+ * {@link ompMetaRoots} because it must NEVER throw: the hook log is a diagnostic, and a
1188
+ * policy refusal still has to be visible somewhere.
1189
+ */
1190
+ export function ompMetaRootBase(env = process.env, home = metaRootHome(env)) {
1191
+ // NEVER `piAgentDir()` here: for backend omp that variable is the VENDOR's agent dir.
1192
+ return path.join(home, ".pi", "agent");
1193
+ }
1194
+ /**
1195
+ * THE OVERRIDE GRAMMAR IS NARROW ON PURPOSE: absolute, or `~` / `~/…`. Anything else —
1196
+ * a relative path, or `~user` — is a NAMED REFUSAL, not a resolution.
1197
+ *
1198
+ * Why fail closed rather than resolve: this bundle has to mean the same thing in two
1199
+ * processes that do not share a working directory. `path.resolve` on a relative value
1200
+ * silently makes CWD an authority, and the OMP extension's cwd is whatever the operator
1201
+ * launched omp from while the doctor's is the repository (`run_ts` cd's there). `[측정]`
1202
+ * the same `ENTWURF_META_SESSIONS_DIR=relative-records` resolved to two different stores
1203
+ * that way, so the doctor could report NOT-YET off an empty directory while the extension's
1204
+ * real store held records (#87 A2, Terra review). Refusing is what keeps cwd from becoming
1205
+ * a garden-root carrier. `~` is allowed because it expands from HOME, which both halves
1206
+ * share by construction.
1207
+ *
1208
+ * The refusal is shared: extension and OMP-labeled bridge child both reach it through this
1209
+ * one leaf, so neither can proceed on a value the other would read differently.
1210
+ */
1211
+ export function ompMetaRoots(env = process.env, home = metaRootHome(env)) {
1212
+ const base = ompMetaRootBase(env, home);
1213
+ const surface = (key, leaf) => {
1214
+ const override = env[key];
1215
+ if (override === undefined || override === "")
1216
+ return path.join(base, leaf);
1217
+ if (override === "~" || override.startsWith("~/"))
1218
+ return path.resolve(expandTildeIn(override, home));
1219
+ if (path.isAbsolute(override))
1220
+ return path.resolve(override);
1221
+ throw new MetaRootPolicyError(`omp meta-root policy refuses ${key}=${JSON.stringify(override)}: a garden root must be absolute or ~-rooted ` +
1222
+ "(`~` or `~/…`). A relative value would resolve against each process's own working directory, and the omp " +
1223
+ "extension and its doctor do not share one — set an absolute path, or unset it to use the default under HOME.");
1224
+ };
1225
+ return {
1226
+ sessionsDir: surface("ENTWURF_META_SESSIONS_DIR", "meta-sessions"),
1227
+ mailboxDir: surface("ENTWURF_META_MAILBOX_DIR", "meta-mailbox"),
1228
+ sendersDir: surface("ENTWURF_META_SENDERS_DIR", "meta-senders"),
1229
+ receiversDir: surface("ENTWURF_META_RECEIVERS_DIR", "meta-receivers"),
1230
+ };
1231
+ }
1232
+ /**
1233
+ * The bridge CHILD half of the same policy, applied to that child's own environment.
1234
+ *
1235
+ * A bridge child selects this by the exact provenance label its managed entry carries —
1236
+ * no probing, no guessing, and no effect on any other harness's child. It then does two
1237
+ * things, in this order and before any lazy default-root consumer has run:
1238
+ *
1239
+ * 1. removes the foreign `PI_CODING_AGENT_DIR` from THIS PROCESS only. The OMP HOST
1240
+ * keeps it — there it is the vendor's own agent dir and deleting it would rewrite
1241
+ * vendor path semantics — but the child has no vendor lookup left to do.
1242
+ * 2. pins the four `ENTWURF_META_*_DIR` overrides to this leaf's answer, so every
1243
+ * `defaultMeta*Dir()` consumer in the bridge reads the OMP bundle literally rather
1244
+ * than recomputing something that merely happens to match. Idempotent: an override
1245
+ * the operator already set was honoured by the leaf and is written back unchanged.
1246
+ *
1247
+ * These four are entwurf's OWN documented override vocabulary, set in our own process —
1248
+ * not a new carrier, not a record field, not a marker.
1249
+ *
1250
+ * The in-process birth extension cannot use this and must not try: it runs INSIDE the omp
1251
+ * host, so mutating that process's env is exactly the thing forbidden above. It passes
1252
+ * explicit directories from {@link ompMetaRoots} instead.
1253
+ */
1254
+ export function applyOmpBridgeChildRootPolicy(env = process.env, home) {
1255
+ // EXACT equality on the RAW label (#87 A3). Trimming was a courtesy that let a
1256
+ // whitespace-drifted entry — one `doctor-omp-mcp` and the writer both call foreign —
1257
+ // still select OMP root mutation in its child. The writer emits the literal and the
1258
+ // doctor compares the literal; this compares the literal too, so all three agree on
1259
+ // what "our entry" means.
1260
+ if (env.ENTWURF_BRIDGE_EXTERNAL_AGENT_ID !== OMP_BRIDGE_PROVENANCE_LABEL) {
1261
+ return { applied: false, roots: null };
1262
+ }
1263
+ const roots = ompMetaRoots(env, home ?? metaRootHome(env));
1264
+ delete env.PI_CODING_AGENT_DIR;
1265
+ env.ENTWURF_META_SESSIONS_DIR = roots.sessionsDir;
1266
+ env.ENTWURF_META_MAILBOX_DIR = roots.mailboxDir;
1267
+ env.ENTWURF_META_SENDERS_DIR = roots.sendersDir;
1268
+ env.ENTWURF_META_RECEIVERS_DIR = roots.receiversDir;
1269
+ return { applied: true, roots };
1270
+ }
1104
1271
  /**
1105
1272
  * A boot-unique identity for a live process: pid is reused, but pid + start-time
1106
1273
  * is unique within a boot. Linux reads `/proc/<pid>/stat` field 22 (starttime in
@@ -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
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot"];
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, prompt, model) {
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
- /** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
193
- * position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
194
- * yields the exact pre-#73 argv — no carrier at all. */
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 prompt = buildFreshCallPrompt({
276
- backend: params.backend,
277
- task,
278
- callerGardenId: params.callerGardenId,
279
- nonce,
280
- });
281
- const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd), env);
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",