@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.
- package/AGENTS.md +4 -1
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +514 -0
- package/DELIVERY.md +10 -2
- package/README.md +43 -4
- package/VERIFY.md +47 -0
- package/docs/acp-backend-rail.md +96 -17
- package/docs/setup-clean-host.md +11 -1
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +39 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +95 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +46 -273
- package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -41
- package/mcp/entwurf-bridge/tsconfig.build.json +1 -0
- package/package.json +9 -8
- package/pi-extensions/entwurf-control.ts +91 -52
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +4 -4
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/codex-declaration.js +612 -0
- package/pi-extensions/lib/codex-fresh-preflight.ts +107 -51
- package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
- package/pi-extensions/lib/entwurf-facts.ts +14 -1
- package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
- package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
- package/pi-extensions/lib/entwurf-self-address.ts +33 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +493 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
- package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
- package/pi-extensions/lib/herdr-placement.ts +276 -0
- package/pi-extensions/lib/mux-fresh-call.ts +95 -301
- package/run.sh +588 -204
- package/scripts/check-acp-usage-accounting.ts +9 -9
- package/scripts/check-codex-birth-hook.ts +187 -0
- package/scripts/check-entwurf-control-rpc.ts +138 -0
- package/scripts/check-entwurf-fact-provider.ts +4 -2
- package/scripts/check-entwurf-peers-surface.ts +3 -1
- package/scripts/check-entwurf-self-address.ts +68 -0
- package/scripts/check-fresh-call-dispatch.ts +301 -0
- package/scripts/check-gate-qualification.ts +17 -4
- package/scripts/check-herdr-activation.ts +1293 -0
- package/scripts/check-herdr-fresh-call.ts +1250 -0
- package/scripts/check-herdr-placement.ts +383 -0
- package/scripts/check-herdr-plugin-build.ts +842 -0
- package/scripts/check-herdr-plugin-profile.ts +371 -0
- package/scripts/check-herdr-plugin.ts +523 -0
- package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
- package/scripts/check-herdr-sandbox.ts +456 -0
- package/scripts/check-herdr-supply.ts +184 -0
- package/scripts/check-mux-launcher-fence.ts +138 -0
- package/scripts/check-peer-facts.ts +331 -0
- package/scripts/check-pi-launch.ts +358 -0
- package/scripts/check-release-gate-outcomes.ts +19 -0
- package/scripts/check-typing-call-fence.ts +296 -0
- package/scripts/codex-birth-doctor.sh +159 -16
- package/scripts/codex-birth-install.sh +178 -72
- package/scripts/codex-birth-uninstall.sh +162 -7
- package/scripts/fixtures/herdr-supply.json +29 -0
- package/scripts/herdr-activation.mjs +536 -0
- package/scripts/herdr-plugin-activate.mjs +270 -0
- package/scripts/herdr-plugin-deactivate.mjs +193 -0
- package/scripts/herdr-runtime.mjs +1203 -0
- package/scripts/install-herdr-ci.sh +96 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +2 -2
- package/scripts/lib/tree-digest.ts +97 -0
- package/scripts/meta-bridge-install.sh +19 -2
- package/scripts/meta-bridge-state.py +167 -11
- package/scripts/mutants/codex-caller-seat.json +6 -8
- package/scripts/mutants/codex-native.json +195 -29
- package/scripts/mutants/control-socket-disconnect.json +57 -0
- package/scripts/mutants/fresh-call-dispatch.json +154 -0
- package/scripts/mutants/herdr-activation.json +305 -0
- package/scripts/mutants/herdr-fresh-call.json +448 -0
- package/scripts/mutants/herdr-placement.json +161 -0
- package/scripts/mutants/herdr-plugin-build.json +160 -0
- package/scripts/mutants/herdr-plugin-profile.json +202 -0
- package/scripts/mutants/herdr-plugin.json +133 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
- package/scripts/mutants/herdr-supply.json +133 -0
- package/scripts/mutants/meta-hook-session-switch.json +2 -2
- package/scripts/mutants/mux-fresh-call.json +104 -17
- package/scripts/mutants/mux-launcher-fence.json +13 -0
- package/scripts/mutants/omp-fresh.json +4 -4
- package/scripts/mutants/peer-facts.json +98 -0
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/self-address.json +33 -0
- package/scripts/mutants/typing-call-fence.json +17 -0
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/peer-facts.ts +120 -0
- package/scripts/register-pi-package.py +8 -0
- package/scripts/register-pi-provider.py +88 -12
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-codex-birth.sh +281 -12
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
- package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
- package/scripts/smoke-herdr-raw-install-live.sh +849 -0
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -385
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- 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();
|