@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
@@ -0,0 +1,423 @@
1
+ /**
2
+ * FRESH-CALL COMPOSITION — the part of opening a sibling that is the same wherever the
3
+ * sibling is placed.
4
+ *
5
+ * WHY THIS FILE EXISTS (#116 S2-a). A herdr rail opens the same five-backend dialect, hands the
6
+ * same first-turn framing and mints the same correlation nonce as the tmux rail — but it places
7
+ * the sibling with `pane split` + `agent start` rather than `new-window`, and it cannot inherit
8
+ * one line of tmux placement to get there. Leaving this composition inside `mux-fresh-call.ts`
9
+ * would have forced the herdr module to import that file, and that file imports tmux placement,
10
+ * the launch preflights and the Codex socket resolver. One import would have dragged the whole
11
+ * tmux rail behind it.
12
+ *
13
+ * SO THE FENCE IS THE POINT: this leaf imports NOTHING from `mux-*` or `entwurf-*`. Only the
14
+ * Node standard library. Anything a rail owns arrives as an explicit argument — the opening
15
+ * sentence that says where the sibling was placed, and the Codex socket path. Both are injected
16
+ * rather than resolved here, because resolving either one would re-open the coupling this file
17
+ * exists to close.
18
+ *
19
+ * WHAT DELIBERATELY STAYED BEHIND in `mux-fresh-call.ts`: placement, seat selection, runtime
20
+ * proof, the mutation-order safety argument, and the receipt type. None of those are shared —
21
+ * a herdr coordinate is not a tmux coordinate, herdr abandons pre-mutation runtime proof
22
+ * (#116 D1), and its two-step launch breaks the ordering argument outright.
23
+ */
24
+ import { randomBytes } from "node:crypto";
25
+ /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
26
+ * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, and `codex`
27
+ * by #95 after its system birth and app-server rails were measured. The set is joined to the
28
+ * citizen backends by `check-harness-admission-parity`: a harness that mints records but is
29
+ * missing HERE is not an unwired convenience, it is a release blocker. */
30
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code", "copilot", "omp", "codex"];
31
+ /**
32
+ * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
33
+ * capability directly (`entwurf_v2`), while an MCP-hosted session reaches it under whatever
34
+ * name that harness composes. Naming the wrong one costs the whole first turn.
35
+ *
36
+ * `[측정]` Copilot CLI 1.0.80 composes `<mcpServerName>-<mcpToolName>` — NOT Claude Code's
37
+ * `mcp__<server>__<tool>`. Read from two independent sessions' own event logs
38
+ * (`~/.copilot/session-state/<id>/events.jsonl`): `assistant.message.toolRequests[].name` and
39
+ * `tool.execution_start.toolName` both carry `entwurf-bridge-entwurf_v2`, with
40
+ * `mcpServerName`/`mcpToolName` beside them as the parts. Derive-and-measure, never copy a
41
+ * sibling's spelling (`docs/adding-a-harness.md` step 5).
42
+ *
43
+ * `[측정]` omp 18.0.0 is the sharpest case for that rule: it mints
44
+ * `mcp__${sanitizedServerName}_${normalizedToolName}` with a sanitizer whose charset is
45
+ * `[a-z_]` (`mcp/tool-bridge.ts:351-357`, `:396`), so the DIGIT IN `entwurf_v2` IS EATEN and the
46
+ * hyphen in the server key becomes an underscore — the model-facing name is
47
+ * `mcp__entwurf_bridge_entwurf_v`, not `..._entwurf_v2` and not Claude's double-underscore form.
48
+ * Confirmed against a live tool dump of all seven bridge tools and a real session transcript
49
+ * (`scripts/raw-omp-measure/README.md` "Tool-name dialect"). Unlike Copilot there is no second
50
+ * permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
51
+ */
52
+ export const FRESH_CALL_CALLBACK_TOOL = {
53
+ pi: "entwurf_v2",
54
+ "claude-code": "mcp__entwurf-bridge__entwurf_v2",
55
+ copilot: "entwurf-bridge-entwurf_v2",
56
+ omp: "mcp__entwurf_bridge_entwurf_v",
57
+ codex: "mcp__entwurf_bridge__entwurf_v2",
58
+ };
59
+ /**
60
+ * The same dialects, for the tool the framing OFFERS rather than requires.
61
+ *
62
+ * `[sol D2, 2026-09-18]` the corroboration sentence named a bare `entwurf_peers` to every backend,
63
+ * including the four that reach it under a composed MCP name. The prohibition it replaced was
64
+ * removed precisely because a sibling should be able to check who called it; naming that tool in a
65
+ * spelling its own session does not expose puts the offer back out of reach for exactly the
66
+ * backends that needed it most.
67
+ *
68
+ * Every entry is the callback dialect applied to `entwurf_peers`, so the two maps drift together
69
+ * or not at all. omp is the one that has to be read rather than pattern-matched: its sanitizer
70
+ * charset is `[a-z_]`, which ate the digit in `entwurf_v2` — `entwurf_peers` has no digit, so the
71
+ * SAME rule produces the whole word here.
72
+ */
73
+ export const FRESH_CALL_PEERS_TOOL = {
74
+ pi: "entwurf_peers",
75
+ "claude-code": "mcp__entwurf-bridge__entwurf_peers",
76
+ copilot: "entwurf-bridge-entwurf_peers",
77
+ omp: "mcp__entwurf_bridge_entwurf_peers",
78
+ codex: "mcp__entwurf_bridge__entwurf_peers",
79
+ };
80
+ /**
81
+ * ONE BACKEND NEEDS TO BE TOLD HOW TO REACH ITS OWN CALLBACK TOOL `[측정 2026-09-18, n=5 + n=3]`.
82
+ *
83
+ * A Claude Code child of this rail could not call `entwurf_v2` on its first turn because the tool
84
+ * was not callable yet — claude-code 2.1.267 surfaces an MCP server that is still connecting as
85
+ * DEFERRED: the NAME is listed, the schema is not, and a call without the schema fails. Across five
86
+ * isolated probes the bridge's tools were directly callable ZERO times (4 DEFERRED, 1 still
87
+ * connecting), and the framing said "FIRST ACTION … call `entwurf_v2`" without saying the one thing
88
+ * that makes that possible. A child that cannot call the tool answers in text and goes idle — which
89
+ * is exactly the "silence" this rail measured in production, 5 of 14 recorded launches.
90
+ *
91
+ * The timing story that preceded this one is retired: re-read at n=13 rather than n=4, the success
92
+ * and silence latencies overlap completely (a 52 ms child called back; a 94 ms child did not), and
93
+ * the repair worked on a 782 ms child — slower than every silent run.
94
+ *
95
+ * `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
96
+ * runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
97
+ * nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
98
+ * Both tools ride ONE `ToolSearch` select, so the corroboration the framing offers stays reachable
99
+ * for a child that takes the offer.
100
+ *
101
+ * Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
102
+ * other three were never measured to need it. A hint invented for a runtime nobody probed would be
103
+ * this rail guessing about a vendor again.
104
+ */
105
+ export const FRESH_CALL_TOOL_LOAD_HINT = {
106
+ pi: [],
107
+ "claude-code": [
108
+ "If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
109
+ 'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
110
+ "it waits for the server — then call ${callbackTool}.",
111
+ ],
112
+ copilot: [],
113
+ omp: [],
114
+ codex: [],
115
+ };
116
+ /**
117
+ * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
118
+ * name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
119
+ *
120
+ * Held equal to the installed extension's own constant by
121
+ * `test/omp-fresh-bootstrap.contract.test.ts`. The two copies exist because the extension
122
+ * ships INSIDE the omp agent dir carrying only its own small closure and cannot import this
123
+ * module; the gate is what keeps the duplication from becoming drift.
124
+ */
125
+ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
126
+ /** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
127
+ * unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
128
+ export const OMP_BOOTSTRAP_VERSION = 1;
129
+ /**
130
+ * The whole of what a fresh omp sibling is launched with.
131
+ *
132
+ * THREE FIELDS, CLOSED. The decoder refuses an unknown key, so this object is the entire
133
+ * contract: who to call back, the nonce that proves it is this call, and the task that is
134
+ * released only after that callback succeeds. There is no command here, no path, no env name
135
+ * and no model — the model is already an explicit argv token, and a second copy of it inside
136
+ * a payload would be a second place for it to disagree with the launch.
137
+ */
138
+ export function buildOmpBootstrapPayload(params) {
139
+ return JSON.stringify({
140
+ v: OMP_BOOTSTRAP_VERSION,
141
+ target: params.callerGardenId,
142
+ nonce: params.nonce,
143
+ task: params.task,
144
+ });
145
+ }
146
+ /**
147
+ * THE CODEX SOCKET IS INJECTED, NOT RESOLVED HERE, and that is the one seam #116 S2-a had to
148
+ * open. `resolveCodexDefaultSocketPath` lives in `native-push/codex-ws-client.ts`, which imports
149
+ * `entwurf-v2-contract` — importing it would have put a delivery-contract module inside the leaf
150
+ * that is supposed to know nothing about delivery. The alternative, re-deriving the path here,
151
+ * would have been a silent second copy of a vendor coordinate. So the caller passes a THUNK: it
152
+ * is evaluated only inside the codex arm, exactly as the inline call was, so a missing HOME still
153
+ * fails only for codex and never for pi.
154
+ */
155
+ /**
156
+ * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
157
+ * getting them wrong first (rail §6-a):
158
+ *
159
+ * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
160
+ * Flag-first submitted no message; Pi rejects the equals form for `--model`.
161
+ * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
162
+ * for allowedTools is variadic and eats the prompt as an option value.
163
+ * copilot — the managed VERB first, then the prompt as the value of `-i/--interactive`,
164
+ * `--model`, value as two tokens, and the policy as the explicit `--yolo`
165
+ * token. Measured from `copilot --help` (1.0.80).
166
+ * omp — NO positional prompt at all. The task rides `--entwurf-bootstrap`, a fixed
167
+ * flag the installed birth extension registers, then `--model`, value as two
168
+ * tokens, then the policy as `--approval-mode`, `yolo`. `-p/--print` remains
169
+ * the flag this argv must never carry — it processes a prompt and EXITS,
170
+ * closing the window on a sibling that has to stay open to be delivered to.
171
+ *
172
+ * Both pi/claude failures looked identical from outside: window open, record and socket minted,
173
+ * no turn.
174
+ *
175
+ * For pi and claude-code the equals form is NOT a permission guarantee — on the measured host
176
+ * the tool was already permitted, so the option's effect was unobservable. What was observed is
177
+ * that it does no harm to the argv. Permission stays a documented host precondition there.
178
+ *
179
+ * Copilot is the backend where the policy IS carried explicitly (step 9 clause 2), and three of
180
+ * its argv facts are load-bearing:
181
+ *
182
+ * - `copilot` is argv[0] of the RUNTIME `entwurf`, i.e. the managed verb — see
183
+ * `FRESH_CALL_RUNTIME`. Everything after it is forwarded byte-identical by
184
+ * `scripts/copilot-launch.sh`.
185
+ * - the prompt rides `--interactive`, never `-p/--prompt`: `-p` runs the prompt and EXITS,
186
+ * which would close the window on a sibling that is supposed to stay open and be delivered
187
+ * to. `--interactive <prompt>` is non-variadic, so the space form is safe here.
188
+ * - the policy token is `--yolo`, STATED HERE rather than left to the launcher: the launcher
189
+ * injects `--yolo` only when the argv names no policy, and step 9 clause 2 requires the
190
+ * fresh composition to state its model and permission policy explicitly, never to rely
191
+ * invisibly on someone else's default.
192
+ *
193
+ * `--yolo` is a GLG operator decision, not a drifted default. The first cut passed a
194
+ * callback-only `--allow-tool=entwurf-bridge(entwurf_v2)` grant, and GLG's 2026-08-25 operator
195
+ * LIVE measured the consequence: the fresh sibling's footer showed no `YOLO`, and every tool
196
+ * its task needed stopped on a confirmation prompt, which made the sibling impractical to work
197
+ * with. GLG then set the policy explicitly: a fresh Copilot sibling carries the same managed
198
+ * `--yolo` profile a human-typed `entwurf copilot` gets. (Copilot 1.0.80 help: `--yolo` = all
199
+ * tools + all paths + all URLs.) The permission GRAMMAR lesson from that first cut — Copilot's
200
+ * `--allow-tool` takes `<mcp-server-name>(tool-name?)`, a different dialect from the
201
+ * model-facing tool name — stays recorded in `docs/adding-a-harness.md` step 9's worked
202
+ * example; it is a measured vendor fact even though this argv no longer uses it.
203
+ *
204
+ * OMP'S POLICY TOKEN IS THE ONE MOST EASILY ARGUED AWAY, SO READ THIS BEFORE DELETING IT.
205
+ * `[측정]` omp 18.0.0's schema default for `tools.approvalMode` IS ALREADY `yolo`
206
+ * (vendor doc `omp://approval-mode.md`; `omp config get tools.approvalMode` → `yolo` on the
207
+ * acceptance host). So dropping `--approval-mode yolo` changes NOTHING observable: the callback
208
+ * still fires, the LIVE smoke still passes, and the argv silently starts depending on a vendor
209
+ * default and on whatever the operator's config happens to say. That is exactly the drift step 9
210
+ * clause 2 forbids — "carry the chosen width as an explicit argv token rather than relying on a
211
+ * launcher's injected default" — and the reason the width is stated here even though the host
212
+ * would have granted it anyway. The width itself (task-wide, not callback-only) is a GLG
213
+ * operator decision of 2026-08-30, taken with the Copilot measurement in hand: a callback-only
214
+ * sibling names itself and then stops at the first tool its TASK needs. omp offers no argv
215
+ * grammar for a narrower grant at all — `tools.approval.<tool>` is a config axis, not a flag —
216
+ * so the honest choice was between `write` and `yolo`, and `yolo` matches what a human-typed
217
+ * `omp` gets on this host. `--approval-mode` takes both the space and equals form (measured);
218
+ * the space form is used for symmetry with `--model`.
219
+ *
220
+ * WHY OMP ALONE CARRIES NO PROMPT, AND WHY THAT IS A MEASUREMENT RATHER THAN A PREFERENCE.
221
+ * `[LIVE 2026-08-30]` the first public fresh call at omp DID pass the full framing as a bare
222
+ * positional. The window opened, the record minted (garden `20260830T181342-452167`), the
223
+ * prompt arrived byte-identical as a user message at `09:13:42.413Z` — and the model answered
224
+ * the literal text `ACK` with ZERO tool calls, because the callback tool did not exist yet.
225
+ * `[source]` the interactive UI defers MCP discovery and only refreshes the tool list once
226
+ * `discoverAndConnect()` settles (`sdk.ts:1847-1855`, `:1881-1905`), while the positional
227
+ * `initialMessage` prompts immediately after `await mode.init()` (`main.ts:540-565`,
228
+ * `595-610`). `[측정]` a `/tmp` observer on the same runtime: `turn_start` at +654ms with the
229
+ * entwurf tools ABSENT, callback tool present only at +1484ms — the turn began ~830ms before
230
+ * the tool it was told to call existed. No argv can close that gap, because the gap is a race
231
+ * inside the host. So the composition hands omp a PAYLOAD instead of a turn, and the
232
+ * in-process birth extension — which can see when the tool becomes callable — owns the first
233
+ * two messages (`pi-extensions/meta-bridge-omp.ts`, "THE TWO-STAGE FRESH BOOTSTRAP").
234
+ *
235
+ * The flag is fixed and one-purpose ON PURPOSE. `[측정 2026-08-30]` a normal discovered
236
+ * extension that registers a flag receives the operator's argv value byte-identical — quotes,
237
+ * `$VAR`, backticks and a semicolon all survived a 137-byte JSON payload — because extensions
238
+ * load before argv classification and the reparse writes the registered map
239
+ * (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`). An env carrier or a temp file would
240
+ * have needed its own quoting, its own lifetime and its own refusal rules; argv already owns
241
+ * all three. This is deliberately NOT a general `--flag value` passthrough — an arbitrary
242
+ * carrier would hand callers the launch-shaping power this rail exists to refuse.
243
+ *
244
+ * CODEX ALONE CARRIES A DIRECTORY IN ITS ARGV, AND OMITTING IT IS A WRONG ANSWER RATHER THAN A
245
+ * NEUTRAL ONE. `[source rust-v0.153.4]` an explicit `--remote <endpoint>` — which this argv
246
+ * always passes — resolves to `AppServerTarget::Remote` (`codex-rs/tui/src/lib.rs:875-876`;
247
+ * `LocalDaemon` is only the IMPLICIT no-flag reuse path), and a Remote target takes its new
248
+ * thread's directory from `remote_cwd_override` ALONE: `thread_cwd_from_config`
249
+ * (`codex-rs/tui/src/app_server_session.rs:2022-2033`) answers `None` without it, and the
250
+ * app-server then opens the thread in ITS OWN directory. That override is exactly `-C/--cd`,
251
+ * retained only for a remote target (`codex-rs/tui/src/startup_orchestration.rs:191-194`), and
252
+ * the flag lives on the shared interactive options this argv already uses for
253
+ * `--dangerously-bypass-approvals-and-sandbox` (`codex-rs/utils/cli/src/shared_options.rs:53-68`)
254
+ * — not only on the `agents` subcommand that also spells it (`codex-rs/cli/src/main.rs:334`).
255
+ *
256
+ * `[측정 2026-09-16]` with the flag absent, three citizens of one chain (pi → Codex → Claude
257
+ * Code) all recorded the app-server's `~/repos/gh/entwurf` while the panes themselves sat in
258
+ * `~/repos/gh/agent-config`, and the birth hook wrote that vendor-supplied cwd into each record
259
+ * honestly (#95 lane C §1). So the token is ALWAYS present for codex: ONE directory, TWO
260
+ * CARRIERS — tmux `-c` places the PANE, codex `-C` places the THREAD, and the rail's chosen
261
+ * directory is the single value both receive. It is not a new input axis: the value is chosen by
262
+ * the cwd rules the rail already has, one layer up.
263
+ *
264
+ * BOTH CODEX FACTS ARRIVE AS THUNKS, and for the same reason: they are rail knowledge, and only
265
+ * the codex branch may pay for them. A host with no Codex home must still open pi and claude
266
+ * siblings, and the herdr rail — where codex is not a pilot backend and is refused before this
267
+ * point — supplies throwing resolvers so a future widening cannot silently inherit a guess.
268
+ */
269
+ export function composeBackendArgs(backend, composition, model, resolveCodexSocketPath, resolveCodexLaunchCwd) {
270
+ switch (backend) {
271
+ case "pi":
272
+ return [composition.prompt, "--entwurf-control", "--model", model];
273
+ case "claude-code":
274
+ return [composition.prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
275
+ case "copilot":
276
+ return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
277
+ case "omp":
278
+ return [`--${OMP_BOOTSTRAP_FLAG}`, composition.bootstrapPayload, "--model", model, "--approval-mode", "yolo"];
279
+ case "codex":
280
+ return [
281
+ "--remote",
282
+ `unix://${resolveCodexSocketPath()}`,
283
+ "-C",
284
+ resolveCodexLaunchCwd(),
285
+ "--model",
286
+ model,
287
+ "--dangerously-bypass-approvals-and-sandbox",
288
+ composition.prompt,
289
+ ];
290
+ }
291
+ }
292
+ /**
293
+ * PLACEMENT SENTENCE. The first line names where the sibling was opened, and only the RAIL knows
294
+ * that — "the operator's tmux session" is true of a `new-window` launch and false of a herdr
295
+ * pane. It is therefore supplied by the caller rather than composed here. Everything BELOW that
296
+ * line is rail-independent and stays the contract: the callback is the first action, the task
297
+ * follows it, and the three prohibitions are measured rather than polite.
298
+ */
299
+ /**
300
+ * The first-turn framing, WITHOUT the task.
301
+ *
302
+ * Split from the whole prompt because the two halves travel differently. The tmux rail hands a
303
+ * sibling one multi-line string and both halves ride together; the herdr rail cannot carry a
304
+ * newline at all (`[source herdr 7505c08]` `src/app/agents.rs:157-161` refuses any agent argument
305
+ * containing a Unicode Cc), so it folds THIS half onto one line and encodes only the task. Keeping
306
+ * the halves separate is what lets the framing stay plain prose on both rails while the operator's
307
+ * task text keeps every byte it was written with.
308
+ *
309
+ * Order is the contract: the callback is the FIRST action and the task follows it, so a sibling
310
+ * that gets stuck in the task has already told the caller who it is.
311
+ *
312
+ * WHAT THIS SAYS AND WHY IT STOPPED SAYING THE REST (#116). Until 2026-09-17 this framing carried
313
+ * three prohibitions, including "Do not inspect environment variables, do not call entwurf_self".
314
+ * `[GLG 직접, 날것 PC, 2026-09-17]` a Claude Sonnet 5 sibling REFUSED the whole first turn and named
315
+ * that line as its reason: "legitimate instructions don't need to disable your ability to
316
+ * sanity-check them". It was right, and the prohibition was costing more than the wasted turn it
317
+ * was written to prevent. What replaces it is the same steering stated as a FACT — reporting your
318
+ * identity in the pane does not reach the caller, the tool call does — plus a read-only way to
319
+ * corroborate the caller instead of an instruction not to look.
320
+ */
321
+ export function composeFreshCallFraming(params) {
322
+ const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
323
+ const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
324
+ // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
325
+ // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
326
+ // moves, and it would go stale silently, in the one sentence a stuck child depends on.
327
+ const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) => line.replaceAll("${callbackTool}", tool).replaceAll("${peersTool}", peersTool));
328
+ const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
329
+ if (params.openingLine.length === 0) {
330
+ throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
331
+ }
332
+ return [
333
+ params.openingLine,
334
+ "",
335
+ `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
336
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
337
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
338
+ `${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
339
+ "secret and grants no access. Sending it is how the caller learns the garden id you were born",
340
+ "with, which it has no other way to know. Your own record does not carry the caller's address,",
341
+ "so reporting your identity here does not reach it — that tool call does.",
342
+ "",
343
+ `You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
344
+ `this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
345
+ "a caller you do not happen to see in it is not a reason to skip the callback.",
346
+ ...hintBlock,
347
+ "",
348
+ // WHERE THE RESULT GOES, said once, as topology. `[GLG 직접, 2026-09-18]` a Sonnet sibling
349
+ // finished its task and printed the answer in its own window; the caller never saw it. An
350
+ // investigation found no broken injection — NEITHER framing, old or new, had ever said this.
351
+ // It used to work because a hand-written briefing named the target and the tool, and when the
352
+ // briefing stopped doing that the sentence turned out to live nowhere.
353
+ //
354
+ // It is a FACT about the topology, not a command about behaviour: the second half is what
355
+ // makes the first half necessary, because a sibling with a visible window has no way to know
356
+ // its window is not the delivery. Nothing here watches for completion or sends on the
357
+ // sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
358
+ // supervisor anywhere in it).
359
+ `When the task reaches its requested final result, send that result to the same target with ${tool}.`,
360
+ "Output in this sibling window is not delivered to the caller.",
361
+ ];
362
+ }
363
+ /**
364
+ * The whole first turn for a rail that can carry newlines: the framing, then the task.
365
+ */
366
+ export function composeFreshCallPrompt(params) {
367
+ return [...composeFreshCallFraming(params), "", "After the tool receipt, carry out this task:", "", params.task].join("\n");
368
+ }
369
+ /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
370
+ * encoded any of those would invite exactly the guessing this rail exists to refuse. */
371
+ export function mintNonce(randomHex = defaultRandomHex) {
372
+ return `mux-fresh-call-${randomHex()}`;
373
+ }
374
+ function defaultRandomHex() {
375
+ return randomBytes(12).toString("hex");
376
+ }
377
+ /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
378
+ * delivery surface, not a claim that a task of this size was measured through any one rail. An
379
+ * argv that the OS refuses is a launch failure and fails loud — it never reads as a delivered
380
+ * task. */
381
+ export const TASK_MAX_CHARS = 16000;
382
+ export const MODEL_MAX_CHARS = 200;
383
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:[\]-]*$/;
384
+ /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
385
+ * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
386
+ * whitespace and shell/tmux control syntax. It is passed without a shell using each runtime's
387
+ * measured CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
388
+ export function isSafeFreshCallModel(model) {
389
+ return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
390
+ }
391
+ /**
392
+ * Normalise and validate what the CALLER supplied, in the one order both rails share.
393
+ *
394
+ * Model and task are TRIMMED — that is the public contract and predates both rails, so a task
395
+ * of spaces is `task-empty` rather than a sibling born with nothing to do. Everything after this
396
+ * point travels byte-exact; the trim is the only normalisation anywhere in a launch.
397
+ *
398
+ * `cwd` is the exception that must not be "fixed": ONLY `undefined` and the exact empty string
399
+ * mean "no cwd". Every other value is the literal path, deliberately untrimmed, so a
400
+ * whitespace-mangled directory is refused loudly by each rail's classifier instead of being
401
+ * silently repaired into a different directory. The classification itself stays on the rails,
402
+ * because the reasons differ — tmux format-expands a `-c` value and herdr does not.
403
+ */
404
+ export function normalizeFreshCallInputs(params) {
405
+ if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
406
+ return { ok: false, reason: "caller-identity-unavailable" };
407
+ }
408
+ const model = params.model.trim();
409
+ if (model.length === 0)
410
+ return { ok: false, reason: "model-empty" };
411
+ if (!isSafeFreshCallModel(model))
412
+ return { ok: false, reason: "model-invalid" };
413
+ const task = params.task.trim();
414
+ if (task.length === 0)
415
+ return { ok: false, reason: "task-empty" };
416
+ if (task.length > TASK_MAX_CHARS)
417
+ return { ok: false, reason: "task-too-long" };
418
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
419
+ return {
420
+ ok: true,
421
+ inputs: { callerGardenId: params.callerGardenId, model, task, ...(cwd === undefined ? {} : { cwd }) },
422
+ };
423
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * fresh-call-dispatch — the ONE place that decides which rail opens a sibling, and the only module
3
+ * allowed to know that there are two.
4
+ *
5
+ * WHY A COMPOSITION ROOT AND NOT A BRANCH IN EACH SURFACE. Two surfaces register
6
+ * `entwurf_fresh_call`: pi's own extension and the MCP bridge. Before this file each of them
7
+ * carried the Codex preflight ordering and the render call itself, and adding a rail choice to
8
+ * both would have made four places where the wrong one could be picked. A defect planted in one
9
+ * copy survives on the other, which is precisely the shape a mutant proves nothing about. So the
10
+ * surfaces now supply identity and an env, and this module supplies everything else.
11
+ *
12
+ * THE RAIL IS A CAPABILITY FACT, NOT A PARAMETER. `HERDR_ENV=1` — exactly that value, herdr's own
13
+ * variable — means this process is inside herdr and the herdr rail is the honest placement. Every
14
+ * other value, including a missing one, stays on the tmux rail. A caller cannot ask for a rail:
15
+ * a parameter would let someone request tmux placement from inside herdr, where no tmux server
16
+ * exists, and the failure would arrive after a mutation rather than before one.
17
+ *
18
+ * NO FALLBACK, EITHER DIRECTION. If herdr was selected and its context is incomplete, the call is
19
+ * refused with the HERDR reason. Silently opening a tmux window instead would put a sibling
20
+ * somewhere the operator cannot see from where they are standing, and the receipt would look fine.
21
+ *
22
+ * WHAT THIS MODULE DOES NOT DO: it never reads the record store, never resolves a garden id, and
23
+ * never turns the direct herdr witness into a second public address. The nonce callback remains
24
+ * the one address path, on both rails.
25
+ */
26
+ import { spawn as spawnChildProcess } from "node:child_process";
27
+ import { codexCallerFreshPreflight, codexFreshPreflight } from "./codex-fresh-preflight.js";
28
+ import { mintNonce } from "./fresh-call-composition.js";
29
+ import { createHerdrRunner, herdrFreshCall, renderHerdrFreshCall, resolveHerdrContext, } from "./herdr-fresh-call.js";
30
+ import { freshCall, renderFreshCall } from "./mux-fresh-call.js";
31
+ /** herdr's own marker, read for its EXACT value. `[측정 2026-09-14]` herdr exports `HERDR_ENV=1`
32
+ * to every process it starts; anything else is not a claim herdr made. */
33
+ export function selectFreshCallRail(env) {
34
+ return env.HERDR_ENV === "1" ? "herdr" : "tmux";
35
+ }
36
+ /**
37
+ * Open a sibling on whichever rail this process is actually standing in.
38
+ *
39
+ * ORDER MATTERS TWICE. On tmux, the Codex capability preflights run BEFORE the composition, which
40
+ * is the pre-existing contract. On herdr, the backend refusal comes first: codex is not a pilot
41
+ * backend there, so running a Codex preflight would ask an irrelevant question and could fail for
42
+ * a reason that has nothing to do with why the call is impossible.
43
+ *
44
+ * THE TWO CODEX CALLER INPUTS ARE TMUX-RAIL FACTS and stop at this boundary. #95 lane B/C measured
45
+ * both against a tmux pane and an app-server-hosted bridge: a pane title is what carries the
46
+ * caller's thread-id, and a record cwd exists as an input because THAT process's directory is the
47
+ * app-server's. Inside herdr the tab is created by herdr, there is no pane title to anchor to, and
48
+ * neither fact has been measured — so the herdr rail is handed the request without consulting
49
+ * them rather than inheriting a rule from a topology it does not share.
50
+ */
51
+ export async function dispatchFreshCall(request, env = process.env, spawn, nonce = mintNonce()) {
52
+ if (selectFreshCallRail(env) === "herdr") {
53
+ const context = resolveHerdrContext(env);
54
+ if (!context.ok) {
55
+ // Selected herdr, incomplete herdr: the refusal names the herdr fact. Falling back to
56
+ // tmux here would open a window the operator cannot see from inside herdr.
57
+ return { rail: "herdr", result: { ok: false, reason: context.reason } };
58
+ }
59
+ const run = createHerdrRunner(context.context.bin, env, spawn ?? spawnChildProcess);
60
+ return { rail: "herdr", result: await herdrFreshCall(request, run, env, nonce) };
61
+ }
62
+ // TWO capability axes, in this order, both pre-mutation and neither standing in for the
63
+ // other. The TARGET axis first — "entwurf cannot open a Codex sibling here at all" is the
64
+ // more fundamental answer than "and it would not know where to put it". The CALLER axis
65
+ // second, and only when the seat anchor will actually be consulted: a codex caller that
66
+ // named an explicit placement never reads a pane title, so refusing it for a missing
67
+ // `thread-id` would refuse an unused capability.
68
+ const targetMissing = request.backend === "codex" ? await codexFreshPreflight(env) : null;
69
+ const callerMissing = targetMissing === null && request.callerNativeSessionId !== undefined && request.placement === undefined
70
+ ? codexCallerFreshPreflight(env)
71
+ : null;
72
+ const missing = targetMissing ?? callerMissing;
73
+ if (missing)
74
+ return { rail: "tmux", result: { ok: false, reason: missing } };
75
+ return {
76
+ rail: "tmux",
77
+ result: freshCall({
78
+ backend: request.backend,
79
+ model: request.model,
80
+ task: request.task,
81
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
82
+ ...(request.placement?.tmuxSession === undefined
83
+ ? {}
84
+ : { placement: { tmuxSession: request.placement.tmuxSession } }),
85
+ callerGardenId: request.callerGardenId,
86
+ ...(request.callerNativeSessionId === undefined
87
+ ? {}
88
+ : { callerNativeSessionId: request.callerNativeSessionId }),
89
+ ...(request.callerCwd === undefined ? {} : { callerCwd: request.callerCwd }),
90
+ }, env, nonce),
91
+ };
92
+ }
93
+ /** Each rail renders its own receipt: the coordinates and the failure modes differ, and a single
94
+ * "universal" renderer would have to speak about panes and windows at once, which is how prose
95
+ * starts claiming every call uses tmux. */
96
+ export function renderDispatchedFreshCall(dispatched) {
97
+ return dispatched.rail === "herdr" ? renderHerdrFreshCall(dispatched.result) : renderFreshCall(dispatched.result);
98
+ }