@junghanacs/entwurf 0.41.1 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +7 -5
  3. package/CHANGELOG.md +185 -0
  4. package/DELIVERY.md +37 -20
  5. package/README.md +70 -65
  6. package/VERIFY.md +9 -9
  7. package/docs/external-mcp-host.md +1 -1
  8. package/docs/setup-clean-host.md +59 -33
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -11
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +3 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +6 -4
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +94 -28
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
  24. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +2 -2
  25. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
  26. package/mcp/entwurf-bridge/src/index.ts +14 -11
  27. package/native/win32-x64/entwurf_win.node +0 -0
  28. package/native/win32-x64/manifest.json +2 -2
  29. package/package.json +4 -4
  30. package/pi-extensions/lib/entwurf-self-address.ts +13 -0
  31. package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
  32. package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
  33. package/pi-extensions/lib/entwurf-v2-surface.ts +3 -0
  34. package/pi-extensions/lib/fresh-call-composition.ts +21 -10
  35. package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
  36. package/pi-extensions/lib/meta-sender-identity.ts +4 -0
  37. package/pi-extensions/lib/mux-fresh-call.ts +6 -4
  38. package/pi-extensions/lib/native-push/register.ts +17 -2
  39. package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
  40. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
  41. package/pi-extensions/lib/remote-pair-receive.ts +27 -19
  42. package/pi-extensions/lib/remote-pair-send.ts +117 -30
  43. package/pi-extensions/meta-bridge-omp.ts +69 -7
  44. package/pi-extensions/meta-bridge-pi-durable.ts +2 -2
  45. package/run.sh +173 -22
  46. package/scripts/check-agy-sender-identity.ts +6 -4
  47. package/scripts/check-entwurf-self-address.ts +34 -3
  48. package/scripts/check-entwurf-v2-contract.ts +24 -2
  49. package/scripts/check-entwurf-v2-production.ts +45 -31
  50. package/scripts/check-gate-qualification.ts +10 -8
  51. package/scripts/check-native-push-adapter.ts +4 -0
  52. package/scripts/check-native-push-register.ts +32 -82
  53. package/scripts/check-remote-pair-installed.ts +2 -2
  54. package/scripts/dev-bin.sh +112 -1
  55. package/scripts/lib/chain-terminus-publication.ts +58 -0
  56. package/scripts/lib/codex-fresh-live-protocol.ts +30 -0
  57. package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
  58. package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
  59. package/scripts/mutants/chain-terminus-publication.json +17 -0
  60. package/scripts/mutants/codex-native.json +98 -0
  61. package/scripts/mutants/control-send-receipt.json +2 -2
  62. package/scripts/mutants/mux-fresh-call.json +16 -1
  63. package/scripts/mutants/native-push-paused.json +17 -0
  64. package/scripts/mutants/omp-fresh.json +13 -2
  65. package/scripts/mutants/pack-install.json +46 -0
  66. package/scripts/mutants/remote-pair-display.json +3 -3
  67. package/scripts/mutants/remote-pair-receive.json +41 -11
  68. package/scripts/mutants/remote-pair-roundtrip.json +67 -7
  69. package/scripts/mutants/remote-pair-send.json +156 -17
  70. package/scripts/mutants/remote-pair-transport.json +4 -4
  71. package/scripts/mutants/source-install.json +66 -0
  72. package/scripts/remote-pair-receive.ts +12 -12
  73. package/scripts/smoke-agy-install-state.sh +121 -0
  74. package/scripts/smoke-agy-native-push-live.ts +13 -0
  75. package/scripts/smoke-codex-fresh-live.ts +50 -27
  76. package/scripts/smoke-entwurf-chain-live.ts +7 -9
  77. package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
  78. package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
  79. package/scripts/smoke-mux-lifecycle-live.ts +140 -8
  80. package/scripts/smoke-omp-fresh-live.ts +5 -4
  81. package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
  82. package/scripts/smoke-setup-verdict.sh +27 -2
@@ -5,6 +5,8 @@
5
5
  * OUT of `pnpm check`. Needs `LIVE=1`, a `herdr` binary, `git`, `npm` — and the NETWORK, because a
6
6
  * git-spec pack builds the bridge through `prepare`, which installs devDependencies from the
7
7
  * registry. That is the axis `check-herdr-plugin-build` cannot have and why it does not claim it.
8
+ * The two sandbox commits below also need the host's `pnpm` and its already-populated store: that
9
+ * pnpm serves only those test clones' ordinary commit hooks, never the product's git-spec `prepare`.
8
10
  *
9
11
  * WHAT MAKES IT HERMETIC ANYWAY. Two substitutions, and only two. HOME/XDG are a mkdtemp sandbox,
10
12
  * so Herdr's registry, its managed checkout, our runtime, our ledger and both harnesses' wiring all
@@ -29,7 +31,7 @@
29
31
  * | 1. remote-commit AVAILABLE | real `herdr plugin install` | the Herdr journey: one install, runtime at the stable root keyed by the commit Herdr resolved, Pi wired at the absolute bridge, ledger `active` |
30
32
  * | 2. REINSTALL widening | real `herdr plugin install` | the Herdr journey: Pi reconciled JSON-identical, Claude added as exactly ONE counted owner entry (real vendor CLI), OpenCode given zero Entwurf state |
31
33
  * | 3. remote-commit UNAVAILABLE | the shipped runtime leaf, called directly with the FIXED product argv | acquisition-leaf evidence only — a remote that cannot serve a commit. Routing it through Herdr would add a second explanation for one red, so it does not, and it does not call itself a journey |
32
- * | 4. POST-BUILD GAP | real `herdr plugin install` | the Herdr journey: a build that mutates the manifest makes HERDR's own commit fail after our runner already succeeded, so Herdr keeps the previous registration while the runtime and the rebound activation REMAIN, and the next normal install re-binds to HEAD. Nothing here calls that atomic |
34
+ * | 4. POST-BUILD GAP | real `herdr plugin install` | the Herdr journey: after our runner finished with 0, a test-owned probe leaves one semantic manifest change on disk (its receipt says so); the install then exits non-zero and Herdr's registration does not move, while the runtime and the rebound activation REMAIN, and the next normal install re-binds to HEAD. Nothing here calls that atomic, and the receipt does not name which Herdr check refused |
33
35
  */
34
36
 
35
37
  import assert from "node:assert/strict";
@@ -111,6 +113,38 @@ const configuredHooksPath = (
111
113
  ""
112
114
  ).trim();
113
115
 
116
+ // ── EACH TEST CLONE GETS ITS OWN FROZEN DEPENDENCIES BEFORE ITS ORDINARY COMMIT ─────────────
117
+ //
118
+ // The operator's hook chain reaches the repo's `.husky/pre-commit`, which runs `pnpm lint` and
119
+ // `pnpm typecheck`, and COMMIT_ENV carries the operator's strict `verify-deps-before-run`. A fresh
120
+ // clone has no `node_modules`, so that strict pnpm refuses (measured: ERR_PNPM_VERIFY_DEPS_BEFORE_RUN
121
+ // at the snapshot commit). The clone installs its OWN dependencies from its own lockfile: frozen,
122
+ // offline (a package missing from the store is a named red, never a network fallback) and
123
+ // ignore-scripts (no `prepare` build here). Never the parent's `node_modules` by symlink or copy,
124
+ // which would inherit another workspace's state, and never a relaxed hook environment.
125
+ const storeQuery = spawnSync("pnpm", ["store", "path"], { encoding: "utf8", env: COMMIT_ENV, cwd: REPO });
126
+ assert.ok(
127
+ !storeQuery.error && storeQuery.status === 0,
128
+ `pnpm store path failed (test-clone hooks need pnpm): status=${storeQuery.status} error=${storeQuery.error?.message ?? "none"} stderr=${storeQuery.stderr ?? ""}`,
129
+ );
130
+ const STORE_DIR = (storeQuery.stdout ?? "").trim();
131
+ assert.ok(
132
+ path.isAbsolute(STORE_DIR),
133
+ `pnpm store path did not answer an absolute directory: ${JSON.stringify(STORE_DIR)}`,
134
+ );
135
+
136
+ function prepareCloneDeps(dir: string, tag: string): void {
137
+ const installed = spawnSync(
138
+ "pnpm",
139
+ ["install", "--frozen-lockfile", "--offline", "--ignore-scripts", "--store-dir", STORE_DIR],
140
+ { encoding: "utf8", env: COMMIT_ENV, cwd: dir, timeout: 900_000 },
141
+ );
142
+ assert.ok(
143
+ !installed.error && installed.status === 0,
144
+ `${tag} clone dependencies (frozen, offline, ignore-scripts, store ${STORE_DIR}) failed: status=${installed.status} error=${installed.error?.message ?? "none"} ${installed.stderr || installed.stdout}`,
145
+ );
146
+ }
147
+
114
148
  /**
115
149
  * One ordinary commit, with its hook invocation OBSERVED rather than steered: `GIT_TRACE2_EVENT`
116
150
  * writes git's own child-process events to a sandbox file, and the assertion reads that file for a
@@ -213,6 +247,7 @@ if (candidatePatch.trim().length > 0) {
213
247
  fs.writeFileSync(patchFile, candidatePatch);
214
248
  const applied = run("git", ["-C", SNAPSHOT, "apply", "--index", patchFile], env);
215
249
  assert.equal(applied.status, 0, `the candidate patch did not apply to the snapshot clone: ${applied.stderr}`);
250
+ prepareCloneDeps(SNAPSHOT, "snapshot");
216
251
  snapshotHook = commitInClone(SNAPSHOT, "candidate snapshot under test", "snapshot").hookTrace;
217
252
  // LOAD-BEARING, before the mirror is made from it: if a hook rewrote a file, or the patch left
218
253
  // anything behind, the mirror would carry bytes the candidate does not have.
@@ -254,14 +289,59 @@ function readJson(file: string): Record<string, unknown> {
254
289
  function integrationInstall(atom: string) {
255
290
  return run("herdr", ["integration", "install", atom], env);
256
291
  }
257
- function pluginInstall(ref: string) {
258
- return run("herdr", ["plugin", "install", PLUGIN_SPEC, "--ref", ref, "--yes"], env);
292
+ function pluginInstall(ref: string, extraEnv: NodeJS.ProcessEnv = {}) {
293
+ return run("herdr", ["plugin", "install", PLUGIN_SPEC, "--ref", ref, "--yes"], { ...env, ...extraEnv });
294
+ }
295
+ /** One git read whose failure is named: a spawn error, a non-zero status or an empty answer. */
296
+ function gitRead(dir: string, argv: string[], what: string): string {
297
+ const r = run("git", ["-C", dir, ...argv], env);
298
+ const out = (r.stdout ?? "").trim();
299
+ assert.ok(
300
+ !r.error && r.status === 0 && out !== "",
301
+ `${what} could not be read (git ${argv.join(" ")} in ${dir}): status=${r.status} error=${r.error?.message ?? "none"} ${r.stderr ?? ""}`,
302
+ );
303
+ return out;
259
304
  }
260
- function managedCheckout(): string | null {
261
- const base = path.join(env.XDG_CONFIG_HOME as string, "herdr", "plugins", "github");
262
- if (!fs.existsSync(base)) return null;
263
- const entry = fs.readdirSync(base).find((e) => e.startsWith(PLUGIN_ID));
264
- return entry ? path.join(base, entry) : null;
305
+
306
+ /**
307
+ * Herdr's OWN registration names where it checked this plugin out: `plugins.json` in its config
308
+ * directory holds one entry per installed plugin, and `plugin_root` is the manifest directory
309
+ * INSIDE that managed checkout. This fixture used to scan `plugins/github/<id>*`; on 0.9.5 that
310
+ * directory did not exist (observed: `plugins/github-installations/<id>-<hash>/<generation>/checkout`)
311
+ * and the scan answered "no checkout" with an empty HEAD instead of failing. So the path is READ
312
+ * from the registration rather than assembled from a layout. That holds only while the registry
313
+ * sits at `plugins.json` with its `plugin_id` / `plugin_root` fields — neither is re-measured on
314
+ * every Herdr — and when it stops holding the asserts below name it. Zero or several entries for
315
+ * this id, a relative root, or a checkout that resolves outside this sandbox's herdr config is red.
316
+ */
317
+ function managedCheckoutRoot(): string {
318
+ const herdrConfig = path.join(env.XDG_CONFIG_HOME as string, "herdr");
319
+ const registry = path.join(herdrConfig, "plugins.json");
320
+ assert.ok(fs.existsSync(registry), `herdr left no plugin registry at ${registry}`);
321
+ const entries: unknown = JSON.parse(fs.readFileSync(registry, "utf8"));
322
+ assert.ok(Array.isArray(entries), `herdr's plugin registry ${registry} is not a list`);
323
+ const mine = entries.filter((e) => (e as { plugin_id?: unknown } | null)?.plugin_id === PLUGIN_ID);
324
+ assert.equal(
325
+ mine.length,
326
+ 1,
327
+ `herdr's plugin registry ${registry} holds ${mine.length} entries for ${PLUGIN_ID}, not exactly one`,
328
+ );
329
+ const pluginRoot = (mine[0] as { plugin_root?: unknown }).plugin_root;
330
+ assert.ok(
331
+ typeof pluginRoot === "string" && path.isAbsolute(pluginRoot),
332
+ `the ${PLUGIN_ID} registration in ${registry} has no absolute plugin_root: ${JSON.stringify(pluginRoot)}`,
333
+ );
334
+ const root = gitRead(pluginRoot, ["rev-parse", "--show-toplevel"], "the registered plugin_root's git root");
335
+ // Compared by REAL path: a lexical check would pass a sandbox-spelled link whose target is the
336
+ // operator's own repo, and once the candidate lands that repo's HEAD is the HEAD under test.
337
+ const realConfig = fs.realpathSync(herdrConfig);
338
+ const realRoot = fs.realpathSync(root);
339
+ const inside = path.relative(realConfig, realRoot);
340
+ assert.ok(
341
+ inside !== "" && inside !== ".." && !inside.startsWith(`..${path.sep}`) && !path.isAbsolute(inside),
342
+ `the registered checkout ${root} (real ${realRoot}) is not inside this sandbox's herdr config ${realConfig}`,
343
+ );
344
+ return root;
265
345
  }
266
346
 
267
347
  try {
@@ -277,17 +357,13 @@ try {
277
357
  const journal = fs.existsSync(journalPath) ? readJson(journalPath) : null;
278
358
  const ledger = fs.existsSync(ledgerPath) ? readJson(ledgerPath) : null;
279
359
  const identity = (journal?.artifactIdentity ?? {}) as Record<string, string>;
280
- const checkout = managedCheckout();
281
- // The managed checkout's git root is asked for rather than guessed: the manifest root is a
282
- // SUBDIRECTORY of the clone, and a hardcoded `<dir>/checkout` would silently answer nothing.
283
- const checkoutRoot =
284
- checkout === null ? "" : (run("git", ["-C", checkout, "rev-parse", "--show-toplevel"], env).stdout ?? "").trim();
285
- const checkoutHead =
286
- checkoutRoot === "" ? "" : (run("git", ["-C", checkoutRoot, "rev-parse", "HEAD"], env).stdout ?? "").trim();
287
- const shallow =
288
- checkoutRoot === ""
289
- ? "no-checkout"
290
- : (run("git", ["-C", checkoutRoot, "rev-parse", "--is-shallow-repository"], env).stdout ?? "").trim();
360
+ // The install is asserted BEFORE the checkout is located, so a failed install is reported as
361
+ // itself and not as the missing registration it would leave behind.
362
+ assert.equal(first.status, 0, `the first herdr plugin install failed: ${first.stderr || first.stdout}`);
363
+ // The manifest root is a SUBDIRECTORY of the clone, so its git root is asked for, not guessed.
364
+ const checkoutRoot = managedCheckoutRoot();
365
+ const checkoutHead = gitRead(checkoutRoot, ["rev-parse", "HEAD"], "the managed checkout's HEAD");
366
+ const shallow = gitRead(checkoutRoot, ["rev-parse", "--is-shallow-repository"], "the managed checkout's shallowness");
291
367
  const piAfterFirst = readJson(piSettings);
292
368
  const provider = (piAfterFirst.entwurfProvider ?? {}) as Record<string, unknown>;
293
369
  const providerText = JSON.stringify(provider);
@@ -443,35 +519,66 @@ try {
443
519
  const scratch = path.join(ROOT, "scratch");
444
520
  assert.equal(run("git", ["clone", "--quiet", MIRROR, scratch], env).status, 0, "scratch clone");
445
521
  assert.equal(run("git", ["-C", scratch, "checkout", "--quiet", "-b", "gapprobe", HEAD], env).status, 0, "branch");
446
- // A build that MUTATES the manifest: herdr reloads it after the build and aborts the install
447
- // (measured: `ensure_manifest_unchanged_after_build`). Our runner still ran to completion.
522
+ // A test-owned probe makes one SEMANTIC manifest change after our runner has finished, and the
523
+ // cell then asserts the install's exit and Herdr's registration. HISTORY, not this run's
524
+ // evidence: at the time this cell was written herdr re-read the manifest after the build and
525
+ // compared the PARSED struct (`ensure_manifest_unchanged_after_build`, read at src/cli/plugin.rs),
526
+ // so only a semantic change could matter; that is not re-measured on the pinned Herdr and is
527
+ // not named as the cause here. Until 2026-10-23 the probe replaced the literal
528
+ // `version = "0.1.0"`, which the manifest stopped carrying at dd84ac0 — so it silently changed
529
+ // nothing. The new version is now DERIVED at build time from the manifest Herdr checked out.
448
530
  const runnerPath = path.join(scratch, "plugins", "herdr", "lib", "build.mjs");
449
531
  // The mutation has to run BEFORE the runner's process exits, so it is INSERTED INTO the entry
450
- // block rather than appended after it. Measured on the previous two runs: appended top-level
451
- // code never executed at all (`process.exit(runBuild(...))` ends the process first), and the
452
- // earlier `# comment` version of it would not have mattered anyway — herdr compares the PARSED
453
- // manifest by full struct equality (`ensure_manifest_unchanged_after_build`, read at
454
- // src/cli/plugin.rs), so only a semantic change makes its commit fail. Rewriting the declared
455
- // plugin version is exactly that.
532
+ // block rather than appended after it (measured earlier: appended top-level code never ran,
533
+ // because `process.exit(runBuild(...))` ends the process first). That block is inside the
534
+ // runner's own try/catch, so a probe guard that throws there ALSO ends in a non-zero build —
535
+ // which would look exactly like the expected gap. The receipt below is what tells them apart:
536
+ // it is written only after the runner's code is known and the rewritten manifest read back.
456
537
  const runnerSource = fs.readFileSync(runnerPath, "utf8");
457
538
  const entry = "\t\tprocess.exit(runBuild(process.env));";
458
- assert.ok(runnerSource.includes(entry), "the runner's entry block is not the shape this probe patches");
459
- fs.writeFileSync(
460
- runnerPath,
461
- runnerSource.replace(
462
- entry,
463
- [
464
- "\t\tconst probeCode = runBuild(process.env);",
465
- "\t\t// gap probe: herdr re-reads the manifest after the build and aborts its own commit.",
466
- '\t\tconst probeManifest = path.join(PLUGIN_DIR, "herdr-plugin.toml");',
467
- "\t\tfs.writeFileSync(",
468
- "\t\t\tprobeManifest,",
469
- '\t\t\tfs.readFileSync(probeManifest, "utf8").replace(\'version = "0.1.0"\', \'version = "0.1.1"\'),',
470
- "\t\t);",
471
- "\t\tprocess.exit(probeCode);",
472
- ].join("\n"),
473
- ),
539
+ assert.equal(
540
+ runnerSource.split(entry).length - 1,
541
+ 1,
542
+ "setup: the runner's entry block is not exactly once the shape this probe patches",
543
+ );
544
+ const scratchManifest = fs.readFileSync(path.join(scratch, "plugins", "herdr", "herdr-plugin.toml"), "utf8");
545
+ assert.equal(
546
+ (scratchManifest.match(/^version = "\d+\.\d+\.\d+"$/gm) ?? []).length,
547
+ 1,
548
+ 'setup: the manifest does not carry exactly one `version = "X.Y.Z"` line for the probe to rewrite',
474
549
  );
550
+ const probedSource = runnerSource.replace(
551
+ entry,
552
+ [
553
+ "\t\tconst probeCode = runBuild(process.env);",
554
+ "\t\t// gap probe (test-owned): one semantic manifest change after a finished runner, then a receipt.",
555
+ "\t\tconst probeReceipt = process.env.HPBL_GAP_PROBE_RECEIPT;",
556
+ "\t\tif (!probeReceipt) {",
557
+ '\t\t\tthrow new Error("gap probe: HPBL_GAP_PROBE_RECEIPT did not reach the build");',
558
+ "\t\t}",
559
+ '\t\tconst probeManifest = path.join(PLUGIN_DIR, "herdr-plugin.toml");',
560
+ '\t\tconst probeBefore = fs.readFileSync(probeManifest, "utf8");',
561
+ '\t\tconst probeLines = probeBefore.match(/^version = "\\d+\\.\\d+\\.\\d+"$/gm) ?? [];',
562
+ "\t\tif (probeLines.length !== 1) {",
563
+ '\t\t\tthrow new Error("gap probe: " + probeLines.length + " version lines, not exactly one");',
564
+ "\t\t}",
565
+ "\t\tconst probeFrom = probeLines[0];",
566
+ "\t\tconst probeTo = probeFrom.replace(/(\\d+)\"$/, (_match, patch) => Number(patch) + 1 + '\"');",
567
+ '\t\tconst probeAfter = probeBefore.replace(/^version = "\\d+\\.\\d+\\.\\d+"$/m, probeTo);',
568
+ "\t\tif (probeAfter === probeBefore || probeFrom === probeTo) {",
569
+ '\t\t\tthrow new Error("gap probe: the version rewrite changed nothing");',
570
+ "\t\t}",
571
+ "\t\tfs.writeFileSync(probeManifest, probeAfter);",
572
+ '\t\tif (fs.readFileSync(probeManifest, "utf8") !== probeAfter) {',
573
+ '\t\t\tthrow new Error("gap probe: the rewritten manifest did not read back");',
574
+ "\t\t}",
575
+ "\t\tfs.writeFileSync(probeReceipt, JSON.stringify({ probeCode, from: probeFrom, to: probeTo }));",
576
+ "\t\tprocess.exit(probeCode);",
577
+ ].join("\n"),
578
+ );
579
+ assert.notEqual(probedSource, runnerSource, "setup: patching the runner's entry block changed nothing");
580
+ fs.writeFileSync(runnerPath, probedSource);
581
+ prepareCloneDeps(scratch, "gapprobe");
475
582
  const gapHook = commitInClone(scratch, "gap probe", "gapprobe").hookTrace;
476
583
  assertExactlyClean(scratch, "the gap-probe scratch clone");
477
584
  assert.equal(run("git", ["-C", scratch, "push", "--quiet", MIRROR, "gapprobe"], env).status, 0, "push probe");
@@ -482,8 +589,29 @@ try {
482
589
  activatedBackends: string[];
483
590
  components: { backend: string; state: string }[];
484
591
  };
485
- const gapRun = pluginInstall("gapprobe");
592
+ // The receipt lives in this sandbox ROOT, never in the checkout Herdr may delete, and its path
593
+ // reaches ONLY this install — with no default, so a build that does not receive it writes none.
594
+ const receiptPath = path.join(ROOT, "gap-probe-receipt.json");
595
+ assert.ok(!fs.existsSync(receiptPath), `setup: a gap-probe receipt already exists at ${receiptPath}`);
596
+ const gapRun = pluginInstall("gapprobe", { HPBL_GAP_PROBE_RECEIPT: receiptPath });
486
597
  const registryAfter = run("herdr", ["plugin", "list"], env).stdout ?? "";
598
+ const gapTail = JSON.stringify((gapRun.stderr || gapRun.stdout || "").trim().slice(-300));
599
+ const receiptText = fs.existsSync(receiptPath) ? fs.readFileSync(receiptPath, "utf8") : null;
600
+ type GapReceipt = { probeCode?: unknown; from?: unknown; to?: unknown };
601
+ const receipt: GapReceipt | null = receiptText === null ? null : JSON.parse(receiptText);
602
+ // Checked HERE, before the reconcile: without a completed-mutation receipt a non-zero install is
603
+ // a probe or setup failure, never the expected gap. One receipt is evidence that ONE attempt
604
+ // finished the mutation — not that the build ran once, and not which Herdr check refused.
605
+ assert.ok(
606
+ receipt !== null &&
607
+ receipt.probeCode === 0 &&
608
+ typeof receipt.from === "string" &&
609
+ typeof receipt.to === "string" &&
610
+ receipt.from !== receipt.to,
611
+ `[${LABEL}] POST-BUILD GAP precondition: no receipt of a finished runner (code 0) followed by a real ` +
612
+ `manifest change, so this install's exit is NOT read as the gap (receipt=${receiptText ?? "absent"} ` +
613
+ `gap-install=${gapRun.status} herdr-tail=${gapTail})`,
614
+ );
487
615
  // The gap commit is a DIFFERENT artifact, so the ledger cannot be byte-identical here and
488
616
  // claiming it would be false: our runner ran to completion, which means it legally rebound the
489
617
  // activation to that commit. What Herdr then failed to do is its own registration.
@@ -503,12 +631,13 @@ try {
503
631
  const noDuplicates = (l: { components: { backend: string }[] }) =>
504
632
  new Set(l.components.map((c) => c.backend)).size === l.components.length;
505
633
  ok(
506
- `[${LABEL}] POST-BUILD GAP: Herdr commits its checkout and registry only AFTER a build, so a build that makes ` +
507
- "that commit fail leaves Herdr's PREVIOUS registration standing while what our runner already did REMAINS " +
508
- "— the runtime is the gap commit and the ledger has legally rebound to it, active and retained, because " +
509
- "Herdr has no cleanup hook to call. The next normal install re-binds to HEAD's identity and reconciles the " +
510
- "same components rather than duplicating them. The gap is NAMED, not atomic, and this cell asserts the " +
511
- `actual transition rather than an unchanged-bytes claim that could never be true (gap-commit-hooks=${gapHook} gap-install=${gapRun.status} registry-unchanged=${registryBefore === registryAfter} before=${ledgerBefore.artifactIdentity.commit?.slice(0, 8)} after-gap=${ledgerAfterGap.phase}/${ledgerAfterGap.artifactIdentity.commit?.slice(0, 8)}=${gapCommit.slice(0, 8)} reconcile=${reconcile.status}/${ledgerAfterReconcile.phase}/${ledgerAfterReconcile.artifactIdentity.commit?.slice(0, 8)}=${HEAD.slice(0, 8)} backends=${JSON.stringify(ledgerAfterReconcile.activatedBackends)} no-dupes=${noDuplicates(ledgerAfterGap)}/${noDuplicates(ledgerAfterReconcile)})`,
634
+ `[${LABEL}] POST-BUILD GAP: after our runner finished with 0 and a test-owned probe left one semantic ` +
635
+ "manifest change on disk, the install exits non-zero and Herdr's PREVIOUS registration stands while what " +
636
+ "our runner already did REMAINS — the runtime is the gap commit and the ledger has legally rebound to it, " +
637
+ "active and retained, because Herdr has no cleanup hook to call. The next normal install re-binds to HEAD's " +
638
+ "identity and reconciles the same components rather than duplicating them. The gap is NAMED, not atomic; " +
639
+ "which Herdr check refused is read from its own output below, not claimed (receipt=" +
640
+ `${receiptText} herdr-tail=${gapTail} gap-commit-hooks=${gapHook} gap-install=${gapRun.status} registry-unchanged=${registryBefore === registryAfter} before=${ledgerBefore.artifactIdentity.commit?.slice(0, 8)} after-gap=${ledgerAfterGap.phase}/${ledgerAfterGap.artifactIdentity.commit?.slice(0, 8)}=${gapCommit.slice(0, 8)} reconcile=${reconcile.status}/${ledgerAfterReconcile.phase}/${ledgerAfterReconcile.artifactIdentity.commit?.slice(0, 8)}=${HEAD.slice(0, 8)} backends=${JSON.stringify(ledgerAfterReconcile.activatedBackends)} no-dupes=${noDuplicates(ledgerAfterGap)}/${noDuplicates(ledgerAfterReconcile)})`,
512
641
  gapRun.status !== 0 &&
513
642
  registryBefore === registryAfter &&
514
643
  ledgerAfterGap.phase === "active" &&
@@ -13,12 +13,15 @@
13
13
  * itself. So this gate enters where a caller enters, through a real MCP `tools/call`, and follows
14
14
  * one citizen all the way around:
15
15
  *
16
- * tools/call entwurf_fresh_call → LAUNCH receipt (tmux coordinates + nonce, nothing else)
17
- * the sibling's own first action → CALLBACK receipt (its garden id, as the sender envelope of
16
+ * tools/call entwurf_fresh_call → LAUNCH receipt (tmux coordinates + nonce, nothing else);
17
+ * the native pi cell names placement.tmuxSession, and the
18
+ * harness asks tmux itself that the pane sits in that seat
19
+ * the sibling's pre-task callback → CALLBACK receipt (its garden id, as the sender envelope of
18
20
  * the nonce message — never a lookup, never a self-report)
19
21
  * tools/call entwurf_v2 f-a-f → LIVE SEND receipt (control-socket, into a running session)
20
22
  * tools/call entwurf_resume_call → REFUSED while the citizen is live, with no window opened
21
23
  * closeWindow(launch handle) → dormant: pane gone, socket unconnectable, record preserved
24
+ * (native cell: a cross-session close; the seat's anchor stands)
22
25
  * tools/call entwurf_v2 f-a-f → refused honestly, because dormant is unreachable by delivery
23
26
  * tools/call entwurf_resume_call → RESUME LAUNCH + RESUME OBSERVATION receipts, kept apart
24
27
  * tools/call entwurf_v2 f-a-f → the resumed citizen recalls the fact from BEFORE the close
@@ -199,6 +202,25 @@ function tmux(socket: string, args: string[], env: NodeJS.ProcessEnv): { status:
199
202
  return { status: r.status, stdout: (r.stdout ?? "").trim() };
200
203
  }
201
204
 
205
+ type TmuxAnswer = ReturnType<typeof tmux>;
206
+
207
+ /** "Unchanged" over two window listings (#108), FAIL-CLOSED: a query that did not answer is never
208
+ * unchanged, because two failed queries print the same nothing. */
209
+ function windowsUnchanged(before: TmuxAnswer, after: TmuxAnswer): boolean {
210
+ return before.status === 0 && after.status === 0 && before.stdout === after.stdout;
211
+ }
212
+
213
+ /** "That window is gone" (#108), FAIL-CLOSED: only an inventory tmux actually answered can prove
214
+ * an absence — a failed query lists nothing, which would otherwise read as "not listed". */
215
+ function windowAbsent(inventory: TmuxAnswer, windowId: string): boolean {
216
+ return inventory.status === 0 && !inventory.stdout.split("\n").includes(windowId);
217
+ }
218
+
219
+ /** The diagnostic line for one listing: which query, and what tmux answered. */
220
+ function listingDetail(query: string, answer: TmuxAnswer): string {
221
+ return `--- ${query} → status ${answer.status} ---\n${answer.stdout || "(empty)"}`;
222
+ }
223
+
202
224
  function onPath(cmd: string): boolean {
203
225
  return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
204
226
  }
@@ -216,8 +238,8 @@ function pidIsAlive(pid: number): boolean {
216
238
  /**
217
239
  * What the launched window actually shows, read at the moment a nonce callback never arrived.
218
240
  *
219
- * The launch receipt says so itself — "if it never comes, the window is visible and can be read
220
- * directly" — but on a headless release-gate run nobody is there to look, and the window is torn
241
+ * The launch receipt says so itself — "if it never comes — not started, stuck, or held back after its
242
+ * check; this receipt cannot tell which — the window is visible and can be read directly" — but on a headless release-gate run nobody is there to look, and the window is torn
221
243
  * down in teardown seconds later. So the harness looks for the operator: is the pane process even
222
244
  * alive, and what is painted in it. That separates the three states a bare timeout collapses into
223
245
  * one — the runtime never started (or died), it started and is sitting on an error or a prompt, or
@@ -604,6 +626,7 @@ async function main(): Promise<void> {
604
626
  cell: string,
605
627
  backend: "pi" | "claude-code",
606
628
  roots: CellRoots = NATIVE_ROOTS,
629
+ seat?: string,
607
630
  ): {
608
631
  socket: string;
609
632
  serverPid: string;
@@ -650,6 +673,15 @@ async function main(): Promise<void> {
650
673
  if (tmux(socket, ["new-session", "-d", "-s", "fixture", "-c", scratch, "-n", "anchor"], serverEnv).status !== 0) {
651
674
  throw new Error(`${LABEL}: could not start the private ${cell} tmux server at ${socket}`);
652
675
  }
676
+ // The optional seat (#108): a SECOND session on this same private server, born with the
677
+ // same server environment as the caller's, so the only difference a seated launch can
678
+ // show is the session it lands in. It dies with this server in teardown, on both paths.
679
+ if (
680
+ seat !== undefined &&
681
+ tmux(socket, ["new-session", "-d", "-s", seat, "-c", scratch, "-n", "anchor"], serverEnv).status !== 0
682
+ ) {
683
+ throw new Error(`${LABEL}: could not create the ${cell} seat session ${seat} on ${socket}`);
684
+ }
653
685
  const anchorPane = tmux(
654
686
  socket,
655
687
  ["display-message", "-p", "-t", "fixture:anchor", "#{pane_id}"],
@@ -681,6 +713,23 @@ async function main(): Promise<void> {
681
713
  return listed.stdout.split("\n").filter((l) => l.trim()).length;
682
714
  }
683
715
 
716
+ /** One session's window ids, by session id — the harness's own tmux question. The status rides
717
+ * with the listing so no caller can read a failed query as an empty session. */
718
+ function sessionWindows(socket: string, sessionId: string, env: NodeJS.ProcessEnv): TmuxAnswer {
719
+ return tmux(socket, ["list-windows", "-t", sessionId, "-F", "#{window_id}"], env);
720
+ }
721
+
722
+ /** Every pane of the session NAMED exactly (`=name`), as `session_id window_id pane_id` rows.
723
+ * The seat oracle (#108) asks by name so its id never comes from the receipt under test. */
724
+ function namedSessionPanes(socket: string, name: string, env: NodeJS.ProcessEnv): string[] {
725
+ const listed = tmux(
726
+ socket,
727
+ ["list-panes", "-s", "-t", `=${name}`, "-F", "#{session_id} #{window_id} #{pane_id}"],
728
+ env,
729
+ );
730
+ return listed.status === 0 ? listed.stdout.split("\n").filter((l) => l.trim()) : [];
731
+ }
732
+
684
733
  /** The pane's ACTUAL start directory, asked by stable pane id in a SEPARATE query.
685
734
  * `pane_start_path` rather than `pane_current_path`: measured 2026-08-06, the row a
686
735
  * `new-window -P -F` prints is emitted BEFORE the child chdirs, so current-path there
@@ -700,8 +749,14 @@ async function main(): Promise<void> {
700
749
  // ════════════════════════════════════════════════════════════════════
701
750
  // The pi lifecycle, run once per PROVIDER SHAPE
702
751
  // ════════════════════════════════════════════════════════════════════
703
- async function runPiCell(cell: string, model: string, full: boolean, roots: CellRoots): Promise<void> {
704
- const srv = startPrivateServer(cell, "pi", roots);
752
+ async function runPiCell(
753
+ cell: string,
754
+ model: string,
755
+ full: boolean,
756
+ roots: CellRoots,
757
+ seat: string | null,
758
+ ): Promise<void> {
759
+ const srv = startPrivateServer(cell, "pi", roots, seat ?? undefined);
705
760
  const bridge = new BridgeClient(srv.env);
706
761
  bridges.push(bridge);
707
762
 
@@ -716,6 +771,31 @@ async function main(): Promise<void> {
716
771
  );
717
772
  }
718
773
 
774
+ // ── the seat, asked of tmux BEFORE the launch (#108) ─────────────
775
+ // Both ids come from the harness's own queries — the seat by its exact name, the caller
776
+ // by the anchor pane the bridge child sits in — so neither can be borrowed from the
777
+ // receipt it is about to judge. The anchor row is kept whole: the close below must leave
778
+ // this exact session, window and pane standing.
779
+ let seatSid = "";
780
+ let callerSid = "";
781
+ let seatAnchor: string[] = [];
782
+ let callerWindowsBefore: TmuxAnswer = { status: null, stdout: "" };
783
+ if (seat !== null) {
784
+ seatAnchor = namedSessionPanes(srv.socket, seat, srv.env);
785
+ seatSid = (seatAnchor[0] ?? "").split(" ")[0] ?? "";
786
+ callerSid = tmux(
787
+ srv.socket,
788
+ ["display-message", "-p", "-t", String(srv.env.TMUX_PANE), "#{session_id}"],
789
+ srv.env,
790
+ ).stdout;
791
+ ok(
792
+ `${cell}: seat — the private server holds a second session named ${seat} with exactly one anchor pane, and it is not the caller's session (both ids asked of tmux by the harness, before any launch)`,
793
+ seatAnchor.length === 1 && /^\$\d+$/.test(seatSid) && /^\$\d+$/.test(callerSid) && seatSid !== callerSid,
794
+ `--- seat panes ---\n${seatAnchor.join("\n") || "(none)"}\n--- caller session ---\n${callerSid}`,
795
+ );
796
+ callerWindowsBefore = sessionWindows(srv.socket, callerSid, srv.env);
797
+ }
798
+
719
799
  // ── receipt 1/4: LAUNCH ─────────────────────────────────────────
720
800
  const launch = await bridge.call("entwurf_fresh_call", {
721
801
  backend: "pi",
@@ -723,6 +803,7 @@ async function main(): Promise<void> {
723
803
  task:
724
804
  `Remember this fact for the rest of this session: the color is ${SCENE_FACT}. ` +
725
805
  "Then reply with the single word ACK and stop. Do not read files.",
806
+ ...(seat === null ? {} : { placement: { tmuxSession: seat } }),
726
807
  });
727
808
  ok(
728
809
  `${cell}: tools/call entwurf_fresh_call returned a launch receipt through the public surface`,
@@ -738,6 +819,37 @@ async function main(): Promise<void> {
738
819
  launch.text.includes(model) && launch.text.includes("LAUNCH receipt") && launch.text.includes("does NOT mean"),
739
820
  `--- receipt ---\n${launch.text}`,
740
821
  );
822
+ if (seat !== null) {
823
+ ok(
824
+ `${cell}: seat — the receipt echoes the REQUESTED seat and resolves it to the seat's own session id, not the caller's`,
825
+ launch.text.includes(`seat: ${seat} (requested tmux session, resolved to ${seatSid})`) &&
826
+ coords.sessionId === seatSid,
827
+ `--- receipt ---\n${launch.text}\n--- seat session (asked by name) ---\n${seatSid}`,
828
+ );
829
+ // The receipt's session id is the target the product CHOSE, not one tmux reported, so
830
+ // it cannot vouch for where the window went. The pane itself is asked instead.
831
+ const seatWindows = sessionWindows(srv.socket, seatSid, srv.env);
832
+ const landed = tmux(
833
+ srv.socket,
834
+ ["display-message", "-p", "-t", coords.paneId, "#{session_id} #{window_id}"],
835
+ srv.env,
836
+ ).stdout;
837
+ ok(
838
+ `${cell}: seat — tmux agrees: the launched pane sits in the seat session's window, and that window is listed in the seat`,
839
+ landed === `${seatSid} ${coords.windowId}` &&
840
+ seatWindows.status === 0 &&
841
+ seatWindows.stdout.split("\n").includes(coords.windowId),
842
+ `--- pane ${coords.paneId} reports ---\n${landed}\n--- expected ---\n${seatSid} ${coords.windowId}\n` +
843
+ listingDetail(`list-windows -t ${seatSid}`, seatWindows),
844
+ );
845
+ const callerAfterLaunch = sessionWindows(srv.socket, callerSid, srv.env);
846
+ ok(
847
+ `${cell}: seat — the caller's own session holds exactly the windows it held before the launch (both listings answered by tmux)`,
848
+ windowsUnchanged(callerWindowsBefore, callerAfterLaunch),
849
+ `${listingDetail(`list-windows -t ${callerSid} (before launch)`, callerWindowsBefore)}\n` +
850
+ listingDetail(`list-windows -t ${callerSid} (after launch)`, callerAfterLaunch),
851
+ );
852
+ }
741
853
 
742
854
  // ── receipt 2/4: CALLBACK IDENTITY ──────────────────────────────
743
855
  const gid = await awaitCallbackSender(nonce, CALLBACK_WAIT_MS);
@@ -838,6 +950,24 @@ async function main(): Promise<void> {
838
950
  `${cell}: the pane process is gone`,
839
951
  await waitForPidsGone(new Set([Number(coords.panePid)]), DORMANT_WAIT_MS),
840
952
  );
953
+ if (seat !== null) {
954
+ // The close crossed sessions: the handle names the seat, the bridge's env names the
955
+ // caller. What must be gone is that one window; what must stand is the seat's own
956
+ // anchor — the same session, window and pane the fixture made — and the caller's set.
957
+ const anchorAfter = namedSessionPanes(srv.socket, seat, srv.env);
958
+ const inventoryAfter = tmux(srv.socket, ["list-windows", "-a", "-F", "#{window_id}"], srv.env);
959
+ const callerAfterClose = sessionWindows(srv.socket, callerSid, srv.env);
960
+ ok(
961
+ `${cell}: seat — the cross-session close removed exactly the launched window: tmux no longer lists it, the seat's original anchor session/window/pane are the ones still standing, and the caller's windows are unchanged`,
962
+ windowAbsent(inventoryAfter, coords.windowId) &&
963
+ anchorAfter.length === seatAnchor.length &&
964
+ anchorAfter.every((row, i) => row === seatAnchor[i]) &&
965
+ windowsUnchanged(callerWindowsBefore, callerAfterClose),
966
+ `--- seat anchor before ---\n${seatAnchor.join("\n")}\n--- seat panes after ---\n${anchorAfter.join("\n") || "(none)"}\n` +
967
+ `${listingDetail("list-windows -a", inventoryAfter)}\n` +
968
+ listingDetail(`list-windows -t ${callerSid} (after close)`, callerAfterClose),
969
+ );
970
+ }
841
971
  let dormantState = "";
842
972
  ok(
843
973
  `${cell}: the citizen is DORMANT — its control socket no longer answers, which is the fact the decider reads`,
@@ -1052,8 +1182,10 @@ async function main(): Promise<void> {
1052
1182
  }
1053
1183
 
1054
1184
  await runClaudeCell();
1055
- await runPiCell("pi-native", PI_NATIVE_MODEL, true, NATIVE_ROOTS);
1056
- await runPiCell("pi-acp", PI_ACP_MODEL, false, ACP_ROOTS);
1185
+ // Only the native cell names a seat (#108): the ACP and Claude Code cells keep the omitted-
1186
+ // placement path, so the default seat stays proven beside the explicit one.
1187
+ await runPiCell("pi-native", PI_NATIVE_MODEL, true, NATIVE_ROOTS, "seat");
1188
+ await runPiCell("pi-acp", PI_ACP_MODEL, false, ACP_ROOTS, null);
1057
1189
 
1058
1190
  ok(
1059
1191
  "correlation: all three siblings reported DIFFERENT garden ids and none is the launcher's — asserted after every cell, so it never depends on which one ran first",
@@ -435,16 +435,17 @@ ok(
435
435
  // successful callback result. The transcript is where both are visible, in order, in one
436
436
  // native session.
437
437
  // Since the callback moved out of band (ENTWURF_CALLBACK_TARGET/NONCE in the sibling env), the
438
- // callback-only prompt carries no address; the receipt is the no-arg first action itself.
439
- const bootstrapLine = buildOmpCallbackOnlyPrompt()
438
+ // callback-only prompt carries no address argument; it names the caller only for the optional
439
+ // read-only check, and the receipt is the no-arg callback itself.
440
+ const bootstrapLine = buildOmpCallbackOnlyPrompt(callerGid)
440
441
  .split("\n")
441
- .find((l) => l.includes("FIRST AND ONLY ACTION")) as string;
442
+ .find((l) => l.includes("THE ONE ACTION THIS MESSAGE ASKS FOR")) as string;
442
443
  const transcript = fs.readFileSync(transcriptPath as string, "utf8");
443
444
  const callbackAt = transcript.indexOf(bootstrapLine);
444
445
  const taskAt = transcript.indexOf(sceneFact);
445
446
  receipts["5-two-stage-order"] = `callback-only prompt @${callbackAt}, task scene fact @${taskAt}`;
446
447
  ok(
447
- "stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg first action; the address rides the sibling env, never this prompt)",
448
+ "stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg callback before the task; the address rides the sibling env, never this prompt)",
448
449
  callbackAt >= 0,
449
450
  ` expected line: ${bootstrapLine}`,
450
451
  );