@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,1027 @@
1
+ /**
2
+ * smoke-herdr-fresh-call-live — the axis no deterministic gate can reach on the herdr rail: a real
3
+ * caller that is ITSELF inside a herdr pane, calling the REAL PUBLIC TOOL, and a real child whose
4
+ * first model action is the callback.
5
+ *
6
+ * OUT of `pnpm check`. Needs `LIVE=1`. Costs four model turns (two callers, two children).
7
+ *
8
+ * WHY THE CALLER MUST BE A REAL AGENT. Calling `dispatchFreshCall` from this script would prove
9
+ * the dispatcher works and nothing about the surface: the rail is selected from the CALLER's
10
+ * process environment, the caller identity comes from the surface's own record-backed context, and
11
+ * the callback has to arrive at that identity. All three only exist when a real session inside a
12
+ * herdr pane invokes the registered tool. So this smoke opens callers and reads what they did.
13
+ *
14
+ * ── Isolate the WRITES, keep the runtimes real (same split as the tmux smoke) ──
15
+ *
16
+ * REAL (runtime-owned) the authenticated runtime config: the real pi agent dir, and
17
+ * the operator's HOME/CLAUDE_CONFIG_DIR for Claude. Native
18
+ * transcripts stay there and are read here as evidence.
19
+ * FIXTURE (entwurf-owned writes) XDG roots, the four meta roots, the v2 lock dir — so every
20
+ * record, mailbox and marker this smoke mints is born and dies
21
+ * inside the fixture and the garden never sees it.
22
+ *
23
+ * HOME IS REAL FOR BOTH RUNTIMES, decided by a measurement rather than a preference.
24
+ * `[측정 2026-09-14]` a pi started with a FIXTURE HOME and the operator's real agent dir dies before
25
+ * it is ever ready: an installed extension resolves its npm dependency through `$HOME`
26
+ * (`…/home/.pi/agent/npm/node_modules/@ogulcancelik/pi-session-recall/session-recall.ts`), which a
27
+ * fresh HOME does not have, and herdr reports `timeout — timed out waiting for agent startup`
28
+ * after waiting the full 240s. So "real agent dir + fixture HOME" is not a composable pair on this
29
+ * host, and claude in a fresh HOME would enter first-run onboarding. Both runtimes therefore keep
30
+ * the operator's real HOME. Each CELL still gets its own private herdr server, because herdr's
31
+ * socket follows `XDG_CONFIG_HOME` and each cell is fenced into its own.
32
+ *
33
+ * What that costs, stated rather than hidden: a pi caller's control socket is HOME-derived with no
34
+ * env override, so it lands in the operator's `~/.pi/entwurf-control/` for the life of the caller.
35
+ * Its RECORD is still born in the fixture store, so the garden's peer listing never sees it, and
36
+ * the teardown proves that exact socket path is gone by the end. The operator's herdr server is
37
+ * snapshotted read-only before and after and must be identical.
38
+ *
39
+ * EVIDENCE IS SOURCE RECEIPTS, NEVER SCREEN TEXT — in TWO GRADES, named rather than averaged:
40
+ *
41
+ * pi axis the caller's and child's own vendor transcripts, which carry the full tool RESULT
42
+ * text. So pi proves the receipt CONTENT (nonce, view coordinates, promotes no
43
+ * address) and the task-ran-after-callback ordering as well as everything below.
44
+ * claude axis the runtime's OWN entwurf-bridge MCP activity log, written inside this smoke's
45
+ * fenced `XDG_CACHE_HOME`, joined to a session by its EXACT `sessionId` — plus
46
+ * entwurf's own mailbox artifact and hook journal. It proves the public call, the
47
+ * identity join, the delivered callback, the caller's continuation, the child's
48
+ * FIRST action, and the codex named reject. It does NOT prove the receipt text or
49
+ * the final answer content: those have no source artifact on this axis and are
50
+ * left to GLG's manual validation rather than substituted with a screen read.
51
+ *
52
+ * `[측정 2026-09-14, 첫 C4 실행]` that split exists because a claude runtime DECLARES a
53
+ * `transcript_path` in its hook envelope that never appeared on disk here. An absent declared
54
+ * transcript is a claude-runtime artifact question — NOT a rail failure — and this smoke must never
55
+ * again report it as one: the production path in that very run had succeeded end to end.
56
+ *
57
+ * AND THE VACUOUS CLAUSE THAT RUN ALSO EXPOSED: the caller's own receipt contains the nonce it
58
+ * minted, so "the nonce appears in the caller's text" can be true while the callback timed out.
59
+ * Callback arrival is therefore proved from the DELIVERED MESSAGE — body is the nonce, and the
60
+ * SENDER is the child the direct witness resolved to — never from the caller's receipt alone.
61
+ *
62
+ * THAT DELIVERED MESSAGE HAS A DIFFERENT SHAPE PER RAIL, and assuming one shape for both is a
63
+ * defect this smoke shipped once. `[측정 2026-09-15]` a claude caller is a self-fetch citizen and
64
+ * its callback lands as a file in its mailbox directory; a pi caller is a LIVE CONTROL-SOCKET
65
+ * citizen and its callback goes straight down the socket, so no `.msg` file — and no mailbox
66
+ * directory at all — is ever created for it. Reading the mailbox on the pi axis failed four cells
67
+ * on a rail whose production path had in fact worked end to end, and an empty nonce then made a
68
+ * fifth cell pass vacuously through `includes("")`. Each rail is now read for its own artifact,
69
+ * the nonce's shape is asserted before any ordering cell uses it, and the caller's id goes through
70
+ * the same official path→id conversion as the child's (a pi witness reports a transcript PATH,
71
+ * while the record is keyed by the uuid inside that filename).
72
+ *
73
+ * Nothing below reads a pane's rendered output, and nothing types a key.
74
+ */
75
+
76
+ import { spawnSync } from "node:child_process";
77
+ import fs from "node:fs";
78
+ import os from "node:os";
79
+ import path from "node:path";
80
+ import { fileURLToPath } from "node:url";
81
+ import { containsControlChar } from "../pi-extensions/lib/herdr-fresh-call.ts";
82
+ import { joinKeyOf, parseHerdrPaneList } from "../pi-extensions/lib/herdr-placement.ts";
83
+ import { assessLauncherCleanup, snapshotClaudeLauncher, verifyClaudeLauncher } from "./lib/claude-launcher-fence.ts";
84
+ import { skipLive } from "./lib/live-skip.ts";
85
+
86
+ const LABEL = "smoke-herdr-fresh-call-live";
87
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
88
+ const REAL_HOME = process.env.HOME ?? os.homedir();
89
+ const REAL_PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(REAL_HOME, ".pi", "agent");
90
+ const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR?.trim() || null;
91
+ const REAL_CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR || path.join(REAL_HOME, ".claude");
92
+
93
+ /** How long a caller's whole turn may take: it has to boot, call the tool, and have its child
94
+ * boot and call back. Bounded, and a timeout is a RED rather than a retry. */
95
+ const CELL_TIMEOUT_MS = 240_000;
96
+ /** How long the CHILD may take to call back after its CALLER's turn is already over. Separate from
97
+ * the cell bound above because it measures a different process: `[측정 2026-09-17]` a child started
98
+ * late in the caller's launch had not finished booting when the caller finished, and the cell read
99
+ * its evidence on the caller's clock. */
100
+ const CHILD_CALLBACK_WAIT_MS = 180_000;
101
+ const POLL_MS = 2_000;
102
+
103
+ let passed = 0;
104
+ const failures: string[] = [];
105
+ function ok(label: string, cond: boolean): void {
106
+ if (cond) {
107
+ console.log(` ok ${label}`);
108
+ passed++;
109
+ return;
110
+ }
111
+ console.error(` FAIL ${label}`);
112
+ failures.push(label);
113
+ }
114
+
115
+ function sleep(ms: number): void {
116
+ spawnSync("sleep", [String(ms / 1000)]);
117
+ }
118
+
119
+ /** herdr prints its refusals as JSON on STDERR with exit 1, so a helper that only carried stdout
120
+ * would report every failure as an empty string — which is exactly how the first run of this smoke
121
+ * hid its own cause. Both streams travel. */
122
+ function herdr(
123
+ bin: string,
124
+ env: NodeJS.ProcessEnv,
125
+ args: readonly string[],
126
+ ): { status: number; stdout: string; stderr: string } {
127
+ const run = spawnSync(bin, [...args], { encoding: "utf8", env, timeout: 320_000 });
128
+ return { status: run.status ?? 1, stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
129
+ }
130
+
131
+ /** The operator's own panes, read with the operator's own environment. */
132
+ function operatorPanes(bin: string): string {
133
+ const run = spawnSync(bin, ["pane", "list"], { encoding: "utf8", timeout: 30_000 });
134
+ if ((run.status ?? 1) !== 0) return "no-operator-server";
135
+ const rows = parseHerdrPaneList(run.stdout ?? "");
136
+ return rows === null ? "unreadable" : JSON.stringify(rows.map((r) => [r.paneId, r.agent, r.sessionValue]).sort());
137
+ }
138
+
139
+ function which(bin: string): boolean {
140
+ return (process.env.PATH ?? "").split(path.delimiter).some((dir) => {
141
+ try {
142
+ fs.accessSync(path.join(dir, bin), fs.constants.X_OK);
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ });
148
+ }
149
+
150
+ /** Every V3 record in the fixture store, by native session id. */
151
+ function fixtureRecords(storeDir: string): Map<string, Record<string, unknown>> {
152
+ const out = new Map<string, Record<string, unknown>>();
153
+ if (!fs.existsSync(storeDir)) return out;
154
+ for (const file of fs.readdirSync(storeDir)) {
155
+ if (!file.endsWith(".meta.json")) continue;
156
+ const record = JSON.parse(fs.readFileSync(path.join(storeDir, file), "utf8")) as Record<string, unknown>;
157
+ out.set(String(record.nativeSessionId), record);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** A pi session transcript, as text. The caller's own vendor artifact — not a screen. */
163
+ function readTranscript(file: string): string {
164
+ try {
165
+ return fs.readFileSync(file, "utf8");
166
+ } catch {
167
+ return "";
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The pi transcript as the RECORDS it is, not as one string.
173
+ *
174
+ * `[sol D1, 2026-09-18]` the ordering cell below used to compare CHARACTER OFFSETS inside the
175
+ * whole file — `indexOf(nonce) < indexOf(token)`. Both strings are already in the birth prompt, in
176
+ * that order, so the predicate was true before the child had done anything at all: it could not
177
+ * distinguish "called back, then worked" from "never called back". Line indices over the JSONL
178
+ * records are the smallest honest unit here — one record is one event, so a claim about WHICH
179
+ * event carried the nonce and WHICH came after it is a claim about the run rather than about the
180
+ * prompt we wrote.
181
+ */
182
+ function transcriptRecords(file: string): string[] {
183
+ return readTranscript(file)
184
+ .split("\n")
185
+ .filter((line) => line.trim().length > 0);
186
+ }
187
+
188
+ /**
189
+ * The id of the `entwurf_v2` toolCall in this record that IS the callback, or null. Pi writes a call
190
+ * and its result as two records; this is one half of the join that replaces a same-record read.
191
+ *
192
+ * WHAT MAKES IT THE CALLBACK, READ STRUCTURALLY `[sol 재검 2026-09-18]`. An earlier version only
193
+ * asked whether the serialised arguments CONTAINED the nonce, which a call carrying `prefix+nonce`,
194
+ * or the right nonce to the wrong target, satisfies just as well — and then the later, correct call
195
+ * is the one that produces the artifact, so the predicate could join a wrong call to a right
196
+ * delivery. The arguments are an object, so they are read as one: the message must BE the nonce,
197
+ * the target must be the caller that minted it, and the intent must be the one the framing names.
198
+ */
199
+ function entwurfCallIdFor(record: string, nonce: string, callerGid: string): string | null {
200
+ let parsed: unknown;
201
+ try {
202
+ parsed = JSON.parse(record);
203
+ } catch {
204
+ return null;
205
+ }
206
+ const content = (parsed as { message?: { content?: unknown } })?.message?.content;
207
+ if (!Array.isArray(content)) return null;
208
+ for (const part of content) {
209
+ const call = part as { type?: unknown; name?: unknown; id?: unknown; arguments?: unknown };
210
+ if (call.type !== "toolCall" || call.name !== "entwurf_v2") continue;
211
+ const args = call.arguments as
212
+ | { message?: unknown; target?: unknown; intent?: unknown; wants_reply?: unknown }
213
+ | undefined;
214
+ if (typeof args !== "object" || args === null) continue;
215
+ if (args.message !== nonce) continue;
216
+ if (args.target !== callerGid) continue;
217
+ if (args.intent !== "fire-and-forget") continue;
218
+ // The framing asks for no reply; a call that asked for one is a different act.
219
+ if (args.wants_reply === true) continue;
220
+ return typeof call.id === "string" ? call.id : null;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ /** The toolCallId this record is a RESULT for — the other half of the join. */
226
+ function toolResultIdOf(record: string): string | null {
227
+ let parsed: unknown;
228
+ try {
229
+ parsed = JSON.parse(record);
230
+ } catch {
231
+ return null;
232
+ }
233
+ const message = (parsed as { message?: { role?: unknown; toolCallId?: unknown } })?.message;
234
+ if (!message || message.role !== "toolResult") return null;
235
+ return typeof message.toolCallId === "string" ? message.toolCallId : null;
236
+ }
237
+
238
+ interface McpEntry {
239
+ readonly sessionId: string;
240
+ readonly timestamp: string;
241
+ readonly debug?: string;
242
+ readonly error?: string;
243
+ }
244
+
245
+ /**
246
+ * THE CLAUDE AXIS EVIDENCE SOURCE, and why it is not the transcript.
247
+ *
248
+ * `[측정 2026-09-14, 첫 C4 실행]` a Claude runtime's SessionStart hook envelope DECLARES a
249
+ * `transcript_path` under the operator's real config dir, and for the sessions this smoke opens
250
+ * that file never appeared on disk — not during the run, not after it. Reading it produced an
251
+ * empty string, so every assertion that quoted it failed while the production rail had in fact
252
+ * succeeded end to end. That absence is a claude-runtime artifact question, NOT a rail failure,
253
+ * and it must never again be able to read as one.
254
+ *
255
+ * So the claude axis reads what the runtime itself wrote INSIDE this smoke's fixture: its
256
+ * entwurf-bridge MCP activity log, which lands under the fenced `XDG_CACHE_HOME`. Every entry
257
+ * carries the exact `sessionId` that stamped it, so entries are grouped by that id and joined by
258
+ * EXACT equality with the official herdr witness. No filename is matched, no modification time is
259
+ * consulted, and nothing is chosen for being the newest — an axis that picks its evidence by
260
+ * recency is an axis that will eventually quote somebody else's session.
261
+ */
262
+ function mcpActivityBySession(root: string): Map<string, McpEntry[]> {
263
+ const dir = path.join(
264
+ root,
265
+ "xdg-cache",
266
+ "claude-cli-nodejs",
267
+ REPO_DIR.replace(/\//g, "-"),
268
+ "mcp-logs-entwurf-bridge",
269
+ );
270
+ const bySession = new Map<string, McpEntry[]>();
271
+ if (!fs.existsSync(dir)) return bySession;
272
+ for (const file of fs.readdirSync(dir).sort()) {
273
+ if (!file.endsWith(".jsonl")) continue;
274
+ for (const line of fs.readFileSync(path.join(dir, file), "utf8").split("\n")) {
275
+ if (!line.trim()) continue;
276
+ let entry: McpEntry;
277
+ try {
278
+ entry = JSON.parse(line) as McpEntry;
279
+ } catch {
280
+ continue;
281
+ }
282
+ if (typeof entry.sessionId !== "string" || entry.sessionId.length === 0) continue;
283
+ const bucket = bySession.get(entry.sessionId) ?? [];
284
+ bucket.push(entry);
285
+ bySession.set(entry.sessionId, bucket);
286
+ }
287
+ }
288
+ return bySession;
289
+ }
290
+
291
+ /** The tool names this session invoked, in the order it invoked them. */
292
+ function toolCallOrder(entries: readonly McpEntry[]): string[] {
293
+ const prefix = "Calling MCP tool: ";
294
+ return entries.flatMap((e) => (e.debug?.startsWith(prefix) ? [e.debug.slice(prefix.length)] : []));
295
+ }
296
+
297
+ function indexOfEntry(entries: readonly McpEntry[], match: (e: McpEntry) => boolean): number {
298
+ return entries.findIndex(match);
299
+ }
300
+
301
+ /** Every delivered message sitting in ONE garden id's mailbox directory. The directory is the
302
+ * address — nothing here scans for "the newest file" anywhere else. */
303
+ function deliveredMessages(mailboxRoot: string, gardenId: string): { file: string; text: string }[] {
304
+ const dir = path.join(mailboxRoot, gardenId);
305
+ if (!fs.existsSync(dir)) return [];
306
+ return fs
307
+ .readdirSync(dir)
308
+ .filter((f) => f.includes(".msg"))
309
+ .sort()
310
+ .map((f) => ({ file: f, text: fs.readFileSync(path.join(dir, f), "utf8") }));
311
+ }
312
+
313
+ interface Cell {
314
+ readonly label: string;
315
+ readonly childBackend: "pi" | "claude-code";
316
+ readonly callerKind: "pi" | "claude";
317
+ readonly childKind: "pi" | "claude";
318
+ readonly callerModel: string;
319
+ readonly childModel: string;
320
+ }
321
+
322
+ async function main(): Promise<void> {
323
+ if (process.env.LIVE !== "1")
324
+ skipLive(LABEL, "LIVE=1 not set — this smoke opens real herdr panes and spends four model turns");
325
+ if (!which("herdr")) skipLive(LABEL, "herdr is not on PATH — the herdr rail cannot be exercised");
326
+ if (!which("pi")) skipLive(LABEL, "the pi runtime is not on PATH");
327
+ if (!which("claude")) skipLive(LABEL, "the claude runtime is not on PATH");
328
+ if (!fs.existsSync(REAL_PI_AGENT_DIR))
329
+ skipLive(LABEL, `no pi agent dir at ${REAL_PI_AGENT_DIR} — an unauthenticated pi would fail for the wrong reason`);
330
+ if (!fs.existsSync(REAL_CLAUDE_CONFIG_DIR))
331
+ skipLive(
332
+ LABEL,
333
+ `no Claude config dir at ${REAL_CLAUDE_CONFIG_DIR} — an unauthenticated claude would fail likewise`,
334
+ );
335
+ if (!fs.existsSync(path.join(REAL_PI_AGENT_DIR, "extensions", "herdr-agent-state.ts")))
336
+ skipLive(
337
+ LABEL,
338
+ `herdr's pi integration is not installed in ${REAL_PI_AGENT_DIR} — without it herdr reports no agent_session and the join has nothing to read`,
339
+ );
340
+
341
+ const bin = "herdr";
342
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "herdr-fresh-call-live-"));
343
+ const storeDir = path.join(root, "meta-sessions");
344
+ const beforeOperator = operatorPanes(bin);
345
+
346
+ const fenced: Record<string, string> = {
347
+ XDG_CONFIG_HOME: path.join(root, "xdg-config"),
348
+ XDG_DATA_HOME: path.join(root, "xdg-data"),
349
+ XDG_STATE_HOME: path.join(root, "xdg-state"),
350
+ XDG_CACHE_HOME: path.join(root, "xdg-cache"),
351
+ XDG_RUNTIME_DIR: path.join(root, "xdg-runtime"),
352
+ ENTWURF_META_SESSIONS_DIR: storeDir,
353
+ ENTWURF_META_RECEIVERS_DIR: path.join(root, "meta-receivers"),
354
+ ENTWURF_META_SENDERS_DIR: path.join(root, "meta-senders"),
355
+ ENTWURF_META_MAILBOX_DIR: path.join(root, "meta-mailbox"),
356
+ ENTWURF_V2_LOCK_DIR: path.join(root, "v2-locks"),
357
+ };
358
+ for (const dir of Object.values(fenced)) fs.mkdirSync(dir, { recursive: true });
359
+ fs.chmodSync(fenced.XDG_RUNTIME_DIR, 0o700);
360
+
361
+ // THE ONE SUBTREE THAT MUST NOT BE FENCED, AND WHY `[측정 2026-09-18, vendor 2.1.267]`.
362
+ // The vendor resolves its two installation halves from DIFFERENT roots: the version store from
363
+ // `XDG_DATA_HOME` and the launcher from `HOME`.
364
+ //
365
+ // Wge = () => env.XDG_DATA_HOME ?? join(home, ".local", "share")
366
+ // EZe = () => join(Wge(), "claude", "versions") // version store ← XDG_DATA_HOME
367
+ // TN = () => join(home, ".local", "bin") // launcher ← HOME
368
+ //
369
+ // A child that inherits a fixture `XDG_DATA_HOME` while keeping the operator's real HOME is
370
+ // therefore looking at an EMPTY version store beside a real launcher, installs itself into the
371
+ // fixture, and repoints `$HOME/.local/bin/claude` at `<fixture>/claude/versions/<v>` — the
372
+ // operator's command now depends on a disposable tmp tree. `[측정 oracle 2026-09-18]` that is
373
+ // not a hazard, it HAPPENED: seven fixture roots from this smoke each held a full
374
+ // `claude/versions/2.1.267` plus `applications/claude-code-url-handler.desktop`, and the real
375
+ // launcher pointed into the newest of them until it was relinked by hand.
376
+ //
377
+ // The fence module's own repair is operator PARITY on the XDG roots, and this rail cannot take
378
+ // it whole: herdr's socket follows `XDG_CONFIG_HOME`, so each cell must keep its own. So it
379
+ // takes parity on the ONE subtree the incident is about — the vendor's data dir is shared with
380
+ // the operator rather than re-created empty, which puts the store and the launcher back in the
381
+ // same install. Everything else in this tree stays fenced.
382
+ //
383
+ // AND IF THERE IS NO SUCH DIR, THIS SMOKE DOES NOT RUN `[sol 재검 2026-09-18]`. The previous
384
+ // version skipped the symlink and launched anyway, on the reasoning that "the preflight below
385
+ // stands" — which was false comfort: the preflight PINS the launcher's identity, and the
386
+ // teardown oracle reports the damage, but neither prevents a child from retargeting it. Without
387
+ // this subtree there is no parity to give, so the incident's exact precondition would be rebuilt
388
+ // on purpose. A rail that cannot protect the operator's launcher declines to open the child.
389
+ const operatorClaudeData = path.join(
390
+ process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"),
391
+ "claude",
392
+ );
393
+ if (!fs.existsSync(operatorClaudeData)) {
394
+ throw new Error(
395
+ `${LABEL}: fail-closed — the operator has no vendor data dir at ${operatorClaudeData}, so the fixture cannot share ` +
396
+ "the version store the launcher resolves against. Launching anyway is the #67 precondition (fixture " +
397
+ "XDG_DATA_HOME beside a real HOME), and this smoke refuses to rebuild it.",
398
+ );
399
+ }
400
+ fs.symlinkSync(operatorClaudeData, path.join(fenced.XDG_DATA_HOME, "claude"));
401
+
402
+ // FAIL-CLOSED PREFLIGHT, before any child exists (issue #67's shared fence). This smoke keeps
403
+ // its fixture rather than removing it, so the guard that matters here is the integrity oracle
404
+ // in the teardown plus the named cleanup verdict it prints — a tree the launcher references
405
+ // must not be swept by a later hand either.
406
+ const launcher = snapshotClaudeLauncher({ env: process.env, fixtureRoot: root });
407
+
408
+ const servers: { cell: Cell; env: NodeJS.ProcessEnv; socket: string }[] = [];
409
+ const artifact = path.join(root, "receipts.md");
410
+ const lines: string[] = [`# ${LABEL} — ${new Date().toISOString()}`, ""];
411
+
412
+ const cells: Cell[] = [
413
+ {
414
+ label: "pi→pi",
415
+ childBackend: "pi",
416
+ callerKind: "pi",
417
+ childKind: "pi",
418
+ callerModel: "openai-codex/gpt-5.6-sol",
419
+ childModel: "openai-codex/gpt-5.6-sol",
420
+ },
421
+ {
422
+ label: "claude→claude",
423
+ childBackend: "claude-code",
424
+ callerKind: "claude",
425
+ childKind: "claude",
426
+ callerModel: "opus",
427
+ // THE CHILD IS THE SAFETY-TUNED MODEL ON PURPOSE (#116, 2026-09-17). This cell pinned
428
+ // `opus` on both sides and passed, while `[GLG 직접, 날것 PC]` a Sonnet 5 child refused the
429
+ // first turn outright — so the gate had never once exercised the layer that refuses, which
430
+ // is the layer a user installing this rail actually meets. The CALLER stays on opus: it is
431
+ // not the side under test here. Override to sample another model; the default is the
432
+ // harder oracle, not the convenient one.
433
+ childModel: process.env.ENTWURF_HERDR_LIVE_CHILD_MODEL?.trim() || "sonnet",
434
+ },
435
+ ];
436
+
437
+ try {
438
+ for (const cell of cells) {
439
+ console.log(`\n[${LABEL}] cell ${cell.label}`);
440
+ // ── one private server per cell, with that runtime's own HOME ────────────────
441
+ const env: NodeJS.ProcessEnv = { ...process.env, ...fenced, PI_CODING_AGENT_DIR: REAL_PI_AGENT_DIR };
442
+ // herdr's socket follows XDG_CONFIG_HOME, so each cell needs its own.
443
+ const cellConfig = path.join(root, `${cell.label.replace(/[^a-z]/g, "")}-xdg-config`);
444
+ fs.mkdirSync(cellConfig, { recursive: true });
445
+ env.XDG_CONFIG_HOME = cellConfig;
446
+ // REAL HOME for both runtimes — see the header: a fixture HOME kills pi before it is
447
+ // ready, and Claude would enter first-run onboarding. Entwurf's own writes stay
448
+ // fenced through the meta roots regardless of HOME.
449
+ env.HOME = REAL_HOME;
450
+ if (ORIGINAL_CLAUDE_CONFIG_DIR) env.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
451
+ delete env.HERDR_ENV;
452
+ delete env.HERDR_PANE_ID;
453
+ delete env.HERDR_BIN_PATH;
454
+ delete env.PI_SESSION_ID;
455
+ delete env.PI_AGENT_ID;
456
+ delete env.TMUX;
457
+ delete env.TMUX_PANE;
458
+
459
+ const socket = path.join(cellConfig, "herdr", "herdr.sock");
460
+ const log = fs.openSync(path.join(root, `${cell.label.replace(/[^a-z]/g, "")}-server.log`), "a");
461
+ const server = spawnSync("sh", ["-c", `nohup "${bin}" server >/dev/null 2>&1 &`], { env, encoding: "utf8" });
462
+ fs.closeSync(log);
463
+ if ((server.status ?? 1) !== 0) throw new Error(`${LABEL}: could not start the private herdr server`);
464
+ const deadline = Date.now() + 20_000;
465
+ while (Date.now() < deadline && !fs.existsSync(socket)) sleep(500);
466
+ if (!fs.existsSync(socket)) throw new Error(`${LABEL}: the private herdr server never opened ${socket}`);
467
+ servers.push({ cell, env, socket });
468
+ ok(`${cell.label}: the herdr server is PRIVATE — its socket is inside the fixture`, socket.startsWith(root));
469
+
470
+ const ws = herdr(bin, env, ["workspace", "create", "--label", "live", "--no-focus", "--cwd", REPO_DIR]);
471
+ if (ws.status !== 0) throw new Error(`${LABEL}: workspace create failed: ${ws.stderr || ws.stdout}`);
472
+
473
+ // ── the CALLER: a real agent, inside a herdr pane, told to call the PUBLIC tool ──
474
+ const callerSplit = herdr(bin, env, [
475
+ "pane",
476
+ "split",
477
+ "--pane",
478
+ "w1:p1",
479
+ "--direction",
480
+ "down",
481
+ "--no-focus",
482
+ "--cwd",
483
+ REPO_DIR,
484
+ "--env",
485
+ "PI_SESSION_ID=",
486
+ "--env",
487
+ "PI_AGENT_ID=",
488
+ ]);
489
+ if (callerSplit.status !== 0)
490
+ throw new Error(`${LABEL}: the caller pane split failed: ${callerSplit.stderr || callerSplit.stdout}`);
491
+ const callerPane = JSON.parse(callerSplit.stdout).result.pane as { pane_id: string; terminal_id: string };
492
+ const token = `live-${cell.label.replace(/[^a-z]/g, "")}-${Math.random().toString(16).slice(2, 10)}`;
493
+ const childTask = [
494
+ "이 측정의 과제는 두 줄이다.",
495
+ `두 번째 줄의 확인 토큰은 ${token} 이다 — 위 콜백을 먼저 보낸 뒤, 이 토큰을 그대로 담아 한 문장으로만 답해 달라.`,
496
+ ].join("\n");
497
+ const callerTask = [
498
+ "You are the CALLER in an entwurf acceptance run, running inside a herdr pane.",
499
+ "Do exactly this and nothing else. Do not call any tool other than entwurf_fresh_call.",
500
+ "",
501
+ `1. Call the entwurf_fresh_call tool with backend="${cell.childBackend}", model="${cell.childModel}", and this exact task string:`,
502
+ childTask.replace(/\n/g, "\\n"),
503
+ '2. Then call entwurf_fresh_call ONE more time with backend="codex" and the same model and task.',
504
+ "3. Report both tool results verbatim in your final answer and stop. Do not retry anything, do not open anything else.",
505
+ ].join("\n");
506
+ // The CALLER gets the transport constraint (one physical line, zero control characters —
507
+ // `[source herdr 7505c08]` src/app/agents.rs:157-161) but NOT the production framing:
508
+ // `composeFreshCallPrompt` would order it to call back to a garden id, and this fixture
509
+ // has none to give — the first run did exactly that and sent the caller dispatching at
510
+ // an id that does not exist. The child's framing is composed by PRODUCTION when the
511
+ // caller invokes the tool, which is the thing under test. It is folded here rather than
512
+ // run through `encodeBirthPrompt`, which now composes a production framing this fixture
513
+ // deliberately does not want.
514
+ const callerArgv = callerTask
515
+ .split("\n")
516
+ .filter((line) => line.length > 0)
517
+ .join(" ");
518
+ if (containsControlChar(callerArgv)) throw new Error(`${LABEL}: the caller prompt carries a control character`);
519
+ const callerArgs =
520
+ cell.callerKind === "pi"
521
+ ? [callerArgv, "--approve", "--entwurf-control", "--model", cell.callerModel]
522
+ : [callerArgv, `--model=${cell.callerModel}`];
523
+ const started = herdr(bin, env, [
524
+ "agent",
525
+ "start",
526
+ `livecaller${cell.label.replace(/[^a-z]/g, "")}`,
527
+ "--kind",
528
+ cell.callerKind,
529
+ "--pane",
530
+ callerPane.pane_id,
531
+ // The smoke's OWN caller may boot slower than herdr's 30s default on a
532
+ // package-heavy install. The CHILD keeps the production default, because that
533
+ // default is part of what this acceptance is judging.
534
+ "--timeout",
535
+ "180000",
536
+ "--",
537
+ ...callerArgs,
538
+ ]);
539
+ if (started.status !== 0)
540
+ throw new Error(`${LABEL}: the caller did not start: ${started.stderr || started.stdout}`);
541
+ const callerSession = JSON.parse(started.stdout).result.agent.agent_session as { value: string } | null;
542
+ ok(`${cell.label}: the caller is itself inside herdr and herdr reported its session`, callerSession !== null);
543
+
544
+ // ── wait for the caller's turn, reading only source receipts ────────────────
545
+ //
546
+ // TWO EVIDENCE GRADES, NAMED RATHER THAN AVERAGED. The pi axis is read from the
547
+ // caller's own vendor transcript, which carries the full tool RESULT text — so the
548
+ // receipt-content claims (nonce, view coordinates, promotes-no-address) and the
549
+ // task-after-callback ordering are asserted there. `[측정 2026-09-14]` the claude axis
550
+ // has no such artifact on this host: its runtime declares a `transcript_path` that
551
+ // never materialised, so that axis is read from the runtime's OWN entwurf-bridge MCP
552
+ // activity log inside this fixture, plus entwurf's own mailbox and hook receipts.
553
+ // Neither axis reads a screen and neither types a key. What claude therefore does NOT
554
+ // prove here is the receipt TEXT and the final answer content; that belongs to GLG's
555
+ // manual validation after M1, and it is written down rather than quietly dropped.
556
+ const callerNative = String(callerSession?.value ?? "");
557
+ const callerIsPi = cell.callerKind === "pi";
558
+ const callerTranscript = callerIsPi
559
+ ? callerNative
560
+ : path.join(REAL_CLAUDE_CONFIG_DIR, "projects", REPO_DIR.replace(/\//g, "-"), `${callerNative}.jsonl`);
561
+ const cellDeadline = Date.now() + CELL_TIMEOUT_MS;
562
+ let text = "";
563
+ let callerActivity: McpEntry[] = [];
564
+ while (Date.now() < cellDeadline) {
565
+ if (callerIsPi) {
566
+ text = readTranscript(callerTranscript);
567
+ if (text.includes("[entwurf fresh call → herdr]") && text.includes("herdr-backend-unsupported")) break;
568
+ } else {
569
+ callerActivity = mcpActivityBySession(root).get(callerNative) ?? [];
570
+ const done = callerActivity.some((e) => (e.error ?? "").includes("herdr-backend-unsupported"));
571
+ if (done) break;
572
+ }
573
+ sleep(POLL_MS);
574
+ }
575
+ lines.push(
576
+ `## ${cell.label}`,
577
+ "",
578
+ "```",
579
+ callerIsPi ? text.slice(-4000) : JSON.stringify(callerActivity, null, 1),
580
+ "```",
581
+ "",
582
+ );
583
+
584
+ // ── the child, in the private server's own JSON ─────────────────────────────
585
+ const panes = parseHerdrPaneList(herdr(bin, env, ["pane", "list"]).stdout) ?? [];
586
+ const childRow = panes.find((row) => row.paneId !== callerPane.pane_id && row.sessionValue !== null);
587
+ const records = fixtureRecords(storeDir);
588
+ const joined = childRow === null || childRow === undefined ? null : joinKeyOf(childRow);
589
+ const childRecord = joined === null ? undefined : records.get(joined.nativeSessionId);
590
+ ok(
591
+ `${cell.label}: the child's direct herdr witness resolves to EXACTLY ONE fixture record through the official conversion`,
592
+ joined !== null &&
593
+ childRecord !== undefined &&
594
+ [...records.values()].filter((r) => r.nativeSessionId === joined.nativeSessionId).length === 1,
595
+ );
596
+ const childGid = String(childRecord?.gardenId ?? "");
597
+ // The CALLER's id goes through the SAME official conversion as the child's. `[측정
598
+ // 2026-09-15]` reading it raw was a real miss: a pi witness reports `kind: "path"`, so
599
+ // `agent_session.value` is a transcript PATH while the record is keyed by the uuid
600
+ // inside that filename. The strict path→id rule is a measured vendor floor (S2-b:
601
+ // grepping the store for the full path literal returns zero records), and bypassing it
602
+ // for the caller made this cell fail on a rail that had worked.
603
+ const callerRow = panes.find((row) => row.paneId === callerPane.pane_id);
604
+ const callerJoined = callerRow === undefined ? null : joinKeyOf(callerRow);
605
+ const callerNativeId = callerJoined?.nativeSessionId ?? "";
606
+ const callerRecord = callerNativeId === "" ? undefined : records.get(callerNativeId);
607
+ const callerGid = String(callerRecord?.gardenId ?? "");
608
+ ok(
609
+ `${cell.label}: the CALLER's own herdr witness also resolves to exactly one fixture record through the official conversion — the address the child had to call is a record, never a pane`,
610
+ callerJoined !== null &&
611
+ callerRecord !== undefined &&
612
+ callerGid.length > 0 &&
613
+ callerGid !== childGid &&
614
+ [...records.values()].filter((r) => r.nativeSessionId === callerNativeId).length === 1,
615
+ );
616
+
617
+ // ── the callback, in whichever rail its CALLER actually answers on ──────────
618
+ //
619
+ // `[측정 2026-09-15]` this evidence is RAIL-SPECIFIC and collapsing it onto one rail is
620
+ // a defect this smoke already shipped once. A claude caller is a self-fetch citizen, so
621
+ // its callback lands as a delivered file in its mailbox directory. A pi caller is a
622
+ // LIVE CONTROL-SOCKET citizen: the message goes straight down the socket, and no `.msg`
623
+ // file — no mailbox directory at all — is ever created for it. Reading the mailbox on
624
+ // the pi axis failed four cells on a rail whose production path had worked end to end.
625
+ //
626
+ // What both forms must carry is the same pair, and it is the pair rather than the nonce
627
+ // alone that proves arrival: the caller's own receipt contains the nonce it MINTED, so
628
+ // a nonce sighting in the caller's text is not evidence that anything came back. The
629
+ // sender identity is. So each rail is read for: body === the nonce, and sender ===
630
+ // the child the direct witness resolved to.
631
+ // THE CHILD HAS ITS OWN CLOCK, and this is where that was measured (#116, 2026-09-17).
632
+ // The loop above waits for the CALLER to finish its two tool calls; the child was
633
+ // started somewhere inside the first of them and may still be booting when the caller
634
+ // is done. Two consecutive runs of this same code differed only there — the caller's
635
+ // launch took 56s in one and 37s in the other, and only the slower one gave the child
636
+ // enough head start to have called back by the time this line ran. Judging the child on
637
+ // the caller's clock is a race, and a race that reports a healthy rail as red. So the
638
+ // child gets its own bounded wait for the artifact BEFORE anything is asserted about it.
639
+ const childCallbackDeadline = Date.now() + CHILD_CALLBACK_WAIT_MS;
640
+ while (Date.now() < childCallbackDeadline) {
641
+ const seen = callerIsPi
642
+ ? /"customType":"entwurf-message"/.test(readTranscript(callerTranscript))
643
+ : deliveredMessages(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid).length > 0;
644
+ if (seen) break;
645
+ sleep(POLL_MS);
646
+ }
647
+ if (callerIsPi) text = readTranscript(callerTranscript);
648
+
649
+ // ── what the child was DOING when we stopped waiting ────────────────────────
650
+ //
651
+ // Diagnosis only — nothing below reads this, and no claim is made from it. It exists
652
+ // because `[측정 2026-09-17]` a claude child that received its prompt and then called no
653
+ // tool is indistinguishable, from every artifact this smoke had, between "it answered
654
+ // and declined" and "it was still thinking". Two facts separate them and both are
655
+ // cheap:
656
+ //
657
+ // herdr agent status `idle`/`done` means the turn ENDED with no tool call — the
658
+ // child answered something and stopped. `working` means the
659
+ // bound was short. `blocked` means it is waiting on a human.
660
+ // the hook journal entwurf's own SessionStart/UserPromptSubmit stamps. `[측정
661
+ // 2026-09-17]` a silent child still stamps UserPromptSubmit
662
+ // ~300ms after SessionStart, so "the prompt never arrived" is
663
+ // already excluded and must not be re-guessed.
664
+ //
665
+ // The claude child leaves NO transcript to read: `[측정 2026-09-17]` every claude
666
+ // session in this smoke — callers and children, acting and silent — is absent from the
667
+ // operator's real `~/.claude/projects/` and from every fenced XDG root, including one
668
+ // child that had just made two successful tool calls. That absence is a claude-runtime
669
+ // fact, re-measured with HOME real, and is why the two stamps below are the evidence.
670
+ const agentStatus = herdr(bin, env, ["agent", "list"]).stdout.trim();
671
+ const hookJournal = ((): string => {
672
+ try {
673
+ return fs
674
+ .readFileSync(path.join(root, "meta-bridge-hook.log"), "utf8")
675
+ .trimEnd()
676
+ .split("\n")
677
+ .slice(-8)
678
+ .join("\n");
679
+ } catch {
680
+ return "(no hook journal)";
681
+ }
682
+ })();
683
+ lines.push(
684
+ `### ${cell.label} — child diagnosis at the end of the wait`,
685
+ "",
686
+ "```",
687
+ `herdr agent list: ${agentStatus.slice(0, 2000)}`,
688
+ "",
689
+ hookJournal,
690
+ "```",
691
+ "",
692
+ );
693
+
694
+ let nonce = "";
695
+ let callbackArrived = false;
696
+ let callbackForm = "";
697
+ if (callerIsPi) {
698
+ // The socket rail's artifact is the delivered `entwurf-message` in the caller's own
699
+ // transcript: the body is the message, and `<sender_info>` is the envelope entwurf
700
+ // synthesised at the receiver (`entwurf-control-rpc.ts` formatSenderInfoBlock).
701
+ const message = /"customType":"entwurf-message","content":"((?:[^"\\]|\\.)*)"/.exec(text)?.[1] ?? "";
702
+ const decoded = message.replace(/\\n/g, "\n").replace(/\\"/g, '"');
703
+ nonce = /(?:herdr|mux)-fresh-call-[0-9a-f]{24}/.exec(decoded)?.[0] ?? "";
704
+ const senderId = /<sender_info>\{[^}]*"sessionId":"([^"]+)"/.exec(decoded)?.[1] ?? "";
705
+ callbackArrived = nonce.length > 0 && decoded.trimStart().startsWith(nonce) && senderId === childGid;
706
+ callbackForm = `control-socket entwurf-message (sender=${senderId || "none"})`;
707
+ } else {
708
+ const delivered = deliveredMessages(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid);
709
+ const body = (delivered[0]?.text ?? "").split(/─{5,}/)[1]?.trim() ?? "";
710
+ nonce = /(?:herdr|mux)-fresh-call-[0-9a-f]{24}/.exec(body)?.[0] ?? "";
711
+ // THE FIRST ARTIFACT, NOT THE ONLY ONE `[측정 2026-09-18, LIVE run #2]`. This read
712
+ // required `delivered.length === 1`, which the framing itself retired: since the
713
+ // first turn asks the sibling to report its result back, a child that obeys sends
714
+ // TWO messages — the nonce at 13:46:44 and `확인 토큰은 …` at 13:46:50 — and the
715
+ // oracle failed the run for doing exactly what it was told. What the claim is about
716
+ // is the FIRST one: the callback precedes the work, so the nonce must be the body of
717
+ // the first artifact, and anything after it is the sibling answering.
718
+ callbackArrived =
719
+ delivered.length >= 1 &&
720
+ nonce.length > 0 &&
721
+ body === nonce &&
722
+ (delivered[0]?.text ?? "").includes(`session: ${childGid}`);
723
+ callbackForm = `meta-mailbox delivered artifact (${delivered.length} file(s))`;
724
+ }
725
+ ok(
726
+ `${cell.label}: the child's callback ARRIVED on the rail this caller answers on — its body is a production nonce and its sender is the child the direct witness resolved to, never the nonce the caller minted for itself — ${callbackForm}`,
727
+ callbackArrived && childGid.length > 0,
728
+ );
729
+ // Nothing below may run on an empty nonce: `includes("")` is true for every string, and
730
+ // a vacuous green here is exactly what the first corrected run produced.
731
+ ok(
732
+ `${cell.label}: the nonce used by every ordering cell below came from the DELIVERED callback, not from a default`,
733
+ /^(?:herdr|mux)-fresh-call-[0-9a-f]{24}$/.test(nonce),
734
+ );
735
+
736
+ // ── the caller went on living ───────────────────────────────────────────────
737
+ // A receiving session killed while answering is the exact shape of the C4 blocker and
738
+ // it reads as an artifact that simply stops. Rail-specific again: the claude hook
739
+ // journal records prompt submissions by native session id, while a pi caller writes
740
+ // nothing there — its own transcript carries the delivered message and whatever it did
741
+ // after it.
742
+ const callerStillRunning = panes.some(
743
+ (row) => row.paneId === callerPane.pane_id && row.sessionValue === callerNative,
744
+ );
745
+ let continued = false;
746
+ let continuationForm = "";
747
+ if (callerIsPi) {
748
+ const at = text.indexOf('"customType":"entwurf-message"');
749
+ continued = at > 0 && /"role":"assistant"/.test(text.slice(at));
750
+ continuationForm = "an assistant message after the delivered entwurf-message";
751
+ } else {
752
+ const hookLog = readTranscript(path.join(root, "meta-bridge-hook.log"));
753
+ const enqueuedAt = String(
754
+ (
755
+ JSON.parse(
756
+ readTranscript(path.join(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid, "state.json")) || "{}",
757
+ ) as { lastEnqueuedAt?: string }
758
+ ).lastEnqueuedAt ?? "",
759
+ );
760
+ continued =
761
+ enqueuedAt.length > 0 &&
762
+ hookLog
763
+ .split("\n")
764
+ .filter((l) => l.includes("event=UserPromptSubmit") && l.includes(`native=${callerNative}`))
765
+ .some((l) => (/^\S+/.exec(l)?.[0] ?? "") > enqueuedAt);
766
+ continuationForm = "a UserPromptSubmit for this exact native session after the enqueue stamp";
767
+ }
768
+ ok(
769
+ `${cell.label}: the caller took the callback INTO its turn and is still the live agent in its own pane — a caller killed mid-answer is what this axis exists to catch — ${continuationForm}`,
770
+ continued && callerStillRunning,
771
+ );
772
+
773
+ // ── what each axis proves about the two tool calls ──────────────────────────
774
+ if (callerIsPi) {
775
+ ok(
776
+ `${cell.label}: the PUBLIC tool answered with the HERDR receipt — never the tmux one`,
777
+ text.includes("[entwurf fresh call → herdr]") && !text.includes("[entwurf fresh call →]\\n backend"),
778
+ );
779
+ ok(
780
+ `${cell.label}: that receipt carries the delivered nonce and herdr view coordinates — the TAB it created and that tab's initial pane, which is the placement policy this rail actually ran`,
781
+ text.includes(nonce) && /w\d+:p/.test(text) && /w\d+:t/.test(text),
782
+ );
783
+ ok(
784
+ `${cell.label}: the receipt promotes NO address — no garden id and no native session id in the tool text`,
785
+ !/\bgardenId\b/.test(text) && text.includes("VIEW coordinates, not an address"),
786
+ );
787
+ ok(
788
+ `${cell.label}: the in-herdr negative cell refused a non-pilot backend BY NAME`,
789
+ text.includes("herdr-backend-unsupported"),
790
+ );
791
+ ok(
792
+ `${cell.label}: the caller recorded BOTH tool results and no stream error reached its transcript — an async EPIPE on a control socket is what killed a resident session on the first run of this axis`,
793
+ text.includes(childGid) && !/\bEPIPE\b|\bECONNRESET\b|uncaughtException/.test(text),
794
+ );
795
+ } else {
796
+ const order = toolCallOrder(callerActivity);
797
+ const successAt = indexOfEntry(callerActivity, (e) =>
798
+ (e.debug ?? "").startsWith("Tool 'entwurf_fresh_call' completed successfully"),
799
+ );
800
+ const rejectAt = indexOfEntry(callerActivity, (e) => (e.error ?? "").includes("herdr-backend-unsupported"));
801
+ ok(
802
+ `${cell.label}: the caller's own MCP activity log — joined by its EXACT session id, never by filename or recency — shows the PUBLIC entwurf_fresh_call completing successfully and NOTHING else called before it`,
803
+ callerActivity.length > 0 && order[0] === "entwurf_fresh_call" && successAt >= 0,
804
+ );
805
+ ok(
806
+ `${cell.label}: the in-herdr negative cell refused a non-pilot backend BY NAME, after the successful call — nothing was created and no tmux fallback was taken`,
807
+ rejectAt > successAt &&
808
+ (callerActivity[rejectAt]?.error ?? "").includes("No tab and no pane were created.") &&
809
+ order.filter((t) => t === "entwurf_fresh_call").length === 2,
810
+ );
811
+ }
812
+
813
+ // ── the child's FIRST action was the callback ───────────────────────────────
814
+ const childNative = String(joined?.nativeSessionId ?? childRow?.sessionValue ?? "");
815
+ if (cell.childKind === "pi") {
816
+ const childRecords = transcriptRecords(String(childRow?.sessionValue ?? ""));
817
+ const childText = childRecords.join("\n");
818
+ // THE JOIN, NOT THE RECORD, IS WHAT THIS CLAIM IS ABOUT `[측정 2026-09-18]`. An earlier
819
+ // version asked for ONE record carrying both the delivered nonce and the `sent`
820
+ // outcome. Pi's transcript can never satisfy that: the nonce rides the toolCall record
821
+ // (`content[].toolCall.id`) and the outcome rides the separate toolResult record
822
+ // (`message.toolCallId`), and the two are joined by that id. It failed a run whose
823
+ // child did exactly the right thing — callback at .381, `sent` at .415, task token at
824
+ // 03.728 — which is the most expensive kind of red there is. What the one-record rule
825
+ // was guarding against ("some nonce appeared somewhere earlier, so call it proof") is
826
+ // held by the join itself: the outcome must belong to THE call that carried THIS nonce.
827
+ const callAt = childRecords.findIndex((record) => entwurfCallIdFor(record, nonce, callerGid) !== null);
828
+ const callId = callAt >= 0 ? entwurfCallIdFor(childRecords[callAt], nonce, callerGid) : null;
829
+ const sentAt =
830
+ callId === null
831
+ ? -1
832
+ : childRecords.findIndex(
833
+ (record, i) =>
834
+ i > callAt &&
835
+ record.includes("entwurf_v2 control-socket → sent") &&
836
+ toolResultIdOf(record) === callId,
837
+ );
838
+ // The LAST mention of the task token, so the birth prompt — which carries it, first —
839
+ // cannot be what satisfies "the work came after".
840
+ let workedAt = -1;
841
+ for (let i = childRecords.length - 1; i >= 0; i -= 1) {
842
+ if (childRecords[i].includes(token)) {
843
+ workedAt = i;
844
+ break;
845
+ }
846
+ }
847
+ ok(
848
+ `${cell.label}: the child decoded the one-line birth argv and ran the task only AFTER its callback — the \`sent\` outcome belongs, by toolCallId, to the very call that carried the delivered nonce, and the task token appears in a record after it`,
849
+ nonce.length > 0 && callAt >= 0 && sentAt > callAt && workedAt > sentAt,
850
+ );
851
+ ok(
852
+ `${cell.label}: the child's OWN tool result says the callback was DELIVERED on the rail its caller answers on — \`entwurf_v2 control-socket → sent\` — not a timeout, not a reject, not a dirty lock`,
853
+ childText.includes("entwurf_v2 control-socket → sent") &&
854
+ !/entwurf_v2 [a-z-]+ (?:execution failed:|DELIVERED \()/.test(childText) &&
855
+ !/entwurf_v2 control-socket → (?!sent)[a-z-]+/.test(childText),
856
+ );
857
+ } else {
858
+ const childActivity = mcpActivityBySession(root).get(childNative) ?? [];
859
+ const order = toolCallOrder(childActivity);
860
+ // THE CALLBACK COMES BEFORE THE TASK — and a read-only corroboration may come before
861
+ // BOTH. `[측정 2026-09-17, oracle, LIVE]` this cell used to require `order[0] ===
862
+ // "entwurf_v2"`, and a Sonnet 5 child failed it by calling `entwurf_peers` first and
863
+ // the callback second. That is not a violation: the framing this rail now sends
864
+ // OFFERS exactly that corroboration ("you can corroborate the caller first if you
865
+ // want to"), so the old oracle contradicted our own prompt and would have forbidden
866
+ // the behaviour we asked for. What still must hold is everything the claim was
867
+ // actually about — the callback lands before any work, it completes, and no
868
+ // entwurf_v2 in the log failed or timed out.
869
+ // ONLY what the framing actually offers. `entwurf_self` used to sit in this set and
870
+ // nothing ever proposed it to the child — an allowance for a tool we do not mention
871
+ // widens the oracle without widening the contract (sol D1, 2026-09-18).
872
+ const READ_ONLY_FIRST = new Set(["entwurf_peers"]);
873
+ const beforeCallback = order.slice(0, Math.max(order.indexOf("entwurf_v2"), 0));
874
+ // THE JOIN THIS AXIS CAN ACTUALLY MAKE. The claude MCP log records WHICH tool was
875
+ // called and whether it completed — never its arguments or its result body — so
876
+ // "the first entwurf_v2 completed" alone would also be true of a call that delivered
877
+ // somebody else's nonce or came back as a semantic reject over a successful
878
+ // transport. The second artifact closes it: the caller's own delivered message
879
+ // carries the EXACT nonce and the child as its sender, and its enqueue timestamp has
880
+ // to fall inside the window of that first call. Two independent records, one event.
881
+ const callAt = indexOfEntry(childActivity, (e) => (e.debug ?? "") === "Calling MCP tool: entwurf_v2");
882
+ const doneAt = indexOfEntry(childActivity, (e) =>
883
+ (e.debug ?? "").startsWith("Tool 'entwurf_v2' completed successfully"),
884
+ );
885
+ const callbackStamp = deliveredMessages(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid)[0]?.file ?? "";
886
+ const stampedAt = Date.parse(
887
+ /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/.exec(callbackStamp)
888
+ ? callbackStamp.replace(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z.*$/, "$1T$2:$3:$4.$5Z")
889
+ : "",
890
+ );
891
+ const within =
892
+ callAt >= 0 &&
893
+ doneAt > callAt &&
894
+ Number.isFinite(stampedAt) &&
895
+ stampedAt >= Date.parse(childActivity[callAt]?.timestamp ?? "") &&
896
+ stampedAt <= Date.parse(childActivity[doneAt]?.timestamp ?? "");
897
+ ok(
898
+ // WHAT THIS AXIS CAN SEE, SAID EXACTLY `[sol 재검 2026-09-18]`. The claim used to read
899
+ // "called back BEFORE doing any work". This cell's only evidence is the child's MCP
900
+ // tool activity — §14 measured that a claude child of this rail leaves no transcript
901
+ // anywhere — so assistant text it may have produced before the call is invisible
902
+ // here, and a claim about "any work" is wider than the oracle. What IS observed, and
903
+ // is the thing the framing actually asks for, is the ORDER OF TOOLS: the first
904
+ // non-read-only tool this child called was the callback.
905
+ `${cell.label}: the child's FIRST non-read-only tool call was the callback — the FIRST entwurf_v2 completed successfully and the delivered callback carrying this exact nonce was enqueued inside that call's own window, preceded only by the read-only corroboration the framing offers, with no failed or timed-out entwurf_v2 anywhere in its log (this cell reads tool activity only; assistant text is not observable on this rail)`,
906
+ childActivity.length > 0 &&
907
+ order.includes("entwurf_v2") &&
908
+ beforeCallback.every((name) => READ_ONLY_FIRST.has(name)) &&
909
+ within &&
910
+ callbackArrived &&
911
+ !childActivity.some(
912
+ (e) => (e.debug ?? "").startsWith("Tool 'entwurf_v2' failed") || (e.error ?? "").includes("entwurf_v2"),
913
+ ),
914
+ );
915
+ }
916
+ // ── HFC-LIVE-FINAL-RESULT-DELIVERED: the framing's last line, actually observed ──
917
+ //
918
+ // `dc550cd` added one sentence to the first turn — tell the sibling where its result
919
+ // goes — and nothing yet measured that a sibling obeys it. `[측정 2026-09-18, LIVE #2]`
920
+ // one does: the claude child sent the nonce at 13:46:44 and `확인 토큰은 …` at 13:46:50,
921
+ // to the same caller, on the same rail. That second message is the acceptance of the
922
+ // framing line, so it is asserted rather than tolerated — and it is asserted AFTER the
923
+ // callback cells, because arriving first would be the framing being disobeyed.
924
+ // It gets the child's own bound for the same reason the callback does: the caller's
925
+ // clock says nothing about how long the sibling takes to finish its task.
926
+ {
927
+ // THE SENDER IS HALF THE CLAIM `[sol 재검 2026-09-18]`. "The sibling reported its
928
+ // result" is a statement about WHO sent it, and a body-only read makes it true of any
929
+ // message that happens to carry the token — including one the caller wrote to itself.
930
+ // So each rail is read for the pair, exactly as the callback cell above is: the body
931
+ // carries the token AND the same record's sender is the child.
932
+ const resultDeadline = Date.now() + CHILD_CALLBACK_WAIT_MS;
933
+ let resultText = "";
934
+ let resultSender = "";
935
+ while (Date.now() < resultDeadline) {
936
+ if (callerIsPi) {
937
+ const messages = [
938
+ ...readTranscript(callerTranscript).matchAll(
939
+ /"customType":"entwurf-message","content":"((?:[^"\\]|\\.)*)"/g,
940
+ ),
941
+ ]
942
+ .map((match) => match[1].replace(/\\n/g, "\n").replace(/\\"/g, '"'))
943
+ .filter((decoded) => !decoded.trimStart().startsWith(nonce));
944
+ const hit = messages.find(
945
+ (decoded) =>
946
+ decoded.includes(token) &&
947
+ (/<sender_info>\{[^}]*"sessionId":"([^"]+)"/.exec(decoded)?.[1] ?? "") === childGid,
948
+ );
949
+ resultText = hit ?? "";
950
+ resultSender = hit === undefined ? "" : childGid;
951
+ } else {
952
+ const delivered = deliveredMessages(String(fenced.ENTWURF_META_MAILBOX_DIR), callerGid);
953
+ const hit = delivered
954
+ .slice(1)
955
+ .find(
956
+ (artifact) =>
957
+ (artifact.text.split(/─{5,}/)[1]?.trim() ?? "").includes(token) &&
958
+ artifact.text.includes(`session: ${childGid}`),
959
+ );
960
+ resultText = hit === undefined ? "" : (hit.text.split(/─{5,}/)[1]?.trim() ?? "");
961
+ resultSender = hit === undefined ? "" : childGid;
962
+ }
963
+ if (resultText.length > 0) break;
964
+ sleep(POLL_MS);
965
+ }
966
+ lines.push(
967
+ "",
968
+ `### ${cell.label} — final result message (sender ${resultSender || "none"})`,
969
+ "```",
970
+ resultText.slice(0, 500),
971
+ "```",
972
+ "",
973
+ );
974
+ ok(
975
+ `${cell.label}: the SIBLING reported its RESULT back to the caller after the callback — a later message on the same rail whose body carries this cell's task token and whose sender is the child itself, which is the first LIVE evidence that the framing's closing line is followed rather than merely written`,
976
+ resultText.includes(token) && resultSender === childGid && childGid.length > 0,
977
+ );
978
+ }
979
+ ok(
980
+ `${cell.label}: the fixture store holds every record this cell minted and the operator's store holds none of them`,
981
+ records.size > 0 && storeDir.startsWith(root),
982
+ );
983
+ }
984
+ } finally {
985
+ fs.writeFileSync(artifact, `${lines.join("\n")}\n`);
986
+ for (const { cell, env, socket } of servers) {
987
+ herdr(bin, env, ["server", "stop"]);
988
+ sleep(2000);
989
+ ok(`${cell.label}: the private server stopped and reclaimed its socket`, !fs.existsSync(socket));
990
+ }
991
+ const afterOperator = operatorPanes(bin);
992
+ ok(
993
+ "the operator's herdr panes are byte-identical — every pane this smoke opened lived on a private server",
994
+ afterOperator === beforeOperator,
995
+ );
996
+ // INTEGRITY ORACLE (#67). The launcher this smoke's children could rewrite is re-derived
997
+ // from the same facts the preflight pinned. A retarget is a FAILURE of this smoke, not a
998
+ // note: the operator's `claude` is how the next session starts.
999
+ const launcherProblems = verifyClaudeLauncher(launcher);
1000
+ lines.push("", `## operator claude launcher`, `- ${launcher.launcherPath} -> ${launcher.resolvedPath}`);
1001
+ for (const problem of launcherProblems) lines.push(`- PROBLEM: ${problem}`);
1002
+ ok(
1003
+ `the operator's claude launcher is untouched — same kind, same link, same resolved target, same content (${launcher.launcherPath})`,
1004
+ launcherProblems.length === 0,
1005
+ );
1006
+ // This fixture is deliberately preserved as evidence, so the cleanup guard is not gating a
1007
+ // removal here — it is stating, by name, whether a later `rm -rf` of this tree would sever
1008
+ // the operator's launcher.
1009
+ const cleanup = assessLauncherCleanup(launcher);
1010
+ if (!cleanup.safeToRemove) {
1011
+ for (const problem of cleanup.problems) {
1012
+ console.error(` WARN removing ${root} would damage the operator's launcher: ${problem}`);
1013
+ lines.push(`- DO NOT REMOVE ${root}: ${problem}`);
1014
+ }
1015
+ }
1016
+ fs.writeFileSync(artifact, `${lines.join("\n")}\n`);
1017
+ console.log(`\n[${LABEL}] receipts: ${artifact}`);
1018
+ }
1019
+
1020
+ if (failures.length > 0) {
1021
+ console.error(`\n[${LABEL}] ${failures.length} FAILED, ${passed} ok`);
1022
+ process.exit(1);
1023
+ }
1024
+ console.log(`\n[${LABEL}] ${passed} assertions ok`);
1025
+ }
1026
+
1027
+ await main();