@junghanacs/entwurf 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/AGENTS.md +4 -1
  2. package/BASELINE.md +1 -0
  3. package/CHANGELOG.md +514 -0
  4. package/DELIVERY.md +10 -2
  5. package/README.md +43 -4
  6. package/VERIFY.md +47 -0
  7. package/docs/acp-backend-rail.md +96 -17
  8. package/docs/setup-clean-host.md +11 -1
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +41 -41
  29. package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
  30. package/package.json +9 -8
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  33. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  34. package/pi-extensions/lib/acp/backend.ts +4 -4
  35. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  36. package/pi-extensions/lib/acp/tool-surface.ts +2 -1
  37. package/pi-extensions/lib/codex-declaration.js +612 -0
  38. package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
  39. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  40. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  41. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  42. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  43. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  44. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  45. package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
  46. package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
  47. package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
  48. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  49. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  50. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  51. package/pi-extensions/lib/herdr-placement.ts +276 -0
  52. package/pi-extensions/lib/mux-fresh-call.ts +95 -301
  53. package/run.sh +588 -204
  54. package/scripts/check-acp-usage-accounting.ts +9 -9
  55. package/scripts/check-codex-birth-hook.ts +187 -0
  56. package/scripts/check-entwurf-control-rpc.ts +138 -0
  57. package/scripts/check-entwurf-fact-provider.ts +4 -2
  58. package/scripts/check-entwurf-peers-surface.ts +3 -1
  59. package/scripts/check-entwurf-self-address.ts +68 -0
  60. package/scripts/check-fresh-call-dispatch.ts +301 -0
  61. package/scripts/check-gate-qualification.ts +17 -4
  62. package/scripts/check-herdr-activation.ts +1293 -0
  63. package/scripts/check-herdr-fresh-call.ts +1250 -0
  64. package/scripts/check-herdr-placement.ts +383 -0
  65. package/scripts/check-herdr-plugin-build.ts +842 -0
  66. package/scripts/check-herdr-plugin-profile.ts +371 -0
  67. package/scripts/check-herdr-plugin.ts +523 -0
  68. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  69. package/scripts/check-herdr-sandbox.ts +456 -0
  70. package/scripts/check-herdr-supply.ts +184 -0
  71. package/scripts/check-mux-launcher-fence.ts +138 -0
  72. package/scripts/check-peer-facts.ts +331 -0
  73. package/scripts/check-pi-launch.ts +358 -0
  74. package/scripts/check-release-gate-outcomes.ts +19 -0
  75. package/scripts/check-typing-call-fence.ts +296 -0
  76. package/scripts/codex-birth-doctor.sh +159 -16
  77. package/scripts/codex-birth-install.sh +178 -72
  78. package/scripts/codex-birth-uninstall.sh +162 -7
  79. package/scripts/fixtures/herdr-supply.json +29 -0
  80. package/scripts/herdr-activation.mjs +536 -0
  81. package/scripts/herdr-plugin-activate.mjs +270 -0
  82. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  83. package/scripts/herdr-runtime.mjs +1203 -0
  84. package/scripts/install-herdr-ci.sh +96 -0
  85. package/scripts/inventory-verification-surface.ts +86 -10
  86. package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
  87. package/scripts/lib/tree-digest.ts +97 -0
  88. package/scripts/meta-bridge-install.sh +19 -2
  89. package/scripts/meta-bridge-state.py +167 -11
  90. package/scripts/mutants/codex-caller-seat.json +6 -8
  91. package/scripts/mutants/codex-native.json +195 -29
  92. package/scripts/mutants/control-socket-disconnect.json +57 -0
  93. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  94. package/scripts/mutants/herdr-activation.json +305 -0
  95. package/scripts/mutants/herdr-fresh-call.json +448 -0
  96. package/scripts/mutants/herdr-placement.json +161 -0
  97. package/scripts/mutants/herdr-plugin-build.json +160 -0
  98. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  99. package/scripts/mutants/herdr-plugin.json +133 -0
  100. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  101. package/scripts/mutants/herdr-supply.json +133 -0
  102. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  103. package/scripts/mutants/mux-fresh-call.json +104 -17
  104. package/scripts/mutants/mux-launcher-fence.json +13 -0
  105. package/scripts/mutants/omp-fresh.json +4 -4
  106. package/scripts/mutants/peer-facts.json +98 -0
  107. package/scripts/mutants/pi-launch.json +113 -0
  108. package/scripts/mutants/resume-args.json +6 -6
  109. package/scripts/mutants/self-address.json +33 -0
  110. package/scripts/mutants/typing-call-fence.json +17 -0
  111. package/scripts/mutants/v2-surface.json +2 -2
  112. package/scripts/mutants/v2-visible-resume.json +17 -17
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  117. package/scripts/smoke-codex-birth.sh +281 -12
  118. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  119. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  120. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  121. package/scripts/smoke-herdr-raw-install-live.sh +849 -0
  122. package/scripts/tsconfig.json +5 -0
  123. package/scripts/check-entwurf-facts.ts +0 -385
  124. package/scripts/check-entwurf-resume-args.ts +0 -122
  125. package/scripts/check-entwurf-v2-decider.ts +0 -657
  126. package/scripts/check-entwurf-v2-lock.ts +0 -371
  127. package/scripts/check-entwurf-v2-mailbox.ts +0 -261
  128. package/scripts/check-entwurf-v2-matrix.ts +0 -401
  129. package/scripts/check-entwurf-v2-native-push.ts +0 -221
  130. package/scripts/check-entwurf-v2-release.ts +0 -151
  131. package/scripts/check-entwurf-v2-runner.ts +0 -448
  132. package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
  133. package/scripts/check-entwurf-v2-send.ts +0 -458
  134. package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
  135. package/scripts/check-socket-discovery.ts +0 -401
@@ -86,7 +86,6 @@
86
86
  * there is no "session created" field because nothing here creates one.
87
87
  */
88
88
 
89
- import { randomBytes } from "node:crypto";
90
89
  import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
91
90
  import {
92
91
  CODEX_CALLER_SEAT_HINT,
@@ -106,6 +105,23 @@ import {
106
105
  type CopilotPreflightRejectReason,
107
106
  copilotFreshPreflight,
108
107
  } from "./copilot-fresh-preflight.ts";
108
+ import {
109
+ buildOmpBootstrapPayload,
110
+ composeBackendArgs,
111
+ composeFreshCallPrompt,
112
+ FRESH_CALL_BACKENDS,
113
+ FRESH_CALL_CALLBACK_TOOL,
114
+ type FreshCallBackend,
115
+ type FreshCallComposition,
116
+ type FreshCallInputRejectReason,
117
+ isSafeFreshCallModel,
118
+ MODEL_MAX_CHARS,
119
+ mintNonce,
120
+ normalizeFreshCallInputs,
121
+ OMP_BOOTSTRAP_FLAG,
122
+ OMP_BOOTSTRAP_VERSION,
123
+ TASK_MAX_CHARS,
124
+ } from "./fresh-call-composition.ts";
109
125
  import {
110
126
  assertLaunchTarget,
111
127
  LaunchPreconditionError,
@@ -127,13 +143,69 @@ import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.ts"
127
143
  import { OMP_PREFLIGHT_HINT, type OmpPreflightRejectReason, ompFreshPreflight } from "./omp-fresh-preflight.ts";
128
144
  import { classifyTmuxSessionName, resolveTmuxSessionId, type TmuxSessionRejectReason } from "./resolve-tmux-session.ts";
129
145
 
130
- /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
131
- * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
132
- * by #95 after its system birth and app-server rails were measured. The set is joined to the
133
- * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
134
- * missing HERE is not an unwired convenience, it is a release blocker. */
135
- export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"] as const;
136
- export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
146
+ /**
147
+ * THE COMPOSITION MOVED, THE RAIL DID NOT (#116 S2-a). Backend argv, the first-turn framing and
148
+ * the nonce now live in `fresh-call-composition.ts`, which knows nothing about tmux, so the herdr
149
+ * rail can reach them without importing this file and dragging placement behind it. They are
150
+ * re-exported here unchanged: every existing caller, gate and mutant anchor that reads them from
151
+ * this module keeps reading the same names.
152
+ *
153
+ * The two wrappers below are where this rail states what only it can state — the sentence that
154
+ * says a sibling was opened in the operator's tmux session, and the Codex socket path. Both are
155
+ * arguments to the leaf rather than knowledge inside it.
156
+ */
157
+ export {
158
+ buildOmpBootstrapPayload,
159
+ FRESH_CALL_BACKENDS,
160
+ FRESH_CALL_CALLBACK_TOOL,
161
+ type FreshCallBackend,
162
+ type FreshCallComposition,
163
+ isSafeFreshCallModel,
164
+ MODEL_MAX_CHARS,
165
+ mintNonce,
166
+ OMP_BOOTSTRAP_FLAG,
167
+ OMP_BOOTSTRAP_VERSION,
168
+ TASK_MAX_CHARS,
169
+ };
170
+
171
+ /** What the tmux rail tells a sibling about where it woke up. The resume verb has no equivalent
172
+ * because it composes no first turn at all; a herdr rail will pass its own sentence here. */
173
+ export const TMUX_FRESH_CALL_OPENING_LINE =
174
+ "You are a fresh visible citizen that entwurf opened in the operator's tmux session.";
175
+
176
+ /** Backend argv for THIS rail: the neutral dialect, with the two Codex facts only a rail can
177
+ * state — the socket resolved from the caller's env, and the directory this launch chose.
178
+ *
179
+ * `launchCwd` defaults to THIS process's own directory, which is what tmux gives a window opened
180
+ * with no `-c` (`[측정 ×2]`, module header). The default is that inherited fact rather than a
181
+ * convenience, so a caller that omits it still names the truth to codex. Both facts reach the
182
+ * leaf lazily, because a host with no Codex home must still be able to open pi and claude
183
+ * siblings — see `composeBackendArgs`. */
184
+ export function buildBackendArgs(
185
+ backend: FreshCallBackend,
186
+ composition: FreshCallComposition,
187
+ model: string,
188
+ env: NodeJS.ProcessEnv = process.env,
189
+ launchCwd: string = process.cwd(),
190
+ ): string[] {
191
+ return composeBackendArgs(
192
+ backend,
193
+ composition,
194
+ model,
195
+ () => resolveCodexDefaultSocketPath(env),
196
+ () => launchCwd,
197
+ );
198
+ }
199
+
200
+ /** The first turn for THIS rail: the neutral framing under this rail's placement sentence. */
201
+ export function buildFreshCallPrompt(params: {
202
+ backend: FreshCallBackend;
203
+ task: string;
204
+ callerGardenId: string;
205
+ nonce: string;
206
+ }): string {
207
+ return composeFreshCallPrompt({ ...params, openingLine: TMUX_FRESH_CALL_OPENING_LINE });
208
+ }
137
209
 
138
210
  /**
139
211
  * The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
@@ -165,273 +237,11 @@ export const FRESH_CALL_RUNTIME: Record<FreshCallBackend, string> = {
165
237
  codex: "codex",
166
238
  };
167
239
 
168
- /**
169
- * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
170
- * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
171
- * name that harness composes. Naming the wrong one costs the whole first turn.
172
- *
173
- * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
174
- * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
175
- * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
176
- * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
177
- * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
178
- * sibling's spelling (`docs/adding-a-harness.md` step 5).
179
- *
180
- * `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
181
- * `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
182
- * `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
183
- * hyphen in the server key becomes an underscore — the model-facing name is
184
- * `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
185
- * Confirmed against a live tool dump of all seven bridge tools and a real session transcript
186
- * (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
187
- * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
188
- */
189
- export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
190
- pi: "entwurf_v2",
191
- "claude-code": "mcp__entwurf-bridge__entwurf_v2",
192
- copilot: "entwurf-bridge-entwurf_v2",
193
- omp: "mcp__entwurf_bridge_entwurf_v",
194
- codex: "mcp__entwurf_bridge__entwurf_v2",
195
- };
196
-
197
- /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
198
- * delivery surface, not a claim that a task of this size was measured through tmux. An argv
199
- * that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
200
- export const TASK_MAX_CHARS = 16000;
201
- export const MODEL_MAX_CHARS = 200;
202
- const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
203
-
204
- /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
205
- * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
206
- * whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
207
- * CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
208
- export function isSafeFreshCallModel(model: string): boolean {
209
- return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
210
- }
211
-
212
- /**
213
- * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
214
- * getting them wrong first (rail §6-a):
215
- *
216
- * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
217
- * Flag-first submitted no message; Pi rejects the equals form for `--model`.
218
- * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
219
- * for allowedTools is variadic and eats the prompt as an option value.
220
- * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
221
- * `--model`, value as two tokens, and the policy as the explicit `--yolo`
222
- * token. Measured from `copilot --help` (1.0.80).
223
- * omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
224
- * flag the installed birth extension registers, then `--model`, value as two
225
- * tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
226
- * the flag this argv must never carry — it processes a prompt and EXITS,
227
- * closing the window on a sibling that has to stay open to be delivered to.
228
- *
229
- * Both pi/claude failures looked identical from outside: window open, record and socket minted,
230
- * no turn.
231
- *
232
- * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
233
- * the tool was already permitted, so the option's effect was unobservable. What was observed is
234
- * that it does no harm to the argv. Permission stays a documented host precondition there.
235
- *
236
- * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
237
- * its argv facts are load-bearing:
238
- *
239
- * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
240
- * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
241
- * `scripts/copilot-launch.sh`.
242
- * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
243
- * which would close the window on a sibling that is supposed to stay open and be delivered
244
- * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
245
- * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
246
- * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
247
- * fresh composition to state its model and permission policy explicitly, never to rely
248
- * invisibly on someone else's default.
249
- *
250
- * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
251
- * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
252
- * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
253
- * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
254
- * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
255
- * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
256
- * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
257
- * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
258
- * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
259
- * example; it is a measured vendor fact even though this argv no longer uses it.
260
- *
261
- * OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
262
- * `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
263
- * (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
264
- * acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
265
- * still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
266
- * default and on whatever the operator's config happens to say. That is exactly the drift step 9
267
- * clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
268
- * launcher's injected default" — and the reason the width is stated here even though the host
269
- * would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
270
- * operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
271
- * sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
272
- * grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
273
- * so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
274
- * `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
275
- * the space form is used for symmetry with `--model`.
276
- *
277
- * WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
278
- * `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
279
- * positional. The window opened, the record minted (garden `20260830T181342-452167`), the
280
- * prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
281
- * the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
282
- * `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
283
- * `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
284
- * `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
285
- * `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
286
- * entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
287
- * the tool it was told to call existed. No argv can close that gap, because the gap is a race
288
- * inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
289
- * in-process birth extension — which can see when the tool becomes callable — owns the first
290
- * two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
291
- *
292
- * The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
293
- * extension that registers a flag receives the operator's argv value byte-identical — quotes,
294
- * `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
295
- * load before argv classification and the reparse writes the registered map
296
- * (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
297
- * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
298
- * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
299
- * carrier would hand callers the launch-shaping power this rail exists to refuse.
300
- *
301
- * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
302
- * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
303
- * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
304
- * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
305
- * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
306
- * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
307
- * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
308
- * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
309
- * the flag lives on the shared interactive options this argv already uses for
310
- * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
311
- * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
312
- *
313
- * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
314
- * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
315
- * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
316
- * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
317
- * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and `launchCwd` is the
318
- * single value both receive. It is not a new input axis: the value is chosen by the cwd rules
319
- * this module already has, one layer up.
320
- */
321
- export function buildBackendArgs(
322
- backend: FreshCallBackend,
323
- composition: FreshCallComposition,
324
- model: string,
325
- env: NodeJS.ProcessEnv = process.env,
326
- /** Where the sibling will actually start: the directory `freshCall` chose when it chose one,
327
- * and otherwise THIS process's own — which is what tmux gives a window opened with no `-c`
328
- * (`[측정 ×2]`, module header). The default is that inherited fact rather than a
329
- * convenience, so a caller that omits it still names the truth to codex. */
330
- launchCwd: string = process.cwd(),
331
- ): string[] {
332
- switch (backend) {
333
- case "pi":
334
- return [composition.prompt, "--entwurf-control", "--model", model];
335
- case "claude-code":
336
- return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
337
- case "copilot":
338
- return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
339
- case "omp":
340
- return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
341
- case "codex":
342
- return [
343
- "--remote",
344
- `unix://${resolveCodexDefaultSocketPath(env)}`,
345
- "-C",
346
- launchCwd,
347
- "--model",
348
- model,
349
- "--dangerously-bypass-approvals-and-sandbox",
350
- composition.prompt,
351
- ];
352
- }
353
- }
354
-
355
- /**
356
- * What a launch has to say, in the two shapes the five backends need. Four of them are
357
- * handed a first-turn PROMPT; omp is handed a bootstrap PAYLOAD its own installed extension
358
- * unpacks. Both are always built, because building one is cheap and a backend switch must
359
- * never be able to reach a field that was not composed.
360
- */
361
- export interface FreshCallComposition {
362
- prompt: string;
363
- bootstrapPayload: string;
364
- }
365
-
366
- /**
367
- * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
368
- * name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
369
- *
370
- * Held equal to the installed extension's own constant by
371
- * `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
372
- * ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
373
- * module; the gate is what keeps the duplication from becoming drift.
374
- */
375
- export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
376
-
377
- /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
378
- * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
379
- export const OMP_BOOTSTRAP_VERSION = 1;
380
-
381
- /**
382
- * The whole of what a fresh omp sibling is launched with.
383
- *
384
- * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
385
- * contract: who to call back, the nonce that proves it is this call, and the task that is
386
- * released only after that callback succeeds. There is no command here, no path, no env name
387
- * and no model — the model is already an explicit argv token, and a second copy of it inside
388
- * a payload would be a second place for it to disagree with the launch.
389
- */
390
- export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
391
- return JSON.stringify({
392
- v: OMP_BOOTSTRAP_VERSION,
393
- target: params.callerGardenId,
394
- nonce: params.nonce,
395
- task: params.task,
396
- });
397
- }
398
-
399
- /**
400
- * The first-turn framing. Order is the contract: the callback is the FIRST action and the task
401
- * follows it, so a sibling that gets stuck in the task has already told the caller who it is.
402
- *
403
- * The three prohibitions are not politeness. Each names a detour that was measured to produce a
404
- * confidently wrong answer or a wasted turn.
405
- */
406
- export function buildFreshCallPrompt(params: {
407
- backend: FreshCallBackend;
408
- task: string;
409
- callerGardenId: string;
410
- nonce: string;
411
- }): string {
412
- const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
413
- return [
414
- "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
415
- "",
416
- `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
417
- `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
418
- `message set to exactly ${params.nonce} — that string alone, nothing added.`,
419
- "That call is how the agent that opened you learns your address. Do not skip it, do not",
420
- "defer it until the task is done, and do not reword the message.",
421
- "",
422
- "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
423
- "server yourself. Your own report of your identity is not the address anyone needs.",
424
- "",
425
- "After the tool receipt, carry out this task:",
426
- "",
427
- params.task,
428
- ].join("\n");
429
- }
430
-
431
240
  /** A launch that was refused, or a placement that could not be established. Every value is a
432
241
  * NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
433
242
  * come from the shared classification leaf and their string values are stable contract. */
434
243
  export type FreshCallRejectReason =
244
+ | FreshCallInputRejectReason
435
245
  | PlacementRejectReason
436
246
  | LaunchRejectReason
437
247
  | TmuxCwdRejectReason
@@ -521,16 +331,6 @@ export interface FreshCallReceipt extends WindowHandle {
521
331
 
522
332
  export type FreshCallResult = { ok: true; receipt: FreshCallReceipt } | { ok: false; reason: FreshCallRejectReason };
523
333
 
524
- /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
525
- * encoded any of those would invite exactly the guessing this rail exists to refuse. */
526
- export function mintNonce(randomHex: () => string = defaultRandomHex): string {
527
- return `mux-fresh-call-${randomHex()}`;
528
- }
529
-
530
- function defaultRandomHex(): string {
531
- return randomBytes(12).toString("hex");
532
- }
533
-
534
334
  /**
535
335
  * The pi identity carrier, scrubbed at the launch seam for EVERY backend (#87 Bundle C).
536
336
  *
@@ -633,24 +433,18 @@ export function freshCall(
633
433
  env: NodeJS.ProcessEnv = process.env,
634
434
  nonce: string = mintNonce(),
635
435
  ): FreshCallResult {
636
- if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
637
- return { ok: false, reason: "caller-identity-unavailable" };
638
- }
639
- const model = params.model.trim();
640
- if (model.length === 0) return { ok: false, reason: "model-empty" };
641
- if (!isSafeFreshCallModel(model)) return { ok: false, reason: "model-invalid" };
642
- const task = params.task.trim();
643
- if (task.length === 0) return { ok: false, reason: "task-empty" };
644
- if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
645
- // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
646
- // valuedeliberately untrimmed, so a whitespace-mangled path is refused loudly by the
647
- // classification below instead of being silently repaired into a different directory. The
648
- // caller's own record directory answers the SAME two-value emptiness rule and is consulted
649
- // ONLY second: an explicit request always wins, and a caller that supplies neither leaves the
650
- // pane to inherit this process's directory exactly as before — a pi caller's argv is
651
- // byte-identical, because its process directory IS its own and a `-c` token would change
652
- // nothing about where that window lands.
653
- const requestedCwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
436
+ // The caller-facing input contract lives in the composition leaf so BOTH rails answer a
437
+ // mistyped model or an oversized task with the same words. Order, trimming and the
438
+ // cwd-omission rule (`undefined` and the exact empty string, and nothing else, mean "no
439
+ // cwd") are unchanged from when they lived here.
440
+ const normalized = normalizeFreshCallInputs(params);
441
+ if (!normalized.ok) return { ok: false, reason: normalized.reason };
442
+ const { callerGardenId, model, task, cwd: requestedCwd } = normalized.inputs;
443
+ // The caller's own record directory answers the SAME two-value emptiness rule the leaf
444
+ // applies to a requested cwd, and is consulted ONLY second: an explicit request always wins,
445
+ // and a caller that supplies neither leaves the pane to inherit this process's directory
446
+ // exactly as before a pi caller's argv is byte-identical, because its process directory IS
447
+ // its own and a `-c` token would change nothing about where that window lands.
654
448
  const callerCwd = params.callerCwd === undefined || params.callerCwd === "" ? undefined : params.callerCwd;
655
449
  const chosenCwd: { value: string; source: FreshCallCwdSource } | undefined =
656
450
  requestedCwd !== undefined
@@ -739,10 +533,10 @@ export function freshCall(
739
533
  prompt: buildFreshCallPrompt({
740
534
  backend: params.backend,
741
535
  task,
742
- callerGardenId: params.callerGardenId,
536
+ callerGardenId,
743
537
  nonce,
744
538
  }),
745
- bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId: params.callerGardenId, nonce, task }),
539
+ bootstrapPayload: buildOmpBootstrapPayload({ callerGardenId, nonce, task }),
746
540
  };
747
541
  const backendArgs = buildBackendArgs(params.backend, composition, model, env, cwd);
748
542
  // THE LAUNCH-DIRECTORY NOTE, AND IT IS A DIAGNOSTIC RATHER THAN A GATE. `[측정 2026-09-16]` a