@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
|
@@ -26,6 +26,20 @@
|
|
|
26
26
|
* LAUNCHFENCE-LIFECYCLE-CELL-BRANCH lifecycle keeps its two truthful cell branches: ACP pi
|
|
27
27
|
* retains the four MEASURED canonical REAL_XDG_* roots,
|
|
28
28
|
* the direct Claude cell gets exact-parity restore
|
|
29
|
+
* LAUNCHFENCE-EXPOSED-SMOKE-WIRED the population, not a list of names: EVERY LIVE smoke
|
|
30
|
+
* that hands a child a fixture XDG_DATA_HOME either
|
|
31
|
+
* consumes this fence or carries a stated exemption that
|
|
32
|
+
* is itself true of its source
|
|
33
|
+
*
|
|
34
|
+
* WHY THE LAST CLAIM EXISTS `[측정 oracle 2026-09-18]`. The two WIRED claims above name two files,
|
|
35
|
+
* and a rail born after them walked straight through the gap: `smoke-herdr-fresh-call-live` fenced
|
|
36
|
+
* XDG while keeping the operator's real HOME, and its Claude children reinstalled themselves into
|
|
37
|
+
* the fixture and repointed `~/.local/bin/claude` at `<fixture>/claude/versions/2.1.267`. Seven
|
|
38
|
+
* preserved fixture roots each held that install; the operator's launcher pointed into the newest
|
|
39
|
+
* of them until it was relinked by hand. The vendor's own two halves are why — the version store
|
|
40
|
+
* follows `XDG_DATA_HOME` and the launcher follows `HOME` (2.1.267: `EZe = join(Wge(), "claude",
|
|
41
|
+
* "versions")` against `TN = join(home, ".local", "bin")`) — so a fixture data root beside a real
|
|
42
|
+
* HOME is the incident's precondition, and THAT is what this claim enumerates.
|
|
29
43
|
*/
|
|
30
44
|
|
|
31
45
|
import assert from "node:assert/strict";
|
|
@@ -248,6 +262,130 @@ function main(): void {
|
|
|
248
262
|
"[QK:LAUNCHFENCE-WIRED-FRESH-CALL] wiring: smoke-mux-fresh-call-live consumes the SAME shared fence rather than a private copy — one protection, two smokes, per the issue's shared-repair requirement",
|
|
249
263
|
wired(read("scripts/smoke-mux-fresh-call-live.ts")),
|
|
250
264
|
);
|
|
265
|
+
// ── the population: who ELSE hands a child a fixture data root ───────────
|
|
266
|
+
{
|
|
267
|
+
// A smoke that assigns its own fixture XDG_DATA_HOME beside the operator's real HOME is
|
|
268
|
+
// standing in the incident's precondition. Three things are read STRUCTURALLY rather than
|
|
269
|
+
// by name `[sol 재검 2026-09-18]`, because the first version of this claim asked only
|
|
270
|
+
// whether four strings appeared anywhere in the file — which a comment, or dead code,
|
|
271
|
+
// satisfies as well as a wired smoke:
|
|
272
|
+
//
|
|
273
|
+
// 1. the preflight is BOUND (`const x = snapshotClaudeLauncher(`) and comes BEFORE the
|
|
274
|
+
// smoke's first child. That boundary is the whole point `[sol 재검 2026-09-18]`: an
|
|
275
|
+
// ordering of snapshot < verify < cleanup alone is satisfied by moving the entire
|
|
276
|
+
// block AFTER the children, which pins the damage as the baseline and makes the
|
|
277
|
+
// oracle green on a launcher that has already moved. Each smoke's first child is
|
|
278
|
+
// named here by its own marker, and a smoke in the population with no marker is
|
|
279
|
+
// RED rather than waved through — a new rail must say where its children begin.
|
|
280
|
+
// 2. the integrity oracle and the cleanup verdict both run on THAT binding, in that
|
|
281
|
+
// order — one snapshot, not three unrelated calls;
|
|
282
|
+
// 3. a smoke that REMOVES its fixture does both before the removal. The herdr smoke
|
|
283
|
+
// preserves its fixture as evidence and therefore has no removal to precede; that
|
|
284
|
+
// is a real difference between the two shapes, so the rule is written as a
|
|
285
|
+
// condition on removal rather than as a `finally` that only one of them has.
|
|
286
|
+
//
|
|
287
|
+
// An exemption is read the same way: not "the file contains HOME somewhere", but "the
|
|
288
|
+
// env object that assigns the fixture XDG_DATA_HOME also relocates HOME", which is the
|
|
289
|
+
// relation that actually keeps the vendor's store and its launcher in one tree.
|
|
290
|
+
const dataRootAssignment = (file: string): RegExp => (file.endsWith(".sh") ? /XDG_DATA_HOME=/ : /XDG_DATA_HOME:/);
|
|
291
|
+
/** The object literal / env block that carries the fixture data root, not the whole file. */
|
|
292
|
+
const envBlockAround = (src: string, file: string): string => {
|
|
293
|
+
const at = src.search(dataRootAssignment(file));
|
|
294
|
+
if (at < 0) return "";
|
|
295
|
+
const from = src.lastIndexOf("{", at);
|
|
296
|
+
if (from < 0) return src.slice(Math.max(0, at - 800), at + 800);
|
|
297
|
+
let depth = 0;
|
|
298
|
+
for (let i = from; i < src.length; i += 1) {
|
|
299
|
+
if (src[i] === "{") depth += 1;
|
|
300
|
+
else if (src[i] === "}") {
|
|
301
|
+
depth -= 1;
|
|
302
|
+
if (depth === 0) return src.slice(from, i + 1);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return src.slice(from);
|
|
306
|
+
};
|
|
307
|
+
const EXEMPT: Record<string, { reason: string; holds: (src: string, file: string) => boolean }> = {
|
|
308
|
+
"smoke-herdr-plugin-build-live.ts": {
|
|
309
|
+
reason: "it relocates HOME into the same sandbox, so store and launcher stay in one tree",
|
|
310
|
+
holds: (src, file) => /(^|\n)\s*HOME[,:]/.test(envBlockAround(src, file)),
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
/** Where each smoke's FIRST Claude-capable child begins. Named per smoke, because the
|
|
314
|
+
* three rails start children in three different ways and a generic "spawn" would match
|
|
315
|
+
* the setup probes (`tmux -V`, `command -v claude`) that run long before any child. */
|
|
316
|
+
const FIRST_CHILD: Record<string, RegExp> = {
|
|
317
|
+
"smoke-herdr-fresh-call-live.ts": /herdr\(bin, env, \[\s*"agent",\s*"start"/,
|
|
318
|
+
"smoke-mux-fresh-call-live.ts": /\n\t*const \w+ = freshCall\(/,
|
|
319
|
+
"smoke-mux-lifecycle-live.ts": /\bbridge\.call\("entwurf_fresh_call"/,
|
|
320
|
+
};
|
|
321
|
+
const consumesFence = (src: string, file: string): boolean => {
|
|
322
|
+
if (!src.includes('from "./lib/claude-launcher-fence.ts"')) return false;
|
|
323
|
+
const bound = /const (\w+) = snapshotClaudeLauncher\(/.exec(src);
|
|
324
|
+
if (bound === null) return false;
|
|
325
|
+
const marker = FIRST_CHILD[file];
|
|
326
|
+
if (marker === undefined) return false;
|
|
327
|
+
const firstChildAt = src.search(marker);
|
|
328
|
+
if (firstChildAt < 0) return false;
|
|
329
|
+
const snapshot = bound[1];
|
|
330
|
+
const preflightAt = src.indexOf(bound[0]);
|
|
331
|
+
const verifyAt = src.indexOf(`verifyClaudeLauncher(${snapshot})`);
|
|
332
|
+
const cleanupAt = src.indexOf(`assessLauncherCleanup(${snapshot})`);
|
|
333
|
+
const removalAt = src.indexOf("rmSync(root");
|
|
334
|
+
if (preflightAt < 0 || preflightAt > firstChildAt) return false;
|
|
335
|
+
if (verifyAt < firstChildAt || cleanupAt < verifyAt) return false;
|
|
336
|
+
return removalAt < 0 || (verifyAt < removalAt && cleanupAt < removalAt);
|
|
337
|
+
};
|
|
338
|
+
const exposed = fs
|
|
339
|
+
.readdirSync(path.join(ROOT, "scripts"))
|
|
340
|
+
.filter((f) => f.startsWith("smoke-") && (f.endsWith("-live.ts") || f.endsWith("-live.sh")))
|
|
341
|
+
.filter((f) => dataRootAssignment(f).test(read(path.join("scripts", f))));
|
|
342
|
+
const unguarded = exposed.filter((f) => {
|
|
343
|
+
const src = read(path.join("scripts", f));
|
|
344
|
+
if (consumesFence(src, f)) return false;
|
|
345
|
+
const exemption = EXEMPT[f];
|
|
346
|
+
return !(exemption && exemption.holds(src, f));
|
|
347
|
+
});
|
|
348
|
+
ok(
|
|
349
|
+
`population: ${exposed.length} LIVE smokes assign a fixture data root (measured across .ts AND .sh, not listed), and the three real-HOME ones are the fence's constituency`,
|
|
350
|
+
exposed.length >= 4 &&
|
|
351
|
+
["smoke-herdr-fresh-call-live.ts", "smoke-mux-fresh-call-live.ts", "smoke-mux-lifecycle-live.ts"].every((f) =>
|
|
352
|
+
exposed.includes(f),
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
ok(
|
|
356
|
+
`[QK:LAUNCHFENCE-EXPOSED-SMOKE-WIRED] every LIVE smoke that hands a child a fixture XDG_DATA_HOME beside the operator's real HOME consumes this fence AS A LIFECYCLE — one bound preflight BEFORE its first child, then its integrity oracle and cleanup verdict on that same snapshot after the children, and all of it before any fixture removal — or carries an exemption proved against the very env block that assigns the data root; unguarded: ${unguarded.join(", ") || "none"}`,
|
|
357
|
+
unguarded.length === 0,
|
|
358
|
+
);
|
|
359
|
+
ok(
|
|
360
|
+
"the exemption discriminates rather than excuses: the exempt smoke relocates HOME in the SAME env block that fences its data root, and moving that line out of the block would put it back in the constituency",
|
|
361
|
+
Object.entries(EXEMPT).every(([f, e]) => e.holds(read(path.join("scripts", f)), f)),
|
|
362
|
+
);
|
|
363
|
+
{
|
|
364
|
+
const IMPORT =
|
|
365
|
+
'import { assessLauncherCleanup, snapshotClaudeLauncher, verifyClaudeLauncher } from "./lib/claude-launcher-fence.ts";';
|
|
366
|
+
const CHILD = '\tconst launch = await bridge.call("entwurf_fresh_call", {});';
|
|
367
|
+
const PIN = "\tconst snap = snapshotClaudeLauncher({ env: process.env, fixtureRoot: root });";
|
|
368
|
+
const ORACLE = "\tverifyClaudeLauncher(snap);\n\tassessLauncherCleanup(snap);";
|
|
369
|
+
const file = "smoke-mux-lifecycle-live.ts";
|
|
370
|
+
ok(
|
|
371
|
+
"the lifecycle read is not satisfied by the STRINGS alone — a source carrying all four names with no ordering is refused",
|
|
372
|
+
!consumesFence(
|
|
373
|
+
[IMPORT, "// snapshotClaudeLauncher( verifyClaudeLauncher( assessLauncherCleanup("].join("\n"),
|
|
374
|
+
file,
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
ok(
|
|
378
|
+
"snapshot < verify < cleanup in the RIGHT order is still refused when the whole block sits after the first child — that shape pins the damage as the baseline, which is the exact way an intact-looking oracle would report a launcher that had already moved",
|
|
379
|
+
!consumesFence([IMPORT, CHILD, PIN, ORACLE].join("\n"), file) &&
|
|
380
|
+
consumesFence([IMPORT, PIN, CHILD, ORACLE].join("\n"), file),
|
|
381
|
+
);
|
|
382
|
+
ok(
|
|
383
|
+
"a smoke in the population whose first child this gate cannot locate is refused rather than waved through — a new rail must say where its children begin",
|
|
384
|
+
!consumesFence([IMPORT, PIN, CHILD, ORACLE].join("\n"), "smoke-brand-new-live.ts"),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
251
389
|
ok(
|
|
252
390
|
"[QK:LAUNCHFENCE-LIFECYCLE-CELL-BRANCH] wiring: lifecycle's real-HOME cell env keeps its two truthful branches — the ACP-backed pi cell retains all four MEASURED canonical REAL_XDG_* assignments, and the direct Claude Code cell (the else branch) gets exact-parity restoreOriginalXdg instead",
|
|
253
391
|
/if \(backend === "pi"\) \{\s*serverEnv\.XDG_CONFIG_HOME = REAL_XDG_CONFIG_HOME;\s*serverEnv\.XDG_DATA_HOME = REAL_XDG_DATA_HOME;\s*serverEnv\.XDG_STATE_HOME = REAL_XDG_STATE_HOME;\s*serverEnv\.XDG_CACHE_HOME = REAL_XDG_CACHE_HOME;\s*\} else \{\s*restoreOriginalXdg\(serverEnv, ORIGINAL_XDG\);\s*\}/.test(
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-peer-facts — deterministic gate for the #116 M2-a observed-peer projection
|
|
3
|
+
* (`entwurf peer-facts` / scripts/peer-facts.ts).
|
|
4
|
+
*
|
|
5
|
+
* Drives the REAL CLI as a subprocess against sandboxed fixtures — every root it can
|
|
6
|
+
* reach is an explicit mkdtemp dir (store, control-socket dir, HOME, XDG_DATA_HOME, pi
|
|
7
|
+
* agent dir), so the operator's live store, sockets and markers are never read. What it
|
|
8
|
+
* proves, and why each claim is its own contract rather than a restatement:
|
|
9
|
+
*
|
|
10
|
+
* - `placement` crosses as the STRUCTURED tagged union, never the human `herdr <pane>`
|
|
11
|
+
* string. A machine consumer joins on `paneId`; a string forces it to re-parse a
|
|
12
|
+
* presentation decision;
|
|
13
|
+
* - the peer keyset is EXACTLY the provider's own facts — no herdr `agent_status` or any
|
|
14
|
+
* other display verdict may enter an entwurf payload, because that is the one door
|
|
15
|
+
* through which "observed activity" could drift into delivery liveness;
|
|
16
|
+
* - no socket coordinate is published: `ENTWURF_DIR` is an INPUT to the probe, never an
|
|
17
|
+
* output — #50 C4 retired the legacy listing together with the `controlDir` it exposed;
|
|
18
|
+
* - `diagnostics` survive the projection in-band (a readable store with hazards is a fact
|
|
19
|
+
* to report, not a reason to emit a clean-looking listing);
|
|
20
|
+
* - the control-socket world actually probed is the one `ENTWURF_DIR` names — proved
|
|
21
|
+
* BEHAVIOURALLY (a record-less socket in the fixture dir must surface as a diagnostic),
|
|
22
|
+
* not by echoing the field back;
|
|
23
|
+
* - no observation budget: a rationed row says `unobserved`, which in a machine payload
|
|
24
|
+
* is indistinguishable from "no herdr on this host";
|
|
25
|
+
* - an unreadable store is exit 3 with NO JSON, a missing store is a readable empty one,
|
|
26
|
+
* bad argv is exit 2;
|
|
27
|
+
* - the surface is reachable: run.sh dispatches it and the bridge build emits its
|
|
28
|
+
* compiled twin (the installed-host fence).
|
|
29
|
+
*
|
|
30
|
+
* The placement join itself, the one-read-per-listing rule and the peers payload keyset are
|
|
31
|
+
* NOT re-proved here — they belong to check-herdr-placement, check-entwurf-fact-provider and
|
|
32
|
+
* check-entwurf-peers-surface. This gate owns only what this verb adds.
|
|
33
|
+
*
|
|
34
|
+
* The gate spawns the CLI rather than importing its pieces because the exit code and stdout
|
|
35
|
+
* bytes ARE the contract a consumer holds. Every [QK:PF-*] token appears exactly once, on
|
|
36
|
+
* the assertion that fails for that claim. No herdr binary, no network, no model turn.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import { spawnSync } from "node:child_process";
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { type MetaIdentity, serializeMetaIdentity } from "../pi-extensions/lib/meta-session.ts";
|
|
45
|
+
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
46
|
+
|
|
47
|
+
let passed = 0;
|
|
48
|
+
function ok(label: string, cond: boolean): void {
|
|
49
|
+
assert.ok(cond, label);
|
|
50
|
+
console.log(` ok ${label}`);
|
|
51
|
+
passed++;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const REPO = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
55
|
+
|
|
56
|
+
const GID_PI = "20260611T111111-aaaaaa";
|
|
57
|
+
const GID_CLAUDE = "20260611T222222-bbbbbb";
|
|
58
|
+
/** A garden id no record in any fixture claims — its socket must be DEMOTED to a diagnostic. */
|
|
59
|
+
const GID_ORPHAN_SOCKET = "20260611T333333-cccccc";
|
|
60
|
+
|
|
61
|
+
function tmp(prefix: string): string {
|
|
62
|
+
return reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), `entwurf-peer-facts-${prefix}-`)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function record(gardenId: string, over: Partial<MetaIdentity> = {}): string {
|
|
66
|
+
return serializeMetaIdentity({
|
|
67
|
+
schemaVersion: 3,
|
|
68
|
+
gardenId,
|
|
69
|
+
backend: "pi",
|
|
70
|
+
nativeSessionId: `n-${gardenId}`,
|
|
71
|
+
cwd: "/x",
|
|
72
|
+
model: "provider/model",
|
|
73
|
+
transcriptPath: null,
|
|
74
|
+
createdAt: "2026-06-11T00:00:00.000Z",
|
|
75
|
+
recordUpdatedAt: "2026-06-11T00:00:00.000Z",
|
|
76
|
+
...over,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function store(files: Record<string, string>): string {
|
|
81
|
+
const dir = tmp("store");
|
|
82
|
+
for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body);
|
|
83
|
+
return dir;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CliRun {
|
|
87
|
+
status: number | null;
|
|
88
|
+
stdout: string;
|
|
89
|
+
stderr: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every ambient root this verb or the provider beneath it could reach is replaced. HOME and
|
|
94
|
+
* XDG_DATA_HOME move together: moving HOME alone still writes/reads real install-state below
|
|
95
|
+
* the inherited XDG root, which is exactly how a verification sweep once polluted a live host.
|
|
96
|
+
*/
|
|
97
|
+
function cli(args: string[], env: Record<string, string> = {}): CliRun {
|
|
98
|
+
const home = tmp("home");
|
|
99
|
+
const res = spawnSync(process.execPath, ["--experimental-strip-types", "scripts/peer-facts.ts", ...args], {
|
|
100
|
+
cwd: REPO,
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
env: {
|
|
103
|
+
...process.env,
|
|
104
|
+
HOME: home,
|
|
105
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
106
|
+
PI_CODING_AGENT_DIR: path.join(home, ".pi", "agent"),
|
|
107
|
+
ENTWURF_DIR: tmp("control"),
|
|
108
|
+
// A herdr read here would be a child process and a live-host dependency. The
|
|
109
|
+
// provider's own gate owns that axis; this one must stay hermetic.
|
|
110
|
+
HERDR_ENV: "0",
|
|
111
|
+
...env,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Placement {
|
|
118
|
+
kind: string;
|
|
119
|
+
paneId?: string;
|
|
120
|
+
}
|
|
121
|
+
interface Peer {
|
|
122
|
+
gardenId: string;
|
|
123
|
+
backend: string;
|
|
124
|
+
cwd: string;
|
|
125
|
+
placement: Placement;
|
|
126
|
+
transcript: string;
|
|
127
|
+
[k: string]: unknown;
|
|
128
|
+
}
|
|
129
|
+
interface Projection {
|
|
130
|
+
schemaVersion: number;
|
|
131
|
+
storeDir: string;
|
|
132
|
+
peers: Peer[];
|
|
133
|
+
diagnostics: { kind: string; message: string }[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseProjection(run: CliRun, label: string): Projection {
|
|
137
|
+
assert.equal(run.status, 0, `${label}: exit 0 (stderr: ${run.stderr})`);
|
|
138
|
+
return JSON.parse(run.stdout) as Projection;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── the projection carries the provider's facts, addressably ────────────────
|
|
142
|
+
{
|
|
143
|
+
const transcriptDir = tmp("transcripts");
|
|
144
|
+
const transcript = path.join(transcriptDir, "live.jsonl");
|
|
145
|
+
fs.writeFileSync(transcript, "{}\n");
|
|
146
|
+
|
|
147
|
+
const dir = store({
|
|
148
|
+
[`${GID_PI}.meta.json`]: record(GID_PI),
|
|
149
|
+
[`${GID_CLAUDE}.meta.json`]: record(GID_CLAUDE, {
|
|
150
|
+
backend: "claude-code",
|
|
151
|
+
nativeSessionId: "ce224ad7-9e29-41a5-81c3-08e09f017802",
|
|
152
|
+
transcriptPath: transcript,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const facts = parseProjection(cli([dir]), "valid store");
|
|
156
|
+
|
|
157
|
+
ok(
|
|
158
|
+
"projection declares schemaVersion 1 and echoes the store it was told to read",
|
|
159
|
+
facts.schemaVersion === 1 && facts.storeDir === dir,
|
|
160
|
+
);
|
|
161
|
+
// Deliberately does NOT inspect `placement`: the shape of that field is the next
|
|
162
|
+
// assertion's own claim, and a generic check here would steal its kill.
|
|
163
|
+
ok(
|
|
164
|
+
"both citizens cross with the identity fields a renderer needs",
|
|
165
|
+
facts.peers.length === 2 &&
|
|
166
|
+
facts.peers.every(
|
|
167
|
+
(p) => typeof p.gardenId === "string" && typeof p.backend === "string" && typeof p.cwd === "string",
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// PLACEMENT STAYS STRUCTURED. `unobserved` is the only value reachable without a herdr
|
|
172
|
+
// host, and that is enough: the human rendering of THAT value is the bare string
|
|
173
|
+
// "unobserved", so a projection that stringified it would be caught right here.
|
|
174
|
+
const placements = facts.peers.map((p) => p.placement);
|
|
175
|
+
assert.ok(
|
|
176
|
+
placements.every(
|
|
177
|
+
(pl) => typeof pl === "object" && pl !== null && !Array.isArray(pl) && typeof pl.kind === "string",
|
|
178
|
+
),
|
|
179
|
+
'[QK:PF-PLACEMENT-STRUCTURED] placement crosses as the tagged union object ({kind:…}), never the human "herdr <pane>" / bare-kind string a renderer would have to re-parse',
|
|
180
|
+
);
|
|
181
|
+
console.log(" ok placement crosses as the tagged union object, never the human string");
|
|
182
|
+
passed++;
|
|
183
|
+
ok(
|
|
184
|
+
"no herdr on this host reads as unobserved, not none",
|
|
185
|
+
placements.every((pl) => pl.kind === "unobserved"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// THE PROMOTION BARRIER. herdr owns idle/working/blocked/done and it is a screen verdict;
|
|
189
|
+
// the moment one of those words enters an entwurf payload it is one rename away from
|
|
190
|
+
// being read as delivery liveness. So the keyset is pinned EXACTLY, not by denylist.
|
|
191
|
+
const PEER_KEYS = [
|
|
192
|
+
"backend",
|
|
193
|
+
"createdAt",
|
|
194
|
+
"cwd",
|
|
195
|
+
"gardenId",
|
|
196
|
+
"liveness",
|
|
197
|
+
"model",
|
|
198
|
+
"nativeSessionId",
|
|
199
|
+
"placement",
|
|
200
|
+
"receiver",
|
|
201
|
+
"recordUpdatedAt",
|
|
202
|
+
"transcript",
|
|
203
|
+
];
|
|
204
|
+
for (const peer of facts.peers) {
|
|
205
|
+
assert.deepEqual(
|
|
206
|
+
Object.keys(peer).sort(),
|
|
207
|
+
PEER_KEYS,
|
|
208
|
+
"[QK:PF-PEER-KEYSET-EXACT] a peer row carries exactly the provider's own facts — no herdr agent_status / interactive_ready / screen verdict may enter an entwurf payload, because that is the door observed activity would use to become delivery liveness",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
console.log(" ok a peer row carries exactly the provider's own facts, with no herdr display verdict");
|
|
212
|
+
passed++;
|
|
213
|
+
// FOUR, not five: the control dir is an INPUT to the probe and never an output. #50 C4
|
|
214
|
+
// retired the legacy projection "with the `controlDir` it exposed" — the record is the
|
|
215
|
+
// sole address axis and a socket path is dispatch-internal transport. A new verb may not
|
|
216
|
+
// quietly republish it.
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
Object.keys(facts as unknown as Record<string, unknown>).sort(),
|
|
219
|
+
["diagnostics", "peers", "schemaVersion", "storeDir"],
|
|
220
|
+
"[QK:PF-NO-SOCKET-COORDINATE] the projection publishes no socket coordinate — #50 C4 retired the legacy listing WITH the controlDir it exposed, because the record is the sole address axis and a socket path is dispatch-internal transport; a new verb that republishes it undoes that retirement while looking like a convenience",
|
|
221
|
+
);
|
|
222
|
+
console.log(" ok the projection publishes no socket coordinate (four documented fields)");
|
|
223
|
+
passed++;
|
|
224
|
+
|
|
225
|
+
// NO OBSERVATION BUDGET. A rationed row says `unobserved`; in a machine payload that is
|
|
226
|
+
// indistinguishable from "nobody could look". The live transcript below is the witness:
|
|
227
|
+
// under a budget it would read `unobserved` instead of `exists`.
|
|
228
|
+
const claude = facts.peers.find((p) => p.gardenId === GID_CLAUDE);
|
|
229
|
+
assert.ok(
|
|
230
|
+
claude !== undefined && claude.transcript === "exists",
|
|
231
|
+
"[QK:PF-NO-OBSERVATION-BUDGET] every row is observed — a presentation budget would emit `unobserved` for rows nobody chose to skip, which a machine consumer cannot tell apart from `nobody could look`",
|
|
232
|
+
);
|
|
233
|
+
console.log(" ok every row is observed — no presentation budget rations a machine projection");
|
|
234
|
+
passed++;
|
|
235
|
+
|
|
236
|
+
const again = parseProjection(cli([dir]), "re-run");
|
|
237
|
+
ok("output is byte-deterministic across runs", JSON.stringify(again.peers) === JSON.stringify(facts.peers));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── diagnostics ride in-band, they are not a reason to look clean ───────────
|
|
241
|
+
{
|
|
242
|
+
const dir = store({
|
|
243
|
+
[`${GID_PI}.meta.json`]: record(GID_PI),
|
|
244
|
+
[`${GID_CLAUDE}.meta.json`]: "{ not json",
|
|
245
|
+
});
|
|
246
|
+
const facts = parseProjection(cli([dir]), "store with a defect");
|
|
247
|
+
ok("a readable store with an uncertifiable entry still reports its healthy citizen", facts.peers.length === 1);
|
|
248
|
+
assert.ok(
|
|
249
|
+
facts.diagnostics.some((d) => d.kind === "meta-record-read-error"),
|
|
250
|
+
"[QK:PF-DIAGNOSTICS-PRESERVED] the provider's diagnostics cross the projection in-band — dropping them turns a store with hazards into a clean-looking listing at exit 0",
|
|
251
|
+
);
|
|
252
|
+
console.log(" ok the provider's diagnostics cross the projection in-band");
|
|
253
|
+
passed++;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── the socket world probed is the one ENTWURF_DIR names, proved behaviourally ──
|
|
257
|
+
{
|
|
258
|
+
const dir = store({ [`${GID_PI}.meta.json`]: record(GID_PI) });
|
|
259
|
+
const control = tmp("control-seeded");
|
|
260
|
+
// Not a listening socket — a plain file with the socket grammar. The probe fails, the
|
|
261
|
+
// liveness is `dead`, and the point stands: this path was READ. A verb that ignored
|
|
262
|
+
// ENTWURF_DIR would probe the sandbox HOME's default dir and report nothing here.
|
|
263
|
+
fs.writeFileSync(path.join(control, `${GID_ORPHAN_SOCKET}.sock`), "");
|
|
264
|
+
|
|
265
|
+
const facts = parseProjection(cli([dir], { ENTWURF_DIR: control }), "seeded control dir");
|
|
266
|
+
// The proof has to be behavioural precisely BECAUSE the dir is not emitted: there is no
|
|
267
|
+
// field to read back, so the witness is what the probe found in it.
|
|
268
|
+
ok(
|
|
269
|
+
"the probed dir is not echoed into the payload",
|
|
270
|
+
!Object.hasOwn(facts as unknown as Record<string, unknown>, "controlDir"),
|
|
271
|
+
);
|
|
272
|
+
assert.ok(
|
|
273
|
+
facts.diagnostics.some((d) => d.kind === "record-less-socket" && d.message.length > 0),
|
|
274
|
+
"[QK:PF-CONTROL-DIR-PROBED] the socket world actually probed is the one ENTWURF_DIR names — the same override the bridge honours, so this verb and entwurf_peers can never answer from two different socket worlds",
|
|
275
|
+
);
|
|
276
|
+
console.log(" ok the socket world probed is the one ENTWURF_DIR names");
|
|
277
|
+
passed++;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── the exit contract ───────────────────────────────────────────────────────
|
|
281
|
+
{
|
|
282
|
+
const missing = path.join(tmp("absent"), "no-such-store");
|
|
283
|
+
const run = cli([missing]);
|
|
284
|
+
const facts = parseProjection(run, "missing store");
|
|
285
|
+
ok("a store that does not exist is a readable EMPTY store, exit 0", facts.peers.length === 0);
|
|
286
|
+
|
|
287
|
+
// An unreadable store must never be mistakable for an empty one, so the refusal is both
|
|
288
|
+
// a distinct exit code AND an empty stdout — a consumer that only reads stdout still
|
|
289
|
+
// cannot parse a false success out of it.
|
|
290
|
+
const unreadable = tmp("unreadable");
|
|
291
|
+
fs.writeFileSync(path.join(unreadable, "blocker"), "");
|
|
292
|
+
fs.chmodSync(unreadable, 0o000);
|
|
293
|
+
const denied = cli([unreadable]);
|
|
294
|
+
fs.chmodSync(unreadable, 0o700);
|
|
295
|
+
assert.ok(
|
|
296
|
+
denied.status === 3 && denied.stdout === "",
|
|
297
|
+
`[QK:PF-EXIT-UNREADABLE] an unreadable store is exit 3 with no JSON — answering it as exit 0 would make an unreadable host look like an empty one (got ${denied.status}, stdout ${JSON.stringify(denied.stdout.slice(0, 80))})`,
|
|
298
|
+
);
|
|
299
|
+
console.log(" ok an unreadable store is exit 3 with no JSON");
|
|
300
|
+
passed++;
|
|
301
|
+
|
|
302
|
+
const flag = cli(["--help"]);
|
|
303
|
+
assert.ok(
|
|
304
|
+
flag.status === 2 && flag.stdout === "",
|
|
305
|
+
"[QK:PF-USAGE] a dash argv is a flag this command does not have — reading it as a store directory would answer `--help` with `empty store, exit 0`, a silent wrong fact",
|
|
306
|
+
);
|
|
307
|
+
console.log(" ok a dash argv is a usage error, not a store directory");
|
|
308
|
+
passed++;
|
|
309
|
+
ok("a second positional is a usage error", cli(["/a", "/b"]).status === 2);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── the surface is reachable from an installed host ─────────────────────────
|
|
313
|
+
{
|
|
314
|
+
const runSh = fs.readFileSync(path.join(REPO, "run.sh"), "utf8");
|
|
315
|
+
ok(
|
|
316
|
+
"run.sh dispatches peer-facts through the single run_ts fence",
|
|
317
|
+
/\n {2}peer-facts\)\n(?:.*\n)*?\s*run_ts scripts\/peer-facts\.ts "\$@"\n/.test(runSh),
|
|
318
|
+
);
|
|
319
|
+
ok("run.sh documents peer-facts in its usage block", runSh.includes("./run.sh peer-facts "));
|
|
320
|
+
const build = fs.readFileSync(path.join(REPO, "mcp/entwurf-bridge/tsconfig.build.json"), "utf8");
|
|
321
|
+
ok(
|
|
322
|
+
"the bridge build emits the compiled twin (installed hosts cannot strip-types)",
|
|
323
|
+
build.includes('"../../scripts/peer-facts.ts"'),
|
|
324
|
+
);
|
|
325
|
+
ok(
|
|
326
|
+
"both pack artifact lists carry the compiled twin",
|
|
327
|
+
(runSh.match(/"mcp\/entwurf-bridge\/dist\/scripts\/peer-facts\.js"/g) ?? []).length === 2,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(`\ncheck-peer-facts: ${passed} assertions passed`);
|