@junghanacs/entwurf 0.17.2 → 0.18.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 (44) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +127 -0
  3. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  14. package/mcp/entwurf-bridge/src/index.ts +16 -8
  15. package/package.json +3 -2
  16. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  17. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  18. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  19. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  20. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  21. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  22. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  23. package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
  24. package/pi-extensions/lib/meta-session.ts +44 -0
  25. package/pi-extensions/meta-bridge-hook.ts +72 -3
  26. package/run.sh +21 -0
  27. package/scripts/check-bridge-delivery.ts +42 -1
  28. package/scripts/check-entwurf-fact-provider.ts +22 -1
  29. package/scripts/check-entwurf-facts.ts +17 -2
  30. package/scripts/check-entwurf-peers-surface.ts +20 -2
  31. package/scripts/check-entwurf-self-address.ts +20 -2
  32. package/scripts/check-entwurf-v2-decider.ts +6 -3
  33. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  34. package/scripts/check-entwurf-v2-production.ts +72 -1
  35. package/scripts/check-gate-qualification.ts +1 -0
  36. package/scripts/check-meta-hook-session-switch.ts +560 -0
  37. package/scripts/meta-bridge-doctor.sh +29 -4
  38. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  39. package/scripts/raw-claude-session-switch/README.md +170 -0
  40. package/scripts/smoke-acp-cortex-live.ts +22 -2
  41. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  42. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  43. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  44. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -39,7 +39,7 @@ import {
39
39
  import {
40
40
  type MailboxDeliverabilityResult,
41
41
  mailboxConversationalDeliverable,
42
- receiverMarkerMatchesIdentity,
42
+ resolveMailboxReceiverFacts,
43
43
  } from "./entwurf-deliverability.ts";
44
44
  import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.ts";
45
45
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
@@ -75,10 +75,13 @@ import {
75
75
  enqueueMetaMessage,
76
76
  type MetaIdentity,
77
77
  type MetaReceiverMarker,
78
+ type MetaSenderMarker,
78
79
  metaCapabilityFor,
79
80
  metaRecordExistsByGardenId,
80
81
  readAddressableMetaIdentity,
81
82
  readMetaReceiverMarker,
83
+ readMetaSenderMarker,
84
+ requireBackend,
82
85
  } from "./meta-session.ts";
83
86
  import {
84
87
  type NativePushAdapter,
@@ -111,6 +114,10 @@ export interface ProductionEntwurfV2Seams {
111
114
  * SE-2 2d-3 active-receiver source; the factory's `mailboxDeliverabilityFor` closure verifies
112
115
  * its identity match. */
113
116
  readReceiverMarker: (gardenId: string) => MetaReceiverMarker | null;
117
+ /** Read the sender marker keyed to a receiver's owner pid — the "which garden does this
118
+ * process serve NOW?" fact the #101 watch-owner join reads. Null = absent / dead owner /
119
+ * corrupt, which the join treats as a retired watch inside its scope. */
120
+ readSenderMarker: (backend: string, ownerPid: number) => MetaSenderMarker | null;
114
121
  /** Record-side lstat of the EXACT target socket path (no connect) for the pre-probe conflict. */
115
122
  inspectPath: (socketPath: string) => Promise<TargetSocketInspection>;
116
123
  acquireLock: (gid: string, deps: { dir?: string }) => AcquireLockResult;
@@ -183,6 +190,9 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
183
190
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
184
191
  readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
185
192
  readReceiverMarker: s.readReceiverMarker ?? ((gid: string) => readMetaReceiverMarker({ gardenId: gid })),
193
+ readSenderMarker:
194
+ s.readSenderMarker ??
195
+ ((backend: string, ownerPid: number) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
186
196
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
187
197
  acquireLock: s.acquireLock ?? realAcquireLock,
188
198
  releaseLock: s.releaseLock ?? realReleaseLock,
@@ -207,22 +217,32 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
207
217
  });
208
218
 
209
219
  // ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
210
- // receiver (a presence marker that matches THIS identity). The SAME closure is injected
211
- // into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
212
- // send can never drift to different deliverability verdicts. recordBacked is true by
213
- // construction resolveTarget already proved the record exists before any unsupported-
214
- // backend mailbox route, and the closure is only consulted on that route. A null /
215
- // dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
216
- // terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
220
+ // receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
221
+ // direct send and a re-resolved fallback send can never drift to different deliverability
222
+ // verdicts. recordBacked is true by construction resolveTarget already proved the record
223
+ // exists before any unsupported-backend mailbox route, and the closure is only consulted on
224
+ // that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
225
+ // (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
226
+ // garbage.
227
+ //
228
+ // The two receiver facts come from the SHARED composition (#101 결함 B), never from one
229
+ // value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
230
+ // `watchArmed` is the separate measurement that this owner is STILL serving this garden.
231
+ // They were the same expression until a Claude session switch inside one pid left a retired
232
+ // garden's marker reading as an armed doorbell and a real message rotted unread in its
233
+ // mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
234
+ // never disagree with what dispatch decided about it. ──────────
217
235
  const mailboxDeliverabilityFor = (identity: MetaIdentity): MailboxDeliverabilityResult => {
218
236
  const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
219
- const marker = io.readReceiverMarker(identity.gardenId);
220
- const matched = receiverMarkerMatchesIdentity(marker, identity);
237
+ const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
238
+ readReceiverMarker: io.readReceiverMarker,
239
+ readSenderMarker: io.readSenderMarker,
240
+ });
221
241
  return mailboxConversationalDeliverable({
222
242
  wakeMode,
223
243
  recordBacked: true,
224
- ownerAlive: matched,
225
- watchArmed: matched,
244
+ ownerAlive,
245
+ watchArmed,
226
246
  });
227
247
  };
228
248
 
@@ -138,6 +138,12 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
138
138
  `\n ${c.detail}` +
139
139
  (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
140
140
  }
141
+ // #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
142
+ // caller looking for a dead session when the session was alive and had simply
143
+ // switched to another garden — the predicate knew that and the surface threw it away.
144
+ if (result.diagnostic?.kind === "mailbox-undeliverable") {
145
+ text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
146
+ }
141
147
  return { text, isError: true };
142
148
  }
143
149
  case "executed": {
@@ -2017,6 +2017,50 @@ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): Met
2017
2017
  }
2018
2018
  }
2019
2019
 
2020
+ export interface RemoveMetaReceiverMarkerOptions {
2021
+ gardenId: string;
2022
+ /** Only remove a marker this pid owns — a retirement must never reach another process's watch. */
2023
+ ownerPid: number;
2024
+ receiversDir?: string;
2025
+ }
2026
+
2027
+ /**
2028
+ * Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
2029
+ *
2030
+ * A native process that switches sessions in place — an in-session `/resume` or `/clear`
2031
+ * fires a second SessionStart inside the same pid under a new native session id — leaves
2032
+ * the previous garden's marker behind, naming a live owner whose watch is gone. Measured
2033
+ * on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
2034
+ * mail sent to the retired one sat unread.
2035
+ *
2036
+ * ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
2037
+ * outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
2038
+ * A retired citizen keeps its record, its transcript and its listing — it loses only
2039
+ * the claim that a doorbell is armed for it, which is exactly the claim that stopped
2040
+ * being true.
2041
+ *
2042
+ * Fail-closed the other way too: the marker is read back first and removed ONLY when its
2043
+ * `ownerPid` field equals the one passed here — pid equality, which is what a caller can
2044
+ * actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
2045
+ * and the reader-side start-key guard is where that finer distinction lives. It is enough for
2046
+ * the rule this serves: a hook never retires a watch some other pid is holding. Returns whether a marker was actually removed. Never throws — a retirement
2047
+ * that cannot happen is a log line, not a broken session start.
2048
+ */
2049
+ export function removeMetaReceiverMarker(opts: RemoveMetaReceiverMarkerOptions): boolean {
2050
+ try {
2051
+ const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
2052
+ const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
2053
+ // verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
2054
+ // a marker whose owner already died is still this pid's to clean up when it names it.
2055
+ const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
2056
+ if (!marker || marker.ownerPid !== opts.ownerPid) return false;
2057
+ fs.unlinkSync(file);
2058
+ return true;
2059
+ } catch {
2060
+ return false;
2061
+ }
2062
+ }
2063
+
2020
2064
  export interface UpsertMetaSessionOptions {
2021
2065
  input: MetaIdentityMintInput;
2022
2066
  /** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
@@ -28,6 +28,20 @@
28
28
  * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
29
  * re-arm the idle watch — the record's address is restored, the wake is not.
30
30
  *
31
+ * SESSION SWITCH (#101). One Claude process serves one session at a time, but it can
32
+ * change which. Measured on oracle 2026-09-04 (raw lab S1-S6, Claude Code 2.1.260): a bare
33
+ * `claude` mints a NEW session (`source=startup`), and an in-session `/resume` or `/clear`
34
+ * then fires a SECOND SessionStart under the SAME pid for a DIFFERENT native id
35
+ * (`source=resume` / `source=clear`). The first garden is left behind — in the #101 field
36
+ * case its transcript was never written at all — while its receiver marker still names a
37
+ * LIVE owner, so a sender reads an armed doorbell nobody holds. This hook therefore reads
38
+ * the sender marker BEFORE overwriting it and retires the previous garden's receiver marker
39
+ * (marker only — records are identity and are never deleted here; and only a marker this
40
+ * pid owns). Compaction (`source=compact`) re-fires SessionStart for the SAME native id, so
41
+ * the same rule retires nothing there — measured, not assumed. The envelope's `source` is
42
+ * logged beside all of it and decides nothing: the switch is settled by what is on disk,
43
+ * which holds on every host and vendor version.
44
+ *
31
45
  * LAUNCH: never invoked directly by Claude. `hooks.json` declares the EXEC form
32
46
  * (`command` = `<plugin-root>/scripts/hook-launch.sh`, `args` = [node, this file]),
33
47
  * and the launcher `exec`s that argv — so this process inherits the launcher's pid
@@ -51,6 +65,8 @@ import {
51
65
  defaultMetaSessionsDir,
52
66
  isPlausibleOwnerPid,
53
67
  type MetaReceiverArmProvenance,
68
+ readMetaSenderMarker,
69
+ removeMetaReceiverMarker,
54
70
  upsertMetaSession,
55
71
  writeMetaReceiverMarker,
56
72
  writeMetaSenderMarker,
@@ -191,6 +207,13 @@ function main(): void {
191
207
  ? env.model_id
192
208
  : undefined;
193
209
  const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
210
+ // `source` (startup | resume | clear | compact) is Claude's own word for WHY this
211
+ // SessionStart fired. It is logged on every line below and decides nothing: a session
212
+ // switch is settled by what is on disk (the sender marker's garden), which is true on
213
+ // every host and every vendor version. Logging it is how this host finally gets a
214
+ // receipt for the envelope order the #101 diagnosis could only read from vendor docs —
215
+ // and the raw lab (scripts/raw-claude-session-switch) reads these lines, not a guess.
216
+ const source = typeof env.source === "string" && env.source.length > 0 ? env.source : "(unset)";
194
217
 
195
218
  if (!sessionId || !transcriptPath) {
196
219
  // A degraded envelope: cannot mint an honest reference record. Log + no-op
@@ -213,7 +236,10 @@ function main(): void {
213
236
  input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
214
237
  });
215
238
  gardenId = result.record.gardenId;
216
- logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
239
+ logLine(
240
+ "INFO",
241
+ `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`,
242
+ );
217
243
  } catch (err) {
218
244
  // Best-effort: a broken record store must surface via the doctor, not by
219
245
  // breaking the user's session open. Log and continue with no arm. This is
@@ -241,9 +267,52 @@ function main(): void {
241
267
  // an env var. Missing launcher provenance or an implausible parent yields no marker.
242
268
  const ownerPid = resolveMetaHookOwnerPid();
243
269
  if (ownerPid !== null) {
270
+ // SESSION SWITCH RETIREMENT (#101 결함 A). One Claude process serves ONE session at a
271
+ // time, but it can switch which: an in-session `/resume` or `/clear` fires a second
272
+ // SessionStart under the same pid for a different native id, leaving the session the
273
+ // process started with behind (measured on oracle, meta-bridge-hook.log 2026-09-04
274
+ // 13:13:04 `source=startup` → 13:13:37 `source=resume`; the field case at 09:31:35 →
275
+ // 09:31:39 is the same shape). Whatever it was serving before is no longer being
276
+ // drained, so the marker advertising its doorbell has to go.
277
+ //
278
+ // The evidence is the sender marker as it stands RIGHT NOW — pid → the garden this
279
+ // process serves — which is why this reads it BEFORE the write below overwrites it with
280
+ // the new garden. No vendor field is consulted: `source` is logged, not branched on, so
281
+ // a host or version that words it differently changes nothing here. A same-garden
282
+ // re-registration (every UserPromptSubmit, a CwdChanged, a re-fired SessionStart) finds
283
+ // an equal garden id and retires NOTHING — the marker it would remove is the live one.
284
+ //
285
+ // Only the marker, never the record (see removeMetaReceiverMarker), and only a marker
286
+ // this pid owns.
287
+ //
288
+ // AND ONLY ON AN EVENT THAT CAN ARM THE REPLACEMENT (cross-review, 2026-09-04). This
289
+ // block sits before the UserPromptSubmit early-return, and UPS cannot emit watchPaths —
290
+ // so a retirement reached from there would take a doorbell down with nothing in the same
291
+ // run able to put one back. A watch is retired only by a run that arms one; that is the
292
+ // whole rule. `armProvenanceFor` is the same predicate the arm block below uses, so the
293
+ // two can never disagree about which events those are.
294
+ //
295
+ // It is NOT a defence against a keystroke from a session this pid has left: measured on
296
+ // oracle 2026-09-04 (raw lab S1-S6, meta-bridge-hook.log), every UserPromptSubmit named
297
+ // the native id its own pid's preceding SessionStart had established — 8 of 8, none
298
+ // otherwise. The hook runs synchronously inside the session's own process, so a UPS
299
+ // envelope is that session speaking, and the sender pointer it moves is authoritative.
300
+ const previous =
301
+ armProvenanceFor(eventName) !== null
302
+ ? readMetaSenderMarker({ backend: "claude-code", ownerPid, verifyOwner: false })
303
+ : null;
304
+ if (previous && previous.gardenId !== gardenId) {
305
+ const retired = removeMetaReceiverMarker({ gardenId: previous.gardenId, ownerPid });
306
+ logLine(
307
+ "INFO",
308
+ retired
309
+ ? `retired receiver marker ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`
310
+ : `no receiver marker to retire for ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`,
311
+ );
312
+ }
244
313
  try {
245
314
  writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
246
- logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
315
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName}, source=${source})`);
247
316
  } catch (err) {
248
317
  logLine(
249
318
  "WARN",
@@ -294,7 +363,7 @@ function main(): void {
294
363
  ownerPid,
295
364
  armProvenance,
296
365
  });
297
- logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
366
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
298
367
  } catch (err) {
299
368
  logLine(
300
369
  "WARN",
package/run.sh CHANGED
@@ -144,6 +144,7 @@ Usage:
144
144
  ./run.sh check-meta-mailbox-state-write # deterministic gate (0.11 Stage 0 step 3D-4 commit2): post-cut receipt is state-only — meta-record file byte-identical across enqueue/read, state carries lastEnqueuedAt/lastReadAt (field isolation), empty inbox no-op on record+state, drift surfaces; no API
145
145
  ./run.sh check-meta-receiver-marker # deterministic gate (SE-2): receiver marker round-trip/start-key/provenance, UserPromptSubmit cannot mint presence, reader does not gate on record existence — marker SEMANTICS only; launch topology moved to check-hook-launch-topology
146
146
  ./run.sh check-hook-launch-topology # #51 gate 1: shipped hooks.json is exec form through hook-launch.sh, launcher is loud on an empty argv (older Claude's silent args drop), exec preserves the pid so the hook's parent is Claude, and a space/$/backtick plugin path survives as one argv element
147
+ ./run.sh check-meta-hook-session-switch # #101 gate: ONE Claude pid, TWO SessionStarts (resume picker placeholder -> resumed id). The hook retires the marker of the garden it stopped serving (records untouched, same-garden re-registration retires nothing), the reader stays fail-closed on a re-planted stale marker (watchArmed measured against the owner's sender marker, never copied), production dispatch refuses the retired garden and NAMES the failing axis, and exactly one of the two gardens is deliverable. Sandboxed roots, no API
147
148
  ./run.sh check-meta-identity-consumers # deterministic gate: V3-only consumer seam — per-entry targeted read + addressable read snapshot uniqueness, non-regular rivals never read, drift/unparseable rivals unreachable, unreadable regular rivals fail loud; strict upsert refuses an unreadable store before any write, no API
148
149
  ./run.sh check-meta-capability-source # deterministic gate (0.11 Stage 0 step 3D-3): capability-source cut-over — mint/parse read wakeMode/deliveryLevel from the registry (metaCapabilityFor, registry-driven via injection), not META_BACKEND_DESCRIPTORS; behaviour-preserving (registry ≡ const); the record.delivery slot 3D-3 preserved was deleted by 3D-4, no API
149
150
  ./run.sh check-socket-probe # deterministic gate (0.11 Stage 0, F3): three-valued control-socket liveness (alive|dead|indeterminate) — GC reclaims dead only, indeterminate survives; pure classify + 2-socket integration, no API
@@ -894,6 +895,23 @@ check_meta_receiver_marker() {
894
895
  run_ts scripts/check-meta-receiver-marker.ts
895
896
  }
896
897
 
898
+ check_meta_hook_session_switch() {
899
+ # #101 gate: a Claude Code SESSION SWITCH — one native process that stops serving one
900
+ # garden and starts serving another. The resume picker (and `/clear`) fire SessionStart
901
+ # twice inside one pid: once for the TUI's placeholder id, once for the id the operator
902
+ # picked. Before this gate nothing asked "one owner pid, two gardens", so a placeholder
903
+ # citizen stayed registered with an armed-looking doorbell and real mail rotted in it.
904
+ # Plays Claude the way check-hook-launch-topology does (spawn the SHIPPED launcher with
905
+ # the manifest's argv, so the hook's parent is this process = one fake owner pid) with
906
+ # every meta root sandboxed. Asserts: both records survive (marker retired, never the
907
+ # record); the retired garden's marker is gone; a same-garden re-registration retires
908
+ # NOTHING; a re-planted stale marker is still undeliverable (the sender-marker join,
909
+ # fail-closed at the reader); production dispatch refuses it and carries the predicate's
910
+ # reason to the rendered surface; exactly one garden is deliverable and it is the served
911
+ # one. Real tmpdir, no API.
912
+ run_ts scripts/check-meta-hook-session-switch.ts
913
+ }
914
+
897
915
  check_copilot_birth_hook() {
898
916
  # #82 gate: the Copilot BIRTH path, proven without Copilot. Drives the REAL
899
917
  # assembler (--assemble-only, into a temp dir), then fires the baked launcher the
@@ -5700,6 +5718,9 @@ case "$cmd" in
5700
5718
  check-meta-receiver-marker)
5701
5719
  check_meta_receiver_marker
5702
5720
  ;;
5721
+ check-meta-hook-session-switch)
5722
+ check_meta_hook_session_switch
5723
+ ;;
5703
5724
  check-meta-identity-consumers)
5704
5725
  check_meta_identity_consumers
5705
5726
  ;;
@@ -181,16 +181,40 @@ writeMetaReceiverMarker({
181
181
 
182
182
  // RECEIVER — a different armed self-fetch citizen. A dead/unarmed target must remain
183
183
  // mailbox-undeliverable, so this marker isolates artifact delivery from target liveness.
184
+ //
185
+ // It is owned by a DIFFERENT live pid than the sender, and that is not decoration (#101):
186
+ // a claude-code watch owner serves exactly one garden at a time, and deliverability now
187
+ // requires that owner's sender marker to still name the garden being addressed. Two
188
+ // citizens armed under ONE pid is a state the real world does not produce — it is what a
189
+ // session switch leaves behind — so a fixture that modelled it would be asking the
190
+ // artifact to deliver into a retired watch.
191
+ //
192
+ // The stand-in second native CLI is a spawned idle process, NOT this gate's parent: the
193
+ // subject bridge resolves its own sender identity through its ancestry, and an ancestor
194
+ // holding a second garden's marker is the "ambiguous sender identity" refusal the bridge
195
+ // already ships. A live pid outside that ancestry is what a real second CLI looks like.
196
+ // It exits on its own timer, and is killed in the outer finally.
197
+ const receiverOwner = spawn(process.execPath, ["-e", "setTimeout(() => {}, 300000)"], { stdio: "ignore" });
198
+ const receiverOwnerPid = receiverOwner.pid;
199
+ if (typeof receiverOwnerPid !== "number") throw new Error("could not spawn the stand-in receiver owner process");
184
200
  const receiver = upsertMetaSession({
185
201
  input: { backend: "claude-code", nativeSessionId: `bridge-delivery-receiver-${process.pid}`, cwd: tmp },
186
202
  dir: sessionsDir,
187
203
  });
188
204
  const gid = receiver.record.gardenId;
205
+ writeMetaSenderMarker({
206
+ backend: "claude-code",
207
+ gardenId: gid,
208
+ nativeSessionId: receiver.record.nativeSessionId,
209
+ cwd: tmp,
210
+ ownerPid: receiverOwnerPid,
211
+ sendersDir,
212
+ });
189
213
  writeMetaReceiverMarker({
190
214
  gardenId: gid,
191
215
  backend: "claude-code",
192
216
  nativeSessionId: receiver.record.nativeSessionId,
193
- ownerPid: process.pid,
217
+ ownerPid: receiverOwnerPid,
194
218
  armProvenance: "session-start",
195
219
  receiversDir,
196
220
  });
@@ -471,6 +495,20 @@ try {
471
495
  await fsp.writeFile(receiverRecordFile, receiverRecordV3Bytes);
472
496
  const anonSendersDir = path.join(tmp, "meta-senders-anon");
473
497
  await fsp.mkdir(anonSendersDir, { recursive: true });
498
+ // The TARGET's owner marker still belongs in this dir (#101). Anonymity is about the
499
+ // CALLER — no marker under the bridge child's own owner pid — while deliverability asks
500
+ // a different question of a different pid: is the target's watch owner still serving the
501
+ // target's garden? Leaving it out would make every cell below fail as
502
+ // mailbox-undeliverable and prove nothing about the anonymous hatch. In production no
503
+ // one swaps this dir; the split exists only because this fixture does.
504
+ writeMetaSenderMarker({
505
+ backend: "claude-code",
506
+ gardenId: gid,
507
+ nativeSessionId: receiver.record.nativeSessionId,
508
+ cwd: tmp,
509
+ ownerPid: receiverOwnerPid,
510
+ sendersDir: anonSendersDir,
511
+ });
474
512
  const oneShotV2 = async (
475
513
  cellEnv: NodeJS.ProcessEnv,
476
514
  message: string,
@@ -582,6 +620,9 @@ try {
582
620
  try {
583
621
  child?.kill("SIGTERM");
584
622
  } catch {}
623
+ try {
624
+ receiverOwner.kill("SIGTERM");
625
+ } catch {}
585
626
  await fsp.rm(tmp, { recursive: true, force: true });
586
627
  }
587
628
 
@@ -19,7 +19,12 @@
19
19
  * socket-dir-read-error diagnostic (P2e②),
20
20
  * - diagnostics are kind-tagged and sorted.
21
21
  *
22
- * No IO — meta entries/reader and socket dir/readdir/probe are injected fakes.
22
+ * No IO — meta entries/reader and socket dir/readdir/probe are injected fakes, and so is
23
+ * the #101 `observe` seam. That seam is the one dep whose PRODUCTION default is real IO
24
+ * (one default is safer than two wiring sites each passing their own observer), so a gate
25
+ * that left it out would quietly stat the operator's real garden roots for fabricated
26
+ * garden ids. It is injected here for that reason, and the cell below proves the injected
27
+ * observation actually reaches the fact rather than being dropped on the way.
23
28
  */
24
29
 
25
30
  import assert from "node:assert/strict";
@@ -74,6 +79,11 @@ function deps(
74
79
  ): EntwurfFactsDeps {
75
80
  const symlinkSet = new Set(opts.symlinks ?? []);
76
81
  return {
82
+ // Injected observation (see the header): a per-identity fake, never the real IO.
83
+ observe: (identity) => ({
84
+ receiver: identity.backend === "claude-code" ? "inactive" : "n/a",
85
+ transcript: identity.gardenId === GID_CLAUDE ? "absent" : "exists",
86
+ }),
77
87
  // Kind-carrying entries, like the real bindings: the listing must be able to refuse a
78
88
  // symlinked record without following it. `irregularMeta` names the ones that are not
79
89
  // regular files.
@@ -144,6 +154,17 @@ async function main(): Promise<void> {
144
154
  );
145
155
  ok("basic: record-less-socket diagnostic keyset exact", true);
146
156
  }
157
+ // #101: the injected observation rides onto each peer. A provider that dropped the
158
+ // seam would return `unobserved` rows — visible, but useless to the caller the
159
+ // columns exist for.
160
+ {
161
+ const claude = r.facts.peers.find((p) => p.gardenId === GID_CLAUDE);
162
+ const pi = r.facts.peers.find((p) => p.gardenId === GID_PI);
163
+ ok(
164
+ "basic: the injected observation reaches every peer fact",
165
+ claude?.receiver === "inactive" && claude?.transcript === "absent" && pi?.receiver === "n/a",
166
+ );
167
+ }
147
168
  // A DEAD record-less socket groups under a different (stale) message — no fresh-cut
148
169
  // pointer for a leftover file, so same-state sockets aggregate per liveness.
149
170
  const r2 = await listEntwurfFacts(deps({}, { [GID_SOCKET_ONLY]: "dead" }));
@@ -138,7 +138,8 @@ for (const backend of META_CITIZEN_BACKENDS) {
138
138
  ok("passthrough recordUpdatedAt", fact.recordUpdatedAt === id.recordUpdatedAt);
139
139
  }
140
140
 
141
- // ── facts-only keyset: identity facts + liveness, NO verb-routing/transcript
141
+ // ── facts-only keyset: identity facts + liveness + the two observed axes, and
142
+ // still NO verb-routing / no transcript PATH ────────────────────────────────
142
143
  {
143
144
  const fact = resolvePeerFact(identity("pi"), "alive");
144
145
  const keys = Object.keys(fact).sort();
@@ -151,9 +152,23 @@ for (const backend of META_CITIZEN_BACKENDS) {
151
152
  "model",
152
153
  "nativeSessionId",
153
154
  "recordUpdatedAt",
155
+ "receiver",
156
+ "transcript",
154
157
  ].sort();
155
158
  assert.deepStrictEqual(keys, expected, `PeerFact keyset drift: got ${keys.join(",")}`);
156
- ok("facts-only keyset exact (identity facts + liveness)", true);
159
+ ok("facts-only keyset exact (identity facts + liveness + observed receiver/transcript)", true);
160
+
161
+ // #101: an unmeasured row says so. `unobserved` is the default for a caller that
162
+ // injected no observer — never `none`/`absent`, which would be a fabricated fact.
163
+ ok(
164
+ "an observer-less composition reports both axes as unobserved (never a default fact)",
165
+ fact.receiver === "unobserved" && fact.transcript === "unobserved",
166
+ );
167
+ const observed = resolvePeerFact(identity("claude-code"), null, { receiver: "inactive", transcript: "absent" });
168
+ ok(
169
+ "an injected observation rides onto the fact verbatim",
170
+ observed.receiver === "inactive" && observed.transcript === "absent",
171
+ );
157
172
 
158
173
  const FORBIDDEN = [
159
174
  "resumable",
@@ -47,7 +47,12 @@ const FORBIDDEN = ["sendable", "resumable", "dispatch", "action", "transport", "
47
47
  // #50 C4: socket-shaped identity must not reappear on this surface.
48
48
  const FORBIDDEN_C4_KEYS = ["sessions", "socketOnly", "controlDir", "socketPath", "count"];
49
49
 
50
- function peer(gardenId: string, backend: MetaCitizenBackend, liveness: FactLiveness): PeerFact {
50
+ function peer(
51
+ gardenId: string,
52
+ backend: MetaCitizenBackend,
53
+ liveness: FactLiveness,
54
+ observed: Partial<Pick<PeerFact, "receiver" | "transcript">> = {},
55
+ ): PeerFact {
51
56
  return {
52
57
  gardenId,
53
58
  backend,
@@ -57,6 +62,8 @@ function peer(gardenId: string, backend: MetaCitizenBackend, liveness: FactLiven
57
62
  createdAt: "2026-06-11T00:00:00.000Z",
58
63
  recordUpdatedAt: "2026-06-11T00:00:00.000Z",
59
64
  liveness,
65
+ receiver: observed.receiver ?? "n/a",
66
+ transcript: observed.transcript ?? "exists",
60
67
  };
61
68
  }
62
69
 
@@ -91,7 +98,7 @@ function main(): void {
91
98
  peer(GID_PI_ALIVE, "pi", "alive"),
92
99
  peer(GID_PI_DEAD, "pi", "dead"),
93
100
  peer(GID_PI_INDET, "pi", "indeterminate"),
94
- peer(GID_CLAUDE, "claude-code", "unsupported"),
101
+ peer(GID_CLAUDE, "claude-code", "unsupported", { receiver: "inactive", transcript: "absent" }),
95
102
  ],
96
103
  // The provider has already folded these into diagnostics; the render layer
97
104
  // never re-renders them as a section (facts carry them for the union math).
@@ -116,6 +123,17 @@ function main(): void {
116
123
  ok("C4: payload is exactly { peers, diagnostics }", true);
117
124
  }
118
125
  ok("peers carry all 4 citizens (unsupported NOT dropped)", payload.peers.length === 4);
126
+ // #101: the two observed axes are the only thing separating two same-cwd claude rows,
127
+ // since `liveness` reads `unsupported` for every one of them. They are FACTS on the
128
+ // row — the forbidden-word scans below still apply to them unchanged.
129
+ ok(
130
+ "observed receiver + transcript ride the row in text",
131
+ new RegExp(`${GID_CLAUDE}.*receiver=inactive.*transcript=absent`).test(text),
132
+ );
133
+ ok(
134
+ "observed axes ride the JSON payload too",
135
+ payload.peers.some((p) => p.gardenId === GID_CLAUDE && p.receiver === "inactive" && p.transcript === "absent"),
136
+ );
119
137
  ok(
120
138
  "unsupported citizen present in peers",
121
139
  payload.peers.some((p) => p.gardenId === GID_CLAUDE && p.liveness === "unsupported"),
@@ -469,8 +469,26 @@ const metaBody = functionBody("buildTrustedMetaSenderEnvelope");
469
469
  ok("buildTrustedMetaSenderEnvelope calls computeSelfAddressability", /computeSelfAddressability\s*\(/.test(metaBody));
470
470
  ok("buildTrustedMetaSenderEnvelope no longer hardcodes `replyable: true`", !/replyable:\s*true/.test(metaBody));
471
471
  ok(
472
- "buildTrustedMetaSenderEnvelope derives active-receiver from the receiver marker (identity-matched)",
473
- /readMetaReceiverMarker\s*\(/.test(metaBody) && /receiverMarkerMatchesIdentity\s*\(/.test(metaBody),
472
+ // #101: the identity match alone is no longer the whole answer. `entwurf_self` composes
473
+ // the SAME `resolveMailboxReceiverFacts` the v2 dispatch seam uses — reading the receiver
474
+ // marker AND, where the watch owner is the sender-marker process, the join that says that
475
+ // owner is still serving this garden. A citizen's self-reported replyability and what
476
+ // dispatch decides about it come from one measurement, so they cannot disagree.
477
+ "buildTrustedMetaSenderEnvelope derives active-receiver from the SHARED receiver composition (both markers)",
478
+ /resolveMailboxReceiverFacts\s*\(/.test(metaBody) &&
479
+ /readMetaReceiverMarker\s*\(/.test(metaBody) &&
480
+ /readMetaSenderMarker\s*\(/.test(metaBody),
481
+ );
482
+ ok(
483
+ "buildTrustedMetaSenderEnvelope no longer copies one match into both receiver facts",
484
+ !/ownerAlive:\s*active/.test(metaBody) && !/watchArmed:\s*active/.test(metaBody),
485
+ );
486
+ ok(
487
+ // #101 (cross-review): both markers are read with their liveness guards ON. A reader that
488
+ // opted out would accept a dead session's leftover file as "which garden this pid serves
489
+ // now", and every fixture pid in a gate is live, so nothing dynamic here could tell.
490
+ "buildTrustedMetaSenderEnvelope reads neither marker with the owner guard disabled",
491
+ !/verifyOwner:\s*false/.test(metaBody),
474
492
  );
475
493
  ok(
476
494
  "buildTrustedMetaSenderEnvelope keeps meta identity + derived replyable (inactive → not null)",
@@ -207,11 +207,13 @@ async function main(): Promise<void> {
207
207
  ok("target-locked: diagnostic carried", d.kind === "reject" && d.diagnostic?.kind === "target-locked");
208
208
  ok(
209
209
  "target-locked: diagnostic holder pid preserved",
210
- d.kind === "reject" && d.diagnostic?.conflict.holder?.pid === 4242,
210
+ d.kind === "reject" && d.diagnostic?.kind === "target-locked" && d.diagnostic.conflict.holder?.pid === 4242,
211
211
  );
212
212
  ok(
213
213
  "target-locked: diagnostic lockPath preserved",
214
- d.kind === "reject" && d.diagnostic?.conflict.lockPath === `/fake/locks/${GID}.lock`,
214
+ d.kind === "reject" &&
215
+ d.diagnostic?.kind === "target-locked" &&
216
+ d.diagnostic.conflict.lockPath === `/fake/locks/${GID}.lock`,
215
217
  );
216
218
  }
217
219
 
@@ -253,7 +255,8 @@ async function main(): Promise<void> {
253
255
  ok(
254
256
  "target-locked(corrupt): diagnostic detail preserved",
255
257
  d.kind === "reject" &&
256
- d.diagnostic?.conflict.detail === "lockfile is empty, corrupt, or holds a different garden id",
258
+ d.diagnostic?.kind === "target-locked" &&
259
+ d.diagnostic.conflict.detail === "lockfile is empty, corrupt, or holds a different garden id",
257
260
  );
258
261
  }
259
262
 
@@ -42,6 +42,7 @@ import {
42
42
  type DispatchDecision,
43
43
  decideDispatch,
44
44
  type ExecutionPlan,
45
+ type RejectDiagnostic,
45
46
  type TargetResolution,
46
47
  } from "../pi-extensions/lib/entwurf-v2-decider.ts";
47
48
  import type { AcquireLockResult, LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
@@ -156,7 +157,10 @@ type LockClass = "none" | "held" | "mailbox-null" | "released" | "acquire-fail";
156
157
 
157
158
  type Expect =
158
159
  | { decision: "execute"; transport: EntwurfV2Transport; lock: LockClass }
159
- | { decision: "reject"; reason: string; lock: LockClass; diagnostic?: boolean };
160
+ // `diagnostic` names the KIND a reject must carry, not a boolean: two reject cells now
161
+ // carry machine-readable evidence, and the axis has to say which — a reject that carried
162
+ // the wrong diagnostic would pass a yes/no cell (#101 갭 C).
163
+ | { decision: "reject"; reason: string; lock: LockClass; diagnostic?: RejectDiagnostic["kind"] };
160
164
 
161
165
  /**
162
166
  * Which IO seams the decider is ALLOWED to touch — the "어느 축을 만지면 안 되는지"
@@ -211,7 +215,7 @@ const ROWS: Row[] = [
211
215
  targetKind: "pi, lock held by another",
212
216
  intent: "fire-and-forget",
213
217
  scenario: { resolution: { identity: identity("pi"), preProbeAddressConflict: false }, lock: "conflict" },
214
- expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic: true },
218
+ expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic: "target-locked" },
215
219
  },
216
220
  {
217
221
  name: "unsupported self-fetch active → meta-mailbox",
@@ -231,7 +235,8 @@ const ROWS: Row[] = [
231
235
  resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
232
236
  mailboxDeliverable: false,
233
237
  },
234
- expect: { decision: "reject", reason: "mailbox-undeliverable", lock: "none" },
238
+ // #101 갭 C: an undeliverable mailbox reject carries the receiver axis that failed.
239
+ expect: { decision: "reject", reason: "mailbox-undeliverable", lock: "none", diagnostic: "mailbox-undeliverable" },
235
240
  },
236
241
  {
237
242
  name: "in-domain live ff → control-socket",
@@ -338,8 +343,8 @@ async function runRow(row: Row): Promise<void> {
338
343
  }
339
344
  if (row.expect.diagnostic) {
340
345
  ok(
341
- `${row.name}: carries target-locked diagnostic`,
342
- d.kind === "reject" && d.diagnostic?.kind === "target-locked",
346
+ `${row.name}: carries the ${row.expect.diagnostic} diagnostic`,
347
+ d.kind === "reject" && d.diagnostic?.kind === row.expect.diagnostic,
343
348
  );
344
349
  } else {
345
350
  ok(`${row.name}: no diagnostic`, d.kind === "reject" && d.diagnostic === undefined);