@junghanacs/entwurf 0.17.1 → 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 (74) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +248 -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-mailbox.js +9 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  17. package/mcp/entwurf-bridge/src/index.ts +16 -8
  18. package/package.json +3 -2
  19. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  20. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  21. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  22. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  23. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  24. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  25. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  26. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  27. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  30. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
  32. package/pi-extensions/lib/meta-session.ts +76 -7
  33. package/pi-extensions/meta-bridge-hook.ts +72 -3
  34. package/run.sh +21 -0
  35. package/scripts/check-bridge-delivery.ts +42 -1
  36. package/scripts/check-entwurf-fact-provider.ts +22 -1
  37. package/scripts/check-entwurf-facts.ts +17 -2
  38. package/scripts/check-entwurf-peers-surface.ts +20 -2
  39. package/scripts/check-entwurf-self-address.ts +20 -2
  40. package/scripts/check-entwurf-v2-decider.ts +6 -3
  41. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  42. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  43. package/scripts/check-entwurf-v2-production.ts +72 -1
  44. package/scripts/check-entwurf-v2-runner.ts +18 -0
  45. package/scripts/check-entwurf-v2-send.ts +13 -1
  46. package/scripts/check-entwurf-v2-surface.ts +75 -3
  47. package/scripts/check-gate-qualification.ts +1 -0
  48. package/scripts/check-hook-launch-topology.ts +70 -1
  49. package/scripts/check-mailbox-receipt-state.ts +6 -0
  50. package/scripts/check-meta-doctor-oracle.sh +73 -0
  51. package/scripts/check-meta-hook-session-switch.ts +560 -0
  52. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  53. package/scripts/check-meta-manifest-schema.py +19 -1
  54. package/scripts/check-meta-session.ts +10 -2
  55. package/scripts/meta-bridge-doctor.sh +47 -7
  56. package/scripts/meta-bridge-state.py +23 -5
  57. package/scripts/meta-bridge-statusline.sh +65 -2
  58. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  59. package/scripts/raw-async-delivery/README.md +280 -9
  60. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  61. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  62. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  63. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  64. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  65. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  66. package/scripts/raw-claude-session-switch/README.md +170 -0
  67. package/scripts/smoke-acp-cortex-live.ts +22 -2
  68. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  69. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  70. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  71. package/scripts/smoke-meta-async-drift.sh +31 -8
  72. package/scripts/smoke-meta-install-state.sh +170 -11
  73. package/scripts/smoke-meta-keyset-guard.sh +4 -1
  74. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -19,6 +19,7 @@
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
21
 
22
+ import { basename } from "node:path";
22
23
  import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
23
24
  import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
24
25
  import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
@@ -137,6 +138,12 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
137
138
  `\n ${c.detail}` +
138
139
  (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
139
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
+ }
140
147
  return { text, isError: true };
141
148
  }
142
149
  case "executed": {
@@ -144,8 +151,13 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
144
151
  if (o.transport === "control-socket") {
145
152
  const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
146
153
  const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
154
+ // #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
155
+ // `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
156
+ // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
157
+ // line degrades to the bare outcome rather than printing "undefined".
158
+ const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
147
159
  return {
148
- text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
160
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
149
161
  isError: !delivered,
150
162
  };
151
163
  }
@@ -156,8 +168,17 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
156
168
  isError: false,
157
169
  };
158
170
  }
159
- // meta-mailbox
160
- return { text: "entwurf_v2 meta-mailbox enqueued", isError: false };
171
+ // meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
172
+ // carries a per-message identifier instead of a bare literal. Only the basename —
173
+ // the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
174
+ // typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
175
+ // PREVIOUS message, so printing it would claim a read that has not happened.
176
+ // A dep that omits the receipt falls back to the old literal rather than printing
177
+ // "undefined" — the delivery still happened.
178
+ return {
179
+ text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
180
+ isError: false,
181
+ };
161
182
  }
162
183
  case "execution-failed": {
163
184
  if (result.releaseFailed && result.finalizedOutcome) {
@@ -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}). */
@@ -2532,9 +2576,17 @@ export interface ReadMetaInboxResult {
2532
2576
  * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
2533
2577
  * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
2534
2578
  * `*.read` so a re-read never double-returns, and — only if at least one message
2535
- * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
2536
- * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
2537
- * reading nothing is not a receipt.
2579
+ * was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
2580
+ * stamp-site note below). An empty inbox mutates nothing: reading nothing is not
2581
+ * a receipt.
2582
+ *
2583
+ * WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
2584
+ * is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
2585
+ * rang for it, `.msg.delivered.read` = this function handed it to the reader.
2586
+ * `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
2587
+ * `lastReadAt` says "this citizen last read something at T", never "message X was
2588
+ * read". A sender that quotes `lastReadAt` back as the fate of the letter it just
2589
+ * queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
2538
2590
  */
2539
2591
  export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2540
2592
  const now = opts.now ?? new Date();
@@ -2560,10 +2612,18 @@ export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2560
2612
  return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
2561
2613
  }
2562
2614
 
2563
- // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
2564
- // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
2565
- // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
2566
- // would report read-time as delivery-time, so it is left as the doorbell left it.
2615
+ // 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
2616
+ // now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
2617
+ // reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
2618
+ // corrects WHY: the old comment said "the doorbell owns it", but the shipped
2619
+ // doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
2620
+ // So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
2621
+ // ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
2622
+ // here would still be wrong (it would report read-time as delivery-time), so it
2623
+ // stays null — but do not read "the doorbell will fill it in" into that. The
2624
+ // per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
2625
+ // deliberately: removing it is a migration (the parser rejects unknown keys AND a
2626
+ // bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
2567
2627
  // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
2568
2628
  // Inside the messages.length>0 branch by construction — an empty inbox already
2569
2629
  // early-returned (no .read archive, state untouched), so "read nothing" is no
@@ -2603,6 +2663,15 @@ export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
2603
2663
  * The per-citizen mailbox receipt state. Holds exactly the three delivery
2604
2664
  * timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
2605
2665
  * capability, deliberately absent). Body is SSOT; the on-disk path is derived.
2666
+ *
2667
+ * SCOPE (#98 5a): these are GARDEN-WIDE "last activity" slots, single-valued and
2668
+ * overwritten — NOT per-message receipts. Per message, the receipt is the file
2669
+ * suffix in the same directory (`.msg` → `.msg.delivered` → `.msg.delivered.read`).
2670
+ * `lastEnqueuedAt`/`lastReadAt` are stamped by `enqueueMetaMessage`/`readMetaInbox`.
2671
+ * `lastDeliveredAt` is stamped by NOBODY: the doorbell does not write state.json, so
2672
+ * it is a reserved slot that is null everywhere. Kept rather than removed because the
2673
+ * parser is doubly strict (exact schemaVersion + no unknown keys) and every stamp
2674
+ * re-parses, so dropping it is a migration over the existing on-disk v1 files (#98 5b).
2606
2675
  */
2607
2676
  export interface MailboxReceiptState {
2608
2677
  schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;
@@ -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
 
@@ -158,6 +158,32 @@ function recordingEnqueue(): {
158
158
  ok("6: successful enqueue → {success:true}", res.success === true && res.error === undefined);
159
159
  }
160
160
 
161
+ // ── 6b. #98 R: the enqueue result's messagePath is the SEND receipt ──────────
162
+ // Reported VERBATIM from the enqueue result, never re-derived from the plan — a
163
+ // re-derived path could disagree with the file that was actually written.
164
+ {
165
+ const enq = recordingEnqueue();
166
+ const res = executeMetaMailboxSend(mailboxPlan(), SENDER, { enqueue: enq.fn });
167
+ ok("6b: result carries the enqueue's messagePath verbatim", res.messagePath === `/fake/mailbox/${GID}/m.msg`);
168
+ }
169
+ {
170
+ // The path is taken from the RESULT, not from `plan.mailboxDir` + gardenId: an enqueue
171
+ // that lands somewhere else (env override, tilde expansion, a resolved symlink) must be
172
+ // reported where it actually landed.
173
+ const res = executeMetaMailboxSend(mailboxPlan(), SENDER, {
174
+ enqueue: (opts) => ({
175
+ gardenId: opts.gardenId,
176
+ recordPath: "/elsewhere/rec.json",
177
+ messagePath: "/elsewhere/queued-here.msg",
178
+ signalPath: "/elsewhere/inbox.signal",
179
+ }),
180
+ });
181
+ ok(
182
+ "6b: a messagePath outside the plan's mailboxDir is still reported as-is",
183
+ res.messagePath === "/elsewhere/queued-here.msg",
184
+ );
185
+ }
186
+
161
187
  // ── 7. production adapter: ignores lock entirely (poison LockClaim) ───────────
162
188
  {
163
189
  const enq = recordingEnqueue();
@@ -223,6 +249,13 @@ function recordingEnqueue(): {
223
249
  for (const forbidden of ["releaseLock", "inspectSocket", "probeSocket", "resolveDispatch", "resolveTarget"]) {
224
250
  ok(`9: lib code has no '${forbidden}' (no release / no routing seam)`, !code.includes(forbidden));
225
251
  }
252
+ // #98 R, the negative half stated as an assertion: a SEND receipt may never carry a
253
+ // read/delivery stamp. At enqueue time `lastReadAt` holds the PREVIOUS message's read,
254
+ // so carrying it here would let a sender read "my message was read" off a stamp that
255
+ // says nothing of the kind. The per-message read receipt is the `.read` suffix.
256
+ for (const forbidden of ["lastReadAt", "lastDeliveredAt", "readAt"]) {
257
+ ok(`9: lib code never carries '${forbidden}' into the send receipt`, !code.includes(forbidden));
258
+ }
226
259
  }
227
260
 
228
261
  console.log(`\ncheck-entwurf-v2-mailbox: ${passed} checks passed`);