@junghanacs/entwurf 0.40.0 → 0.41.0

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 (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -47,8 +47,8 @@
47
47
  */
48
48
 
49
49
  import assert from "node:assert/strict";
50
- import { spawnSync } from "node:child_process";
51
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
50
+ import { spawn, spawnSync } from "node:child_process";
51
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
52
52
  import { tmpdir } from "node:os";
53
53
  import * as path from "node:path";
54
54
  import { fileURLToPath } from "node:url";
@@ -56,10 +56,12 @@ import { resolveMailboxReceiverFacts } from "../pi-extensions/lib/entwurf-delive
56
56
  import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
57
57
  import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
58
58
  import type { DispatchDecision } from "../pi-extensions/lib/entwurf-v2-decider.ts";
59
+ import { acquireLock, releaseLock } from "../pi-extensions/lib/entwurf-v2-lock.ts";
59
60
  import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
60
61
  import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
61
62
  import { renderEntwurfV2Result } from "../pi-extensions/lib/entwurf-v2-surface.ts";
62
63
  import {
64
+ defaultMetaRecordArmLocksDir,
63
65
  listAllMetaIdentitiesDir,
64
66
  type MetaIdentity,
65
67
  makeStoreRecordReader,
@@ -70,6 +72,7 @@ import {
70
72
  upsertMetaSession,
71
73
  writeMetaReceiverMarker,
72
74
  } from "../pi-extensions/lib/meta-session.ts";
75
+ import { hookImportClosure, installerCopiedLibFiles, stageMetaHookBundle } from "./lib/meta-hook-bundle.ts";
73
76
  import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
74
77
 
75
78
  let passed = 0;
@@ -91,17 +94,9 @@ const sessionStartLeaf = manifest.hooks.SessionStart?.[0]?.hooks[0] ?? {};
91
94
 
92
95
  /** Stage a plugin bundle: entry + lib closure + the launcher, exactly as installed. */
93
96
  function makeBundle(root: string): void {
94
- mkdirSync(path.join(root, "lib"), { recursive: true });
95
- mkdirSync(path.join(root, "scripts"), { recursive: true });
96
- copyFileSync(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), path.join(root, "meta-bridge-hook.ts"));
97
- copyFileSync(
98
- path.join(REPO_DIR, "pi-extensions", "lib", "meta-session.ts"),
99
- path.join(root, "lib", "meta-session.ts"),
100
- );
101
- copyFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "session-id.js"), path.join(root, "lib", "session-id.js"));
102
- const launcher = path.join(root, "scripts", "hook-launch.sh");
103
- copyFileSync(LAUNCHER, launcher);
104
- chmodSync(launcher, 0o755);
97
+ // Entry + its OWN import closure + the launcher (scripts/lib/meta-hook-bundle.ts): a hand-kept
98
+ // list here could stay green while the installer missed a file the hook now imports.
99
+ stageMetaHookBundle(root, path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), LAUNCHER);
105
100
  }
106
101
 
107
102
  /** Resolve one manifest element the way Claude does: plain-string substitution, no shell. */
@@ -558,4 +553,226 @@ ok(
558
553
  );
559
554
  }
560
555
 
556
+ // ── E. the record-arm binding (#147 L2 B1) ────────────────────────────────────
557
+ // An arm-capable event commits a fresh `attachIncarnation` in the record and copies the SAME
558
+ // value into the receiver marker, under the record-arm lock; every other event preserves it. A
559
+ // dead marker is resume evidence only while those two agree, so every path where the marker was
560
+ // NOT rewritten has to leave them disagreeing, and a normal arm must never leave them so.
561
+ {
562
+ const BIND_NATIVE = "b1b1b1b1-arm-binding-session";
563
+ const BIND_TRANSCRIPT = path.join(CWD, `${BIND_NATIVE}.jsonl`);
564
+ writeFileSync(BIND_TRANSCRIPT, "");
565
+ const drive = (event: string, source = "startup") =>
566
+ driveHook({ event, nativeSessionId: BIND_NATIVE, source, transcriptPath: BIND_TRANSCRIPT });
567
+ const pair = (gid: string) => ({
568
+ record: identityFor(gid).attachIncarnation,
569
+ marker: readMetaReceiverMarker({ gardenId: gid, receiversDir: ROOTS.receiversDir })?.attachIncarnation ?? null,
570
+ });
571
+ const recordBytes = (gid: string) => readFileSync(path.join(ROOTS.sessionsDir, `${gid}.meta.json`), "utf8");
572
+ const armLocks = defaultMetaRecordArmLocksDir(ROOTS.receiversDir);
573
+ const envelope = (event: string) =>
574
+ JSON.stringify({
575
+ hook_event_name: event,
576
+ session_id: BIND_NATIVE,
577
+ transcript_path: BIND_TRANSCRIPT,
578
+ cwd: CWD,
579
+ source: "startup",
580
+ });
581
+
582
+ drive("SessionStart");
583
+ const BIND_GID = gardenIdFor(BIND_NATIVE);
584
+ const first = pair(BIND_GID);
585
+ ok(
586
+ "a first SessionStart (create, then the locked arm) commits a fresh 32-hex binding in the record [QK:MHSS-ARM-RECORD-BOUND]",
587
+ typeof first.record === "string" && /^[0-9a-f]{32}$/.test(first.record),
588
+ );
589
+ ok(
590
+ "…and the receiver marker that arm wrote carries the SAME value [QK:MHSS-ARM-BINDING-PAIRED]",
591
+ first.marker === first.record,
592
+ );
593
+
594
+ drive("UserPromptSubmit");
595
+ const afterUps = pair(BIND_GID);
596
+ ok(
597
+ "a UserPromptSubmit backfill PRESERVES the binding — a keystroke is not a new incarnation [QK:MHSS-UPS-PRESERVES-BINDING]",
598
+ afterUps.record === first.record && afterUps.marker === first.record,
599
+ );
600
+
601
+ drive("SessionStart", "compact");
602
+ const reminted = pair(BIND_GID);
603
+ ok(
604
+ "every arm-capable event mints a NEW binding and the pair still agrees [QK:MHSS-ARM-REMINTS-PAIRED]",
605
+ reminted.record !== first.record && reminted.marker === reminted.record,
606
+ );
607
+
608
+ // An arm whose owner cannot be proven (no launcher provenance): the record commits a new
609
+ // binding, the marker is not rewritten — so they disagree, and a later resume refuses.
610
+ {
611
+ const direct = spawnSync(process.execPath, [path.join(PLUGIN_ROOT, "meta-bridge-hook.ts")], {
612
+ encoding: "utf8",
613
+ input: envelope("SessionStart"),
614
+ env: hookEnv(),
615
+ });
616
+ assert.equal(direct.status, 0, `direct hook drive exited ${direct.status}: ${direct.stderr}`);
617
+ const unproven = pair(BIND_GID);
618
+ ok(
619
+ "an arm with no validated owner commits a new record binding and leaves the marker's old one — never a matching stale pair [QK:MHSS-UNPROVEN-OWNER-UNBINDS]",
620
+ unproven.record !== null &&
621
+ unproven.record !== reminted.record &&
622
+ readMetaReceiverMarker({ gardenId: BIND_GID, receiversDir: ROOTS.receiversDir, verifyOwner: false })
623
+ ?.attachIncarnation === reminted.record,
624
+ );
625
+ }
626
+ drive("SessionStart", "resume"); // back to a matching pair for the lock cells
627
+
628
+ // The lock held by someone else: an arm waits its bound and then writes NOTHING (ERROR); a
629
+ // keystroke skips its backfill (WARN). Held by THIS gate's live pid, so it cannot be reclaimed.
630
+ {
631
+ const held = acquireLock(BIND_GID, { dir: armLocks });
632
+ assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
633
+ const before = recordBytes(BIND_GID);
634
+ const markerBefore = pair(BIND_GID).marker;
635
+ const logBefore = readFileSync(HOOK_LOG, "utf8").length;
636
+ drive("SessionStart", "resume");
637
+ const armDelta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
638
+ ok(
639
+ "an arm that cannot take the record-arm lock writes NOTHING and says so as ERROR (attach not committed) [QK:MHSS-ARM-LOCK-BUSY-NO-WRITE]",
640
+ recordBytes(BIND_GID) === before &&
641
+ pair(BIND_GID).marker === markerBefore &&
642
+ /ERROR record-arm lock busy — attach NOT committed, watch not armed/.test(armDelta) &&
643
+ !/armed watch/.test(armDelta),
644
+ );
645
+ const upsBefore = readFileSync(HOOK_LOG, "utf8").length;
646
+ drive("UserPromptSubmit");
647
+ ok(
648
+ "a keystroke that finds the lock busy skips its backfill, writing nothing [QK:MHSS-UPS-LOCK-BUSY-SKIPS]",
649
+ recordBytes(BIND_GID) === before &&
650
+ /WARN record-arm lock busy — backfill skipped/.test(readFileSync(HOOK_LOG, "utf8").slice(upsBefore)),
651
+ );
652
+ if (held.ok) releaseLock(held.claim, { dir: armLocks });
653
+ }
654
+
655
+ // The lock released inside the bound: the arm waits, then commits a matching pair.
656
+ {
657
+ const held = acquireLock(BIND_GID, { dir: armLocks });
658
+ assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
659
+ const before = pair(BIND_GID).record;
660
+ const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
661
+ const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
662
+ const child = spawn(exe, argv, { env: hookEnv(), stdio: ["pipe", "ignore", "pipe"] });
663
+ // The close listener is registered at spawn, so a child that exits early is still observed.
664
+ const closed = new Promise<number | null>((resolve) => child.on("close", resolve));
665
+ child.stdin.end(envelope("SessionStart"));
666
+ await new Promise((resolve) => setTimeout(resolve, 300));
667
+ if (held.ok) releaseLock(held.claim, { dir: armLocks });
668
+ const code = await closed;
669
+ const waited = pair(BIND_GID);
670
+ ok(
671
+ "an arm that finds the lock held waits within its bound and, once released, commits a NEW matching pair [QK:MHSS-ARM-WAITS-FOR-LOCK]",
672
+ code === 0 && waited.record !== before && waited.marker === waited.record,
673
+ );
674
+ }
675
+
676
+ // TWO SUCCESSFUL OVERLAPPING SCHEDULES (Astra D4): the cells above hold the lock in the
677
+ // gate and run one child at a time; these put two real hook processes in flight at once and
678
+ // require BOTH to succeed, then the pair to agree. The lock is held by the gate only to make
679
+ // the overlap certain (both children start while it is held), never as a substitute writer.
680
+ // SUPPORTING SCHEDULE CELLS, not the lock proof: they carry no mutant, because a kill here
681
+ // would rest on process timing. The deterministic lock proof is the three busy cells above
682
+ // (MHSS-ARM-LOCK-BUSY-NO-WRITE, MHSS-UPS-LOCK-BUSY-SKIPS, MHSS-ARM-WAITS-FOR-LOCK). Schedule 2's
683
+ // order (arm before keystroke) relies on the keystroke's node startup outlasting the arm's
684
+ // few-ms critical section; it is asserted, never retried or slept into green.
685
+ const launch = (event: string) => {
686
+ const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
687
+ const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
688
+ const child = spawn(exe, argv, { env: hookEnv(), stdio: ["pipe", "ignore", "pipe"] });
689
+ child.stdin.end(envelope(event));
690
+ return new Promise<number | null>((resolve) => child.on("close", resolve));
691
+ };
692
+ const count = (text: string, re: RegExp) => (text.match(re) ?? []).length;
693
+
694
+ // Schedule 1 — arm × arm: both arms are waiting when the lock frees, so they contend.
695
+ {
696
+ const held = acquireLock(BIND_GID, { dir: armLocks });
697
+ assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
698
+ const before = pair(BIND_GID).record;
699
+ const logBefore = readFileSync(HOOK_LOG, "utf8").length;
700
+ const a = launch("SessionStart");
701
+ const b = launch("CwdChanged");
702
+ await new Promise((resolve) => setTimeout(resolve, 300));
703
+ if (held.ok) releaseLock(held.claim, { dir: armLocks });
704
+ const codes = await Promise.all([a, b]);
705
+ const delta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
706
+ const end = pair(BIND_GID);
707
+ ok(
708
+ "two arms in flight at once BOTH commit (two attaches, two markers, no busy) and end as one matching pair [QK:MHSS-OVERLAP-ARM-ARM-PAIRED]",
709
+ codes.every((c) => c === 0) &&
710
+ count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json`, "g")) === 2 &&
711
+ count(delta, new RegExp(`INFO receiver marker ${BIND_GID} `, "g")) === 2 &&
712
+ !/record-arm lock busy/.test(delta) &&
713
+ end.record !== before &&
714
+ end.marker === end.record,
715
+ );
716
+ }
717
+
718
+ // Schedule 2 — arm + UserPromptSubmit: the arm is waiting on the lock when the keystroke
719
+ // starts; the arm takes the freed lock first, and the keystroke's backfill commits after it.
720
+ // Both succeed, the keystroke preserves the arm's binding, and the pair agrees.
721
+ {
722
+ const held = acquireLock(BIND_GID, { dir: armLocks });
723
+ assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
724
+ const before = pair(BIND_GID).record;
725
+ const logBefore = readFileSync(HOOK_LOG, "utf8").length;
726
+ const arm = launch("SessionStart");
727
+ await new Promise((resolve) => setTimeout(resolve, 300));
728
+ const keystroke = launch("UserPromptSubmit");
729
+ if (held.ok) releaseLock(held.claim, { dir: armLocks });
730
+ const codes = await Promise.all([arm, keystroke]);
731
+ const delta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
732
+ const end = pair(BIND_GID);
733
+ ok(
734
+ "an arm and a keystroke in flight at once BOTH commit, the keystroke preserves the arm's new binding, and the pair agrees [QK:MHSS-OVERLAP-ARM-UPS-PAIRED]",
735
+ codes.every((c) => c === 0) &&
736
+ count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=SessionStart`, "g")) === 1 &&
737
+ count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=UserPromptSubmit`, "g")) === 1 &&
738
+ delta.search(new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=SessionStart`)) <
739
+ delta.search(new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=UserPromptSubmit`)) &&
740
+ !/record-arm lock busy/.test(delta) &&
741
+ end.record !== before &&
742
+ end.marker === end.record,
743
+ );
744
+ }
745
+
746
+ ok(
747
+ "the record-arm lock lives beside the receivers dir, never in the v2 dispatch lock dir resume holds [QK:MHSS-ARM-LOCK-OWN-DIR]",
748
+ armLocks === path.join(AGENT_ROOT, "meta-record-arm-locks") &&
749
+ armLocks !== ROOTS.locksDir &&
750
+ !/ENTWURF_V2_LOCK_DIR/.test(readFileSync(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), "utf8")),
751
+ );
752
+ }
753
+
754
+ // ── F. the installed closure IS the hook's static `from` import closure ───────
755
+ // The installer copies a fixed set of lib files; the hook imports whatever it imports. If those
756
+ // drift, the installed hook cannot load — and a gate that hand-copied its own list would still be
757
+ // green. SOURCE form only: this gate runs in the source-only qualification snapshot, which has no
758
+ // dist. The compiled-form comparison and the staged compiled-hook run live with the dist consumer,
759
+ // check-capability-bundle-reach ([QK:BUNDLE-REACH-COMPILED-HOOK-CLOSURE] /
760
+ // [QK:BUNDLE-REACH-COMPILED-HOOK-RUNS]; the latter was MHSS-COMPILED-CLOSURE-RUNS here).
761
+ {
762
+ const installer = readFileSync(path.join(REPO_DIR, "scripts", "meta-bridge-install.sh"), "utf8");
763
+ const copied = installerCopiedLibFiles(installer);
764
+ // The installer names each mode-switched module `<name>.$LIB_EXT`; `session-id.js` ships as itself.
765
+ const asInstalled = (closure: string[]) =>
766
+ closure
767
+ .map((rel) => path.basename(rel))
768
+ .map((base) => (base === "session-id.js" ? base : base.replace(/\.(ts|js)$/, ".$LIB_EXT")))
769
+ .sort();
770
+ const sourceClosure = hookImportClosure(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"));
771
+ ok(
772
+ "the installer copies exactly the hook's static double-quoted `from` import closure, in source form [QK:MHSS-BUNDLE-CLOSURE-INSTALLED]",
773
+ sourceClosure.includes("lib/entwurf-v2-lock.ts") &&
774
+ JSON.stringify(asInstalled(sourceClosure)) === JSON.stringify(copied),
775
+ );
776
+ }
777
+
561
778
  console.log(`\ncheck-meta-hook-session-switch: ${passed} assertions passed`);
@@ -76,6 +76,7 @@ const identity = (gardenId: string, nativeSessionId: string): MetaIdentity => ({
76
76
  transcriptPath: null,
77
77
  createdAt: "2026-03-01T12:00:00.000Z",
78
78
  recordUpdatedAt: "2026-03-01T12:30:00.000Z",
79
+ attachIncarnation: null,
79
80
  });
80
81
 
81
82
  const GID_A = "20260301T120000-aaaaaa";
@@ -57,6 +57,7 @@ function rec(gardenId: string, over: Partial<MetaIdentity> = {}): string {
57
57
  transcriptPath: null,
58
58
  createdAt: "2026-06-11T00:00:00.000Z",
59
59
  recordUpdatedAt: "2026-06-11T00:00:00.000Z",
60
+ attachIncarnation: null,
60
61
  ...over,
61
62
  });
62
63
  }
@@ -109,7 +109,7 @@ if hooks is not None:
109
109
  # the array at runtime and reports the hook as `exit_code: 0,
110
110
  # outcome: success`. So the minimal keyset never bought protection from
111
111
  # that version — it only kept us on a launch form whose topology no host
112
- # could guarantee. The real defenses are the `>=2.1.217` floor
112
+ # could guarantee. The real defenses are the `>=2.1.306` floor
113
113
  # (check-claude-floor-coherence) and hook-launch.sh refusing an empty
114
114
  # argv. The marketplace/plugin manifests below stay minimal: the closed-
115
115
  # schema lesson that produced this gate was about a DECORATIVE key
@@ -22,6 +22,7 @@ import {
22
22
  defaultMetaMailboxDir,
23
23
  defaultMetaSessionsDir,
24
24
  enqueueMetaMessage,
25
+ findCertifiedIdentityByNativeSessionId,
25
26
  META_SCHEMA_VERSION_V3,
26
27
  type MetaIdentityMintInput,
27
28
  MetaRecordError,
@@ -32,6 +33,7 @@ import {
32
33
  readMailboxReceiptState,
33
34
  readMetaInbox,
34
35
  serializeMetaIdentity,
36
+ UpsertActionMismatchError,
35
37
  upsertMetaSession,
36
38
  } from "../pi-extensions/lib/meta-session.ts";
37
39
 
@@ -155,6 +157,35 @@ check("upsertMetaSession: second call attaches — same file/id, recordUpdatedAt
155
157
  }
156
158
  });
157
159
 
160
+ check(
161
+ "upsertMetaSession expectAction refuses BEFORE any write when the decision is not that action (#147 L2 B1)",
162
+ () => {
163
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-store-"));
164
+ try {
165
+ assert.throws(
166
+ () => upsertMetaSession({ dir, input: claudeInput(), now: T0, expectAction: "attach" }),
167
+ (err: unknown) => err instanceof UpsertActionMismatchError && err.actual === "create",
168
+ "expectAction refuses before any write when the decision differs [QK:META-UPSERT-EXPECT-ACTION]",
169
+ );
170
+ assert.deepEqual(fs.readdirSync(dir), []); // nothing published
171
+ const first = upsertMetaSession({ dir, input: claudeInput(), now: T0, expectAction: "create" });
172
+ const bytes = fs.readFileSync(first.path, "utf8");
173
+ assert.throws(
174
+ () => upsertMetaSession({ dir, input: claudeInput({ cwd: "/raced" }), now: T1, expectAction: "create" }),
175
+ (err: unknown) => err instanceof UpsertActionMismatchError && err.actual === "attach",
176
+ );
177
+ assert.equal(fs.readFileSync(first.path, "utf8"), bytes); // a raced create never became an attach
178
+ assert.equal(
179
+ findCertifiedIdentityByNativeSessionId(claudeInput().nativeSessionId, dir)?.gardenId,
180
+ first.record.gardenId,
181
+ );
182
+ assert.equal(fs.readFileSync(first.path, "utf8"), bytes); // the lookup is read-only
183
+ } finally {
184
+ fs.rmSync(dir, { recursive: true, force: true });
185
+ }
186
+ },
187
+ );
188
+
158
189
  check("upsertMetaSession: creates the store dir if absent; leaves no .tmp residue", () => {
159
190
  const base = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-store-"));
160
191
  const dir = path.join(base, "nested", "meta-sessions"); // does not exist yet
@@ -16,11 +16,15 @@
16
16
  * showing the actual rejected version value, and unknown/missing versions
17
17
  * crash,
18
18
  * - strict keyset: a body carrying any retired or unknown field is rejected
19
- * as stray, never coerced.
19
+ * as stray, never coerced,
20
+ * - `attachIncarnation` (#147 L2 B1) is the one key a body may omit (absent reads
21
+ * as null), is always emitted, is grammar-checked, and survives every attach
22
+ * that does not mint a new one.
20
23
  */
21
24
 
22
25
  import assert from "node:assert/strict";
23
26
  import {
27
+ decideUpsert,
24
28
  FRESH_CUT_COMMAND,
25
29
  type MetaIdentity,
26
30
  MetaRecordError,
@@ -64,6 +68,7 @@ const V3_IDENTITY: MetaIdentity = {
64
68
  transcriptPath: "/synthetic/project/.transcript.jsonl",
65
69
  createdAt: "2026-01-01T00:00:00.000Z",
66
70
  recordUpdatedAt: "2026-01-02T03:04:05.000Z",
71
+ attachIncarnation: null,
67
72
  };
68
73
 
69
74
  const V3_PI_IDENTITY: MetaIdentity = {
@@ -76,6 +81,7 @@ const V3_PI_IDENTITY: MetaIdentity = {
76
81
  transcriptPath: null,
77
82
  createdAt: "2026-03-01T12:00:00.000Z",
78
83
  recordUpdatedAt: "2026-03-01T12:30:00.000Z",
84
+ attachIncarnation: null,
79
85
  };
80
86
 
81
87
  const V1_BODY = JSON.stringify({
@@ -191,4 +197,52 @@ throws("parseMetaRecordV3 rejects an unknown backend", () =>
191
197
  parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, backend: "gemini" })),
192
198
  );
193
199
 
200
+ // --- attachIncarnation: the one key a V3 record may omit (#147 L2 B1) ---------
201
+ // One schema, one reader: absent reads as null ("no binding observed"), every serialize emits
202
+ // the key, a present value must be 32 lowercase hex, and only a mint replaces it — every other
203
+ // attach preserves it, and nothing infers one.
204
+ {
205
+ const BIND = "0123456789abcdef0123456789abcdef";
206
+ const { attachIncarnation: _omit, ...withoutKey } = V3_IDENTITY;
207
+ // Evaluated inside the assertion: a parser that REFUSES the absent key throws, and that refusal is
208
+ // exactly this claim failing, so it must land on this label rather than escape before it.
209
+ ok(
210
+ "a V3 body without attachIncarnation reads it as null — not a stray, not a defect [QK:META-V3-INCARNATION-ABSENT-NULL]",
211
+ (() => {
212
+ try {
213
+ return parseMetaRecordV3(JSON.stringify(withoutKey)).attachIncarnation === null;
214
+ } catch {
215
+ return false;
216
+ }
217
+ })(),
218
+ );
219
+ throws("a malformed attachIncarnation (not 32 lowercase hex) is refused [QK:META-V3-INCARNATION-MALFORMED]", () =>
220
+ parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, attachIncarnation: "ABCDEF" })),
221
+ );
222
+ throws("a non-string attachIncarnation is refused", () =>
223
+ parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, attachIncarnation: 7 })),
224
+ );
225
+ ok(
226
+ "serialize always emits the key (null included) and a bound value round-trips [QK:META-V3-INCARNATION-ALWAYS-EMITTED]",
227
+ serializeMetaIdentity(V3_IDENTITY).includes('"attachIncarnation": null') &&
228
+ parseMetaRecordV3(serializeMetaIdentity({ ...V3_IDENTITY, attachIncarnation: BIND })).attachIncarnation === BIND,
229
+ );
230
+ const bound = { ...V3_IDENTITY, attachIncarnation: BIND };
231
+ const input = {
232
+ backend: "claude-code" as const,
233
+ nativeSessionId: V3_IDENTITY.nativeSessionId,
234
+ cwd: "/synthetic/moved",
235
+ };
236
+ const preserved = decideUpsert(bound, input);
237
+ const reminted = decideUpsert(bound, { ...input, attachIncarnation: "f".repeat(32) });
238
+ ok(
239
+ "an attach without a value PRESERVES the binding; a mint replaces it; a create without one is null [QK:META-UPSERT-PRESERVES-INCARNATION]",
240
+ preserved.action === "attach" &&
241
+ preserved.record.attachIncarnation === BIND &&
242
+ preserved.record.cwd === "/synthetic/moved" &&
243
+ reminted.record.attachIncarnation === "f".repeat(32) &&
244
+ decideUpsert(null, input).record.attachIncarnation === null,
245
+ );
246
+ }
247
+
194
248
  console.log(`[check-meta-v3-record] ${passed} assertions ok`);
@@ -21,7 +21,11 @@
21
21
  * MUXRESUME-ARGV-HAS-CWD the `-c` actually reaches tmux, after the append shape
22
22
  * MUXRESUME-ARGV-RUNTIME-AFTER-DASHDASH the runtime and its flags are exec'd, never parsed as
23
23
  * tmux options or run through a shell
24
- * MUXRESUME-ARGV-CARRIER-FREE nothing beyond append + `-c` + runtime + the caller's flags
24
+ * MUXRESUME-ARGV-CARRIER-FREE nothing beyond append + the fixed scrub + `-c` + runtime + flags
25
+ * MUXRESUME-ARGV-IDENTITY-SCRUB the tmux half empties exactly the two pi identity carriers a tmux
26
+ * server may have inherited — the same scrub fresh-call carries
27
+ * MUXRESUME-RUNTIME-TABLE the runtime is a closed per-backend table: pi → pi, claude-code → claude
28
+ * MUXRESUME-HERDR-REFUSED inside herdr the tmux resume rail refuses by name, opening nothing
25
29
  * MUXRESUME-NO-IDENTITY this module never learns which citizen it is reopening
26
30
  * MUXRESUME-SURFACE-SEAM both surfaces hand the launcher IN; neither lets the v2
27
31
  * composition import mux
@@ -131,9 +135,16 @@ function main(): void {
131
135
  // whole line would be green only because this fixture has no extensions, and would go red
132
136
  // on the recorded-ACP shape that actually ships.
133
137
  const tmuxPrefix = args.slice(0, args.indexOf("--"));
138
+ const envAssignments = tmuxPrefix.flatMap((token, at) => (token === "-e" ? [tmuxPrefix[at + 1]] : []));
134
139
  ok(
135
- "[QK:MUXRESUME-ARGV-CARRIER-FREE] the TMUX half of the argv is exactly the append shape plus -c — no -n window name, no -e env carrier, no -b; the runtime's own flags live after `--` and are not tmux's business",
136
- !tmuxPrefix.includes("-n") && !tmuxPrefix.includes("-e") && !tmuxPrefix.includes("-b"),
140
+ "[QK:MUXRESUME-ARGV-CARRIER-FREE] the TMUX half of the argv is exactly the append shape plus the fixed scrub and -c — no -n window name, no -b, and every -e only EMPTIES an identity carrier; the runtime's own flags live after `--` and are not tmux's business",
141
+ !tmuxPrefix.includes("-n") &&
142
+ !tmuxPrefix.includes("-b") &&
143
+ envAssignments.every((assignment) => assignment === "PI_SESSION_ID=" || assignment === "PI_AGENT_ID="),
144
+ );
145
+ ok(
146
+ "[QK:MUXRESUME-ARGV-IDENTITY-SCRUB] the TMUX half empties exactly PI_SESSION_ID and PI_AGENT_ID — a tmux server started from a pi shell hands its window a stale identity, and a resumed Claude Code, which never re-exports its own, would present it to its bridge",
147
+ JSON.stringify(envAssignments) === JSON.stringify(["PI_SESSION_ID=", "PI_AGENT_ID="]),
137
148
  );
138
149
  ok(
139
150
  "a cwd that classification refuses can never be built into argv either — the builder re-checks rather than trusting its caller",
@@ -161,19 +172,31 @@ function main(): void {
161
172
  // ── refusals happen BEFORE any mutation ───────────────────────────────────────
162
173
  // No tmux context at all: a bad cwd must still be the reason, because it is decided first
163
174
  // and nothing may reach tmux while it is wrong.
164
- const noTmux = resumeCall({ cwd: path.join(tmp, "gone"), runtimeArgs: [] }, {});
175
+ const noTmux = resumeCall({ backend: "pi", cwd: path.join(tmp, "gone"), runtimeArgs: [] }, {});
165
176
  ok(
166
177
  "an unusable cwd refuses even outside tmux — the cwd question is settled before placement, so no window can exist by the time it is answered",
167
178
  !noTmux.ok && noTmux.reason === "cwd-missing",
168
179
  );
169
- const outsideTmux = resumeCall({ cwd: realDir, runtimeArgs: [] }, { PATH: "/nonexistent" });
180
+ const outsideTmux = resumeCall({ backend: "pi", cwd: realDir, runtimeArgs: [] }, { PATH: "/nonexistent" });
170
181
  ok(
171
182
  "with a good cwd and no runtime on PATH, the refusal is the runtime — preconditions are proven before placement is even read",
172
183
  !outsideTmux.ok && outsideTmux.reason === "runtime-unresolved",
173
184
  );
174
185
 
186
+ // Inside herdr the rail is refused FIRST and by name: a good cwd, a runtime on PATH and no TMUX
187
+ // at all would otherwise reach the placement question and answer something about tmux.
188
+ const insideHerdr = resumeCall(
189
+ { backend: "pi", cwd: realDir, runtimeArgs: [] },
190
+ { HERDR_ENV: "1", PATH: runtimeDir },
191
+ );
192
+ ok(
193
+ "[QK:MUXRESUME-HERDR-REFUSED] inside herdr (HERDR_ENV=1) the tmux resume rail refuses as herdr-resume-unsupported before anything else — an ambient TMUX would otherwise open the window somewhere the operator is not looking",
194
+ !insideHerdr.ok && insideHerdr.reason === "herdr-resume-unsupported",
195
+ );
196
+
175
197
  // ── every reason is answerable ────────────────────────────────────────────────
176
198
  const reasons: ResumeCallRejectReason[] = [
199
+ "herdr-resume-unsupported",
177
200
  "no-tmux-context",
178
201
  "anchor-malformed",
179
202
  "anchor-unresolved",
@@ -193,7 +216,10 @@ function main(): void {
193
216
  "every reject reason has a hint a caller can act on — a reason without one is a reason they will guess about",
194
217
  reasons.every((r) => typeof RESUME_CALL_REJECT_HINT[r] === "string" && RESUME_CALL_REJECT_HINT[r].length > 0),
195
218
  );
196
- ok("the fixed runtime is pi — only a control-socket backend has a same-id resume", RESUME_CALL_RUNTIME === "pi");
219
+ ok(
220
+ "[QK:MUXRESUME-RUNTIME-TABLE] the runtime is a closed per-backend table, pi → pi and claude-code → claude — never a command a caller supplies",
221
+ JSON.stringify(RESUME_CALL_RUNTIME) === JSON.stringify({ pi: "pi", "claude-code": "claude" }),
222
+ );
197
223
 
198
224
  // ── boundaries, asserted on source ────────────────────────────────────────────
199
225
  const SRC = read("pi-extensions/lib/mux-resume-call.ts");
@@ -241,7 +267,7 @@ function main(): void {
241
267
  const MCP_RESUME_BLOCK = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("\n);")[0];
242
268
  ok(
243
269
  "[QK:MUXRESUME-MCP-TARGET-PASSTHROUGH] the bridge handler passes its target straight to visibleResume — a rewritten, defaulted or substituted id would reopen a DIFFERENT citizen than the one the caller named",
244
- /async \(\{ target \}\) =>/.test(MCP_RESUME_BLOCK) &&
270
+ /async \(\{ target \}, extra\) =>/.test(MCP_RESUME_BLOCK) &&
245
271
  /await visibleResume\(target, makeVisibleResumeDeps\(visibleResumeLaunch\)\)/.test(MCP_RESUME_BLOCK),
246
272
  );
247
273
 
@@ -249,7 +275,7 @@ function main(): void {
249
275
  // Not prose parity — three claims an operator acts on, plus the host's own cap.
250
276
  const MCP_DESC = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("{")[0];
251
277
  const statesTheContract = (text: string): boolean =>
252
- /dormant/i.test(text) && /no turn/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
278
+ /dormant/i.test(text) && /submits NO prompt/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
253
279
 
254
280
  ok(
255
281
  "MCP bridge: the resume description fits the host's 2048-char tool-description cap",
@@ -259,7 +285,7 @@ function main(): void {
259
285
  // appear literally exactly once in its gate source, so a token assembled at runtime names a
260
286
  // claim no manifest can bind to.
261
287
  ok(
262
- "[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the three things a caller acts on — DORMANT targets only, no turn is run, and TWO receipts whose observation is the fact that matters",
288
+ "[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the three things a caller acts on — DORMANT targets only, no prompt is submitted, and TWO receipts whose observation is the fact that matters",
263
289
  statesTheContract(MCP_DESC),
264
290
  );
265
291
  } finally {
@@ -73,6 +73,7 @@ function record(gardenId: string, over: Partial<MetaIdentity> = {}): string {
73
73
  transcriptPath: null,
74
74
  createdAt: "2026-06-11T00:00:00.000Z",
75
75
  recordUpdatedAt: "2026-06-11T00:00:00.000Z",
76
+ attachIncarnation: null,
76
77
  ...over,
77
78
  });
78
79
  }
@@ -586,7 +586,7 @@ async function birthCell(
586
586
  report.hostPid === host.pid,
587
587
  );
588
588
  ok(
589
- `[QK:PDC-TOOLS] the registered extension exposes exactly entwurf_self, entwurf_peers, entwurf_v2, entwurf_inbox_read, entwurf_callback, entwurf_fresh_call, in that order (${report.toolNames.join(", ")})`,
589
+ `[QK:PDC-TOOLS] the registered extension exposes exactly entwurf_self, entwurf_peers, entwurf_v2, entwurf_inbox_read, entwurf_callback, entwurf_fresh_call, entwurf_resume_call, entwurf_retire_call, in that order (${report.toolNames.join(", ")})`,
590
590
  JSON.stringify(report.toolNames) ===
591
591
  JSON.stringify([
592
592
  "entwurf_self",
@@ -595,6 +595,8 @@ async function birthCell(
595
595
  "entwurf_inbox_read",
596
596
  "entwurf_callback",
597
597
  "entwurf_fresh_call",
598
+ "entwurf_resume_call",
599
+ "entwurf_retire_call",
598
600
  ]),
599
601
  );
600
602
 
@@ -154,7 +154,7 @@ const NATIVE_CALLS = [
154
154
  { id: "call_pdm_2", name: "bash", arguments: { command: 'printf %s "$MOCK_NATIVE_MARK"' } },
155
155
  { id: "call_pdm_3", name: "entwurf_self", arguments: {} },
156
156
  ];
157
- /** The contact's six tool names, typed here (this gate imports no adapter constant). */
157
+ /** The contact's eight tool names, typed here (this gate imports no adapter constant). */
158
158
  const CONTACT_TOOLS = [
159
159
  "entwurf_self",
160
160
  "entwurf_peers",
@@ -162,6 +162,8 @@ const CONTACT_TOOLS = [
162
162
  "entwurf_inbox_read",
163
163
  "entwurf_callback",
164
164
  "entwurf_fresh_call",
165
+ "entwurf_resume_call",
166
+ "entwurf_retire_call",
165
167
  ];
166
168
  const IMPORT_OBSERVER = `data:text/javascript,${encodeURIComponent(
167
169
  [
@@ -1729,7 +1731,7 @@ function finalWireNative(ctx: NativeContext): void {
1729
1731
  (t) => t.function?.name ?? "",
1730
1732
  );
1731
1733
  ok(
1732
- `[QK:PDM-WIRE-TOOLS-OFFERED] the app's first model request offers the module's tool, the native bash and all six contact tools together (offered: ${offered.join(", ")})`,
1734
+ `[QK:PDM-WIRE-TOOLS-OFFERED] the app's first model request offers the module's tool, the native bash and all eight contact tools together (offered: ${offered.join(", ")})`,
1733
1735
  ["mock_native_echo", "bash", ...CONTACT_TOOLS].every((name) => offered.includes(name)),
1734
1736
  );
1735
1737
  ok(
@@ -59,6 +59,7 @@ const DECLARED = [
59
59
  "entwurf_fresh_call",
60
60
  "entwurf_peers",
61
61
  "entwurf_resume_call",
62
+ "entwurf_retire_call",
62
63
  "entwurf_self",
63
64
  "entwurf_v2",
64
65
  ];