@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
package/AGENTS.md CHANGED
@@ -90,7 +90,7 @@ A `--entwurf-control` pi session is a citizen for the same reason a native bridg
90
90
 
91
91
  - **control-socket domain (currently `pi`)**: socket liveness, per-target lock, live send, and a dormant cell that rejects.
92
92
  - **No relaunch transport exists inside delivery.** `spawn-bg` — a detached, window-less resume child — was removed under the visible-first rule, not deprecated behind a reject, and `entwurf_v2` still starts no process: a dormant socket-domain citizen is refused as `dormant-fire-forget-unsupported`. Reopening one is the separate lifecycle verb `entwurf_resume_call`, which is pi-only (`target-not-pi` otherwise), returns a LAUNCH receipt and an OBSERVATION receipt that are never merged, releases its per-gid lock on every path, and on an unobserved socket leaves the visible window open rather than retrying. No watcher, no retry, no supervisor. Do not re-route it through `entwurf_v2` and do not describe a spawn domain that does not exist.
93
- - **self-fetch domain (Claude Code, Copilot CLI)**: active receiver + mailbox deliverability; no resume authority. The two arm that receiver through different vendor surfaces — a Claude hook that emits watchPaths, a Copilot first-party extension that holds the watch in a forked child — and the marker records which, because the pid a reader verifies differs.
93
+ - **self-fetch domain (Claude Code, Copilot CLI)**: active receiver + mailbox deliverability; no resume authority. The two arm that receiver through different vendor surfaces — a Claude hook that emits watchPaths, a Copilot first-party extension that holds the watch in a forked child — and the marker records which, because the pid a reader verifies differs. A live marker is not by itself an armed doorbell: where the watch owner IS the process the sender marker is keyed to (`ownerKind: claude-code-cli`), that owner may switch sessions in place, so deliverability also requires its sender marker to still name the same garden. That join is `ownerKind`-scoped by construction — a Copilot watch lives in a forked child with its own pid, so the join does not exist there and must never be applied to it (#101).
94
94
  - **native-push domain (currently Antigravity)**: adapter probe + direct injection; no mailbox and no resume authority.
95
95
  - `origin: "pi-session" | "meta-session" | "external-mcp"` records sender provenance. It is not the citizen identity schema and not a hierarchy.
96
96
  - `entwurf_peers` reports record citizens and liveness facts only. It never embeds routing verbs or socket addresses for peers.
@@ -172,6 +172,7 @@ implement → affected focused gates → independent review → one amendment bu
172
172
  | `pi-extensions/lib/entwurf-v2-*.ts` | v2 contract, decider, transports, runner, production wiring; visible resume keeps launch injected |
173
173
  | `pi-extensions/lib/mux-*.ts` | same-tmux placement plus narrow fresh-call and visible-resume launch compositions |
174
174
  | `pi-extensions/lib/entwurf-fact*.ts` | record citizens + transport-specific liveness facts |
175
+ | `pi-extensions/lib/entwurf-peer-observe.ts` | the IO half of the observed peer facts (receiver / transcript) |
175
176
  | `pi-extensions/lib/native-push/` | native-push adapter/probe/register leaf |
176
177
  | `pi-extensions/acp-provider.ts` | `entwurf` provider registration |
177
178
  | `pi-extensions/lib/acp/` | ACP adapter rail, config/overlay, augment, turn loop, event mapping |
package/CHANGELOG.md CHANGED
@@ -4,6 +4,133 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.18.0 - 2026-09-04
8
+
9
+ ### Fixed
10
+
11
+ - **A Claude window that changes which session it serves no longer leaves an armed receiver behind
12
+ (#101).** One process serves one garden at a time but can change which: open a bare `claude` and
13
+ then `/resume` or `/clear` inside it, and a second `SessionStart` arrives under the same pid,
14
+ seconds later, naming a different session. The garden it stopped serving kept a receiver marker
15
+ naming a LIVE owner, so dispatch read an armed doorbell nobody held — a sibling's message was
16
+ reported as sent and then sat unread in that mailbox for over 50 minutes (oracle, 2026-09-04; that
17
+ unread letter is what opened the issue).
18
+ - **Deliverability now asks about *now*, not *ever*.** `watchArmed` was a copy of the identity
19
+ match; it is a measurement — the receiver owner's own sender marker
20
+ (`meta-senders/<backend>/<pid>.json`) must still name the same garden. Both consumers, the v2
21
+ production seam and `entwurf_self`, go through one shared composition, so a citizen's
22
+ self-report and dispatch cannot disagree.
23
+ - **The hook retires what it stopped serving** — the marker only, never the record (a record is a
24
+ citizen's identity, not sweepable state), only a marker its own pid owns, and only on an
25
+ arm-capable event. `UserPromptSubmit` cannot emit `watchPaths`, so retiring on a keystroke would
26
+ disarm the session the operator is sitting in with no way to re-arm. The vendor's `source` is
27
+ logged on every line and branched on nowhere: the switch is settled by what is on disk.
28
+ - **The join is scoped by the marker's `ownerKind`, not by backend** — today that is
29
+ `claude-code-cli`. A Copilot watch lives in a forked extension child with its own pid while its
30
+ sender marker carries the CLI's `process.ppid`, so applying the same check there would make
31
+ every Copilot citizen permanently `mailbox-undeliverable`. That is a scope decision with a
32
+ measured reason, not an omission.
33
+
34
+ ### Added
35
+
36
+ - **A rejected delivery now names which receiver axis failed (#101).** A bare `mailbox-undeliverable`
37
+ sent one sibling hunting for a live session it had read as dead. The rejection now says whether
38
+ the doorbell is not armed, the owner is not alive, or there is no record.
39
+ - **`entwurf_peers` separates a live citizen from a phantom (#101).** Every claude-code row reads
40
+ `liveness=unsupported`, so two rows were byte-identical whether or not anyone was home. Each row
41
+ now carries two observed facts — `receiver=` (doorbell state) and `transcript=` (whether a
42
+ conversation was ever written):
43
+
44
+ ```text
45
+ - 20260904T072015-e09b66 liveness=unsupported receiver=active transcript=exists
46
+ - 20260904T093135-ac7a1a liveness=unsupported receiver=inactive transcript=absent
47
+ ```
48
+
49
+ - **`check-meta-hook-session-switch` (28 assertions) and the first mutant lane the Claude hook has
50
+ ever had (#101).** The gate drives the shipped launcher twice under one fake owner pid and
51
+ requires that exactly one of the two gardens is deliverable — and that it is the one the operator
52
+ is sitting in. `scripts/mutants/meta-hook-session-switch.json` carries **17 claims**, 1:1 with the
53
+ gate's `[QK:…]` labels, each re-planting the defect this lane closed and requiring the gate to go
54
+ red at its claimed signature. Three of those six new mutants exist because cross-review found two
55
+ QK labels with no mutant behind them and three real weakenings walking straight through: the
56
+ join's `ownerKind` scope, the sender marker's start-key guard, and its backend equality.
57
+
58
+ ### Changed
59
+
60
+ - **One inherited sentence about the resume picker is retired, and it was retired by measurement
61
+ (#101).** The diagnosis said the picker fires `SessionStart` twice — a placeholder id, then the
62
+ picked one. Six LIVE cells on this host (Claude Code 2.1.260, hook log verbatim in
63
+ `scripts/raw-claude-session-switch/README.md`) show it fires **once**, carrying the real id,
64
+ whether the id comes from the picker or from argv. The two-`SessionStart` shape is a bare `claude`
65
+ followed by an in-session `/resume` or `/clear` — which is exactly what the field case did, four
66
+ seconds before its second envelope. The repair is unaffected (either way one pid stops serving one
67
+ garden), so only the prose moved, in four places. Compaction, manual and automatic, re-fires
68
+ `SessionStart` for the **same** native id and retires nothing; the same-garden rule already
69
+ covered it.
70
+ - **A `UserPromptSubmit` envelope is trusted for the receiver join, on a measured footing.** The
71
+ cross-review threat model ("a stale or out-of-order UPS") was withdrawn rather than defended: it
72
+ claimed the sender pointer is untrustworthy for the join while the same pointer stays authoritative
73
+ for sender identity — two incompatible readings of one file. Across the raw lab's four pids, every
74
+ `UserPromptSubmit` named the native id its own pid's preceding `SessionStart` had established,
75
+ **8 of 8**. The arm-capable restriction stays on its own footing.
76
+ - **Three LIVE smokes stopped depending on a fixture the new join reads as retired.** Five smokes
77
+ seeded a receiver marker with no sender marker beside it — three of them release MUSTs — so each
78
+ would have failed on its own fixture rather than on the rail it exists to prove. They now seed
79
+ both and sandbox the senders root, the shape `smoke-mux-lifecycle-live` and `smoke-omp-fresh-live`
80
+ already had. `smoke-entwurf-chain-live` additionally gives its terminus an owner **outside hop 1's
81
+ ancestry**: seeding it under the smoke's own pid put two garden citizens on one host process and
82
+ the bridge refused the hop outright with `ambiguous sender identity`.
83
+
84
+ ### Upgrade note
85
+
86
+ **Run `entwurf setup` once after upgrading — every rail, not just Claude.** This release changes
87
+ `pi-extensions/lib/meta-session.ts` and the Claude hook, and **four install paths deploy that
88
+ file** — `install-meta-bridge` (Claude), `install-omp-bridge` and `install-omp-receive` (OMP),
89
+ `install-copilot-bridge` (Copilot). Every one of them present on the host now carries a STALE
90
+ writer until it is re-installed, and its own doctor says so by name. Re-installing only the Claude
91
+ rail leaves the others stale, and that does not surface until a LIVE gate turns red — measured in
92
+ the 0.17.2 cut, where it blocked `smoke-omp-receive-live` and made the first `--cut` run BLOCKED.
93
+
94
+ `entwurf setup` is presence-driven and re-synthesizes exactly the units this host has, which is why
95
+ it is the upgrade command rather than any single `install-*`. An already-open Claude Code session
96
+ keeps the old manifest until it restarts — and, for this release specifically, an already-open
97
+ session also keeps the old hook, which is the code that retires a switched-away receiver.
98
+
99
+ ### Verification
100
+
101
+ All of the following ran on oracle (Linux, Claude Code 2.1.260, node 24.18.1, pi 0.84.4, omp 18.0.0).
102
+
103
+ - **`pnpm run check:full` — exit 0**, 440s on the prepared tree and 446s inside the release gate.
104
+ - **`LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.18.0c.xZlKjZ --cut` — `cut: OK`,
105
+ exit 0.** **MUST PASS=23 FAIL=0 SKIP=0**, **BEHAVIOR PASS=1 FAIL=0 SKIP=0**. Run 18:57→19:48 KST
106
+ on `faee8f6`, with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID`. Log:
107
+ `/tmp/entwurf-release-gate-0.18.0c.xZlKjZ/release-gate.log`. It carried `check:full` and
108
+ `check-gate-qualification` (**364/364 KILLED**, up from 347 — the 17 new claims are this lane's
109
+ `MHSS-*`) as MUST steps.
110
+ - **This lane's own gate: `check-meta-hook-session-switch` — 28 assertions passed**, and all 17
111
+ `MHSS-*` mutants killed at their claimed signatures inside that qualification run.
112
+ - **The three LIVE smokes whose fixtures this lane changed are green**, including
113
+ `smoke-entwurf-chain-live` — **24 assertions**, four citizens across three harnesses, and its new
114
+ pre-flight cell `fixture: the terminus is a deliverable citizen at the moment the chain starts`
115
+ passed before the chain ran.
116
+ - **One unattributed `smoke-entwurf-chain-live` failure is on the record and is NOT closed.** During
117
+ the lane (2026-09-04 16:58 KST) one run rejected at hop 3 with `mailbox-undeliverable (observed
118
+ liveness: unsupported)`; the runs before and after it passed and it has not reproduced. A
119
+ start-key race and an early idle-owner exit were excluded by measurement; a memory-pressure
120
+ hypothesis is neither confirmed nor refuted (no OOM or kill entries in the host journal for that
121
+ window). The instrument that will split the next occurrence shipped in this release — the fixture
122
+ pre-flight assertion above, plus a `terminus fixture at timeout:` line naming owner liveness and
123
+ both receiver facts.
124
+ - **The first `--cut` attempt of this cut was BLOCKED, and the cause was operator error, not the
125
+ product.** `check-gate-qualification` aborted with `origin HEAD changed during qualification`
126
+ because a commit was created while the gate was running. Every other MUST step in that run passed
127
+ (`MUST PASS=22 FAIL=1 SKIP=0`). The run above is the re-measurement on a frozen HEAD. One LIVE
128
+ gate run before that was killed by the host harness's low-memory watchdog rather than by any
129
+ assertion, and was re-run under tmux.
130
+ - **Exact-SHA CI on the pre-version HEAD `e56eee0`** — `check`, `install-surface`,
131
+ `artifact-consumer` all `success`.
132
+ Run: https://github.com/junghan0611/entwurf/actions/runs/33853363923
133
+
7
134
  ## 0.17.2 - 2026-09-03
8
135
 
9
136
  ### Added
@@ -64,7 +64,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
64
64
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
65
65
  import { z } from "zod";
66
66
  import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
67
- import { receiverMarkerMatchesIdentity } from "../../../pi-extensions/lib/entwurf-deliverability.js";
67
+ import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.js";
68
68
  import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
69
69
  import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.js";
70
70
  import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
@@ -73,7 +73,7 @@ import { resolveMailboxWakeModeCapability } from "../../../pi-extensions/lib/ent
73
73
  import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
74
74
  import { makeVisibleResumeDeps, renderVisibleResume, visibleResume, } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.js";
75
75
  import { probeNativeSenderAlive, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
76
- import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, } from "../../../pi-extensions/lib/meta-session.js";
76
+ import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
77
77
  import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.js";
78
78
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
79
79
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
@@ -223,9 +223,12 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
223
223
  // admits another self-fetch citizen.
224
224
  // none ← neither. omp today: no mailbox drain, no native-push adapter. Rendering
225
225
  // this as self-fetch printed a mailboxPath nothing drains.
226
- // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the receiver
227
- // presence marker (readMetaReceiverMarker folds a dead/reused owner to null, so a match
228
- // means a live, ARMED receiver the sender marker proves identity, never an armed watch).
226
+ // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the SHARED
227
+ // receiver composition `resolveMailboxReceiverFacts`, the same one the v2 dispatch seam
228
+ // uses, so a citizen's self-reported replyability can never disagree with what dispatch
229
+ // decides about it. It reads the presence marker (a dead/reused owner already folds to
230
+ // null) AND, where the watch owner is the sender-marker process, the #101 join that says
231
+ // the owner is still serving THIS garden rather than one it switched away from.
229
232
  // native-push (antigravity): there is no inbox and no watch. A reply is injected into a
230
233
  // live app-server conversation, so only an adapter probe can answer. Composing the
231
234
  // receiver atom here would demand `watchArmed` from a backend that never arms one, and
@@ -248,14 +251,16 @@ async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
248
251
  }
249
252
  : metaDeliveryDomain === "self-fetch"
250
253
  ? (() => {
251
- const receiver = readMetaReceiverMarker({ gardenId: identity.gardenId });
252
- const active = receiverMarkerMatchesIdentity(receiver, identity);
254
+ const receiver = resolveMailboxReceiverFacts(identity, {
255
+ readReceiverMarker: (gardenId) => readMetaReceiverMarker({ gardenId }),
256
+ readSenderMarker: (backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
257
+ });
253
258
  return {
254
259
  origin: "meta-session",
255
260
  metaDeliveryDomain,
256
261
  recordBacked: true,
257
- ownerAlive: active,
258
- watchArmed: active,
262
+ ownerAlive: receiver.ownerAlive,
263
+ watchArmed: receiver.watchArmed,
259
264
  };
260
265
  })()
261
266
  : {
@@ -72,6 +72,82 @@ export function receiverMarkerMatchesIdentity(marker, identity) {
72
72
  marker.backend === identity.backend &&
73
73
  marker.nativeSessionId === identity.nativeSessionId);
74
74
  }
75
+ // ── the watch-owner ↔ sender-marker join (#101 결함 B) ──────────────────────
76
+ // A receiver marker proves "a LIVE process once armed a watch for this garden".
77
+ // It does NOT prove "that process is serving this garden RIGHT NOW". One native
78
+ // process can hold markers for several gardens and drain exactly one of them:
79
+ // Claude Code's session switch (an in-session `/resume` or `/clear`) fires a second
80
+ // SessionStart inside the SAME pid under a NEW native session id, so the first
81
+ // garden's marker keeps naming a live owner forever while the doorbell it advertises
82
+ // is gone. Measured on oracle 2026-09-04 (#101): one pid held both markers, a
83
+ // 09:33 enqueue to the retired garden sat unread as a bare `.msg` for ≥50 minutes —
84
+ // the "enqueue into a void" this module's header exists to refuse.
85
+ //
86
+ // The join that decides it is already on disk: the SENDER marker is keyed by owner
87
+ // pid and rewritten with the CURRENT garden on every arm/prompt, so
88
+ // `meta-senders/<backend>/<ownerPid>.json.gardenId` is the answer to "which garden
89
+ // does this process serve now?". A receiver marker naming a different garden than
90
+ // its own owner's sender marker is a retired watch.
91
+ //
92
+ // SCOPE IS NOT UNIVERSAL, AND THE MARKER SAYS SO. The join only exists where the
93
+ // watch owner IS the process the sender marker is keyed to. That is true for the
94
+ // Claude hook (one `ownerPid` variable writes both markers —
95
+ // `meta-bridge-hook.ts` sender + receiver arm) and false BY CONSTRUCTION for
96
+ // Copilot, whose watch lives in a forked first-party extension child
97
+ // (`extension.mjs` writes `ownerPid: process.pid`) while its sender marker is keyed
98
+ // to the CLI parent (`meta-bridge-hook-copilot.ts` uses `process.ppid`). Applying
99
+ // the join there would make every Copilot citizen permanently undeliverable — a
100
+ // regression on a shipped lane, not a fix. `ownerKind` is recorded on the marker for
101
+ // exactly this reason ("the marker records which, because the pid a reader verifies
102
+ // differs" — AGENTS.md, self-fetch domain), so it is the axis, not `backend`.
103
+ //
104
+ // `omp-host` is a CANDIDATE, deliberately not admitted here: OMP already retires the
105
+ // previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
106
+ // so the cell this join closes has a different owner there. Admitting it needs its
107
+ // own measurement, not this file's optimism.
108
+ export const SENDER_JOINED_RECEIVER_OWNER_KINDS = ["claude-code-cli"];
109
+ /** Does this watch owner share its pid with the backend's sender marker? */
110
+ export function receiverOwnerKindJoinsSender(ownerKind) {
111
+ return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
112
+ }
113
+ /**
114
+ * Is the watch owner still serving THIS garden? Fail-closed inside the join's scope:
115
+ * an absent/unreadable sender marker, or one naming another garden or backend, means
116
+ * the watch is retired. Outside the scope (an ownerKind whose watch owner is not the
117
+ * sender-marker process) the join does not apply and the marker's own live-owner guard
118
+ * is the whole rule — returning true here is NOT optimism, it is "this axis says
119
+ * nothing", and the caller has already required the marker to match the identity.
120
+ */
121
+ export function receiverOwnerServesGarden(marker, senderMarker) {
122
+ if (!receiverOwnerKindJoinsSender(marker.ownerKind))
123
+ return true;
124
+ return !!senderMarker && senderMarker.gardenId === marker.gardenId && senderMarker.backend === marker.backend;
125
+ }
126
+ /**
127
+ * THE single composition of the two mailbox receiver facts, over injected readers.
128
+ * Both production consumers — the v2 `mailboxDeliverabilityFor` seam and the MCP
129
+ * bridge's `entwurf_self` — call THIS, so a direct send, a re-resolved fallback send
130
+ * and a citizen's own replyability can never drift to different verdicts.
131
+ *
132
+ * ownerAlive — a live-owner marker that names THIS identity (the reader already ran
133
+ * the plausibility + start-key guards; this adds the identity match).
134
+ * watchArmed — that owner is still serving this garden (the join above). It is a
135
+ * MEASUREMENT, never a copy of ownerAlive: copying it is what let a
136
+ * retired watch read as an armed doorbell (#101 결함 B).
137
+ *
138
+ * `recordBacked` is NOT decided here — it stays the caller's explicit fact, so an
139
+ * absent record and a dead owner stay distinguishable in the reason string.
140
+ */
141
+ export function resolveMailboxReceiverFacts(identity, readers) {
142
+ const marker = readers.readReceiverMarker(identity.gardenId);
143
+ const ownerAlive = receiverMarkerMatchesIdentity(marker, identity);
144
+ if (!ownerAlive || !marker)
145
+ return { ownerAlive: false, watchArmed: false };
146
+ const senderMarker = receiverOwnerKindJoinsSender(marker.ownerKind)
147
+ ? readers.readSenderMarker(marker.backend, marker.ownerPid)
148
+ : null;
149
+ return { ownerAlive, watchArmed: receiverOwnerServesGarden(marker, senderMarker) };
150
+ }
75
151
  /**
76
152
  * The conversational-mailbox enqueue gate. False (no enqueue) unless the backend is
77
153
  * self-fetch AND the receiver is active. A direct-inject backend (pi / codex /
@@ -26,7 +26,8 @@
26
26
  * a same-gid socket exists would be half a lie. Both leave the normal output;
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
- import { isOutOfSocketDomainGardenIdConflict, resolveFactList } from "./entwurf-facts.js";
29
+ import { isOutOfSocketDomainGardenIdConflict, resolveFactList, } from "./entwurf-facts.js";
30
+ import { observePeerFacts } from "./entwurf-peer-observe.js";
30
31
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
31
32
  import { FRESH_CUT_PRESCRIPTION, listAllMetaIdentities, } from "./meta-session.js";
32
33
  import { scanSocketProbes } from "./socket-discovery.js";
@@ -65,10 +66,12 @@ function diagnosticSortKey(d) {
65
66
  }
66
67
  }
67
68
  /**
68
- * Assemble the facts-only listing. Pure over its injected deps (no direct IO) so
69
- * the gate drives it without a filesystem; slice 4c supplies the real readdir /
70
- * readFile / probe. Probes are gid + liveness only (#50 C4 the per-socket
71
- * get_info enrich left with the socket-only quasi-citizen listing).
69
+ * Assemble the facts-only listing. Driven entirely through injected deps, so the gate
70
+ * runs it without a filesystem; slice 4c supplies the real readdir / readFile / probe.
71
+ * The one dep with a REAL default is `observe` (#101)see its comment: a per-citizen
72
+ * measurement both wiring sites must make identically is safer as one default than as two
73
+ * call-site arguments. Probes are gid + liveness only (#50 C4 — the per-socket get_info
74
+ * enrich left with the socket-only quasi-citizen listing).
72
75
  */
73
76
  export async function listEntwurfFacts(deps) {
74
77
  const diagnostics = [];
@@ -129,7 +132,7 @@ export async function listEntwurfFacts(deps) {
129
132
  // fire as the last line of defense, never caught here.
130
133
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
131
134
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
132
- const facts = resolveFactList(cleanIdentities, cleanProbes);
135
+ const facts = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
133
136
  // 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
134
137
  // section. One diagnostic per socket (subjects aggregate at render, F8);
135
138
  // the message is liveness-keyed so same-state sockets group into one line.
@@ -19,6 +19,9 @@
19
19
  * a peer-facing fact; `entwurf_peers` exposes identity + cwd-history, not
20
20
  * filesystem internals. (who-can / dispatch read it via the meta-record
21
21
  * directly when they genuinely need it — it does not belong in the listing.)
22
+ * Whether that transcript EXISTS is a different thing and is carried (#101):
23
+ * the path stays private, the fact that a citizen has never written a turn
24
+ * does not.
22
25
  *
23
26
  * The 4-value liveness (`alive|dead|indeterminate|unsupported`, R3b) and the
24
27
  * out-of-domain → `unsupported` rule (R1: never coerce an unprobed backend to
@@ -27,6 +30,9 @@
27
30
  * the surrounding identity facts around it.
28
31
  */
29
32
  import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
33
+ /** What a caller that measured nothing must say. Explicit and greppable — a row that
34
+ * silently defaulted to `none`/`absent` would be a fabricated fact. */
35
+ export const UNOBSERVED_PEER = { receiver: "unobserved", transcript: "unobserved" };
30
36
  /**
31
37
  * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
32
38
  *
@@ -39,7 +45,7 @@ import { factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.js";
39
45
  *
40
46
  * Pure: same inputs → same output, no IO.
41
47
  */
42
- export function resolvePeerFact(identity, socket) {
48
+ export function resolvePeerFact(identity, socket, observations = UNOBSERVED_PEER) {
43
49
  return {
44
50
  gardenId: identity.gardenId,
45
51
  backend: identity.backend,
@@ -49,6 +55,8 @@ export function resolvePeerFact(identity, socket) {
49
55
  createdAt: identity.createdAt,
50
56
  recordUpdatedAt: identity.recordUpdatedAt,
51
57
  liveness: factLivenessOf(identity.backend, socket),
58
+ receiver: observations.receiver,
59
+ transcript: observations.transcript,
52
60
  };
53
61
  }
54
62
  /**
@@ -98,7 +106,7 @@ export function isOutOfSocketDomainGardenIdConflict(backend, gardenId, socketGid
98
106
  * A gardenId is never emitted as both a `PeerFact` and a `RecordLessSocketFact`;
99
107
  * a record-less socket becomes a `PeerFact` the moment a record claims the gid.
100
108
  */
101
- export function resolveFactList(identities, socketProbes) {
109
+ export function resolveFactList(identities, socketProbes, observe = () => UNOBSERVED_PEER) {
102
110
  const probeMap = new Map();
103
111
  for (const probe of socketProbes) {
104
112
  if (probeMap.has(probe.gardenId)) {
@@ -129,7 +137,7 @@ export function resolveFactList(identities, socketProbes) {
129
137
  }
130
138
  socket = null;
131
139
  }
132
- peers.push(resolvePeerFact(identity, socket));
140
+ peers.push(resolvePeerFact(identity, socket, observe(identity)));
133
141
  consumed.add(gid);
134
142
  }
135
143
  const recordLessSockets = [];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * entwurf-peer-observe — the IO half of the two OBSERVED peer facts (#101).
3
+ *
4
+ * `entwurf_peers` used to answer a claude-code citizen with `liveness=unsupported` and
5
+ * nothing else, because the control-socket probe genuinely does not apply to a self-fetch
6
+ * backend. Two same-cwd rows — one a live conversation, one an abandoned startup session
7
+ * whose transcript was never written — were therefore byte-identical in the listing, and
8
+ * a caller picking "the newest record in this cwd" (the natural heuristic, and the one the
9
+ * surface encourages) picked the phantom as often as the real one. Both halves of the
10
+ * distinction were already on disk; nothing read them.
11
+ *
12
+ * FACTS, NOT VERBS. `receiver` and `transcript` say what was found, never what to do
13
+ * about it: no `sendable`, no `deliverable`, no transport. The listing stays a listing —
14
+ * dispatch still computes routing at call time from the same underlying markers.
15
+ *
16
+ * ONE MEASUREMENT, TWO WORDS. `receiver` is derived from the SAME
17
+ * `resolveMailboxReceiverFacts` composition the v2 dispatch seam and `entwurf_self` use.
18
+ * The surfaces are allowed to phrase it differently — a listing wants an enum, a reject
19
+ * wants a sentence — but they may not disagree, so the enum is a projection of that atom
20
+ * rather than a second opinion about the same markers.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.js";
24
+ import { metaCapabilityFor, metaReceiverMarkerPath, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
25
+ /**
26
+ * The mailbox receiver axis for one citizen.
27
+ *
28
+ * `n/a` is not a failure: a backend with no mailbox (pi's control socket, antigravity's
29
+ * native push) has no receiver marker to be right or wrong about, and printing `none`
30
+ * there would invent a missing thing. The `inactive` / `none` split is what a reader
31
+ * needs to tell "a watch was armed and is no longer valid" from "no watch was ever
32
+ * armed here", so it is taken from the marker FILE's existence rather than from the
33
+ * reader's null — the reader folds absent, corrupt and dead-owner into one null.
34
+ */
35
+ function observeReceiver(identity) {
36
+ if (metaCapabilityFor(identity.backend).wakeMode !== "self-fetch")
37
+ return "n/a";
38
+ const facts = resolveMailboxReceiverFacts(identity, {
39
+ readReceiverMarker: (gardenId) => readMetaReceiverMarker({ gardenId }),
40
+ readSenderMarker: (backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
41
+ });
42
+ if (facts.ownerAlive && facts.watchArmed)
43
+ return "active";
44
+ return fs.existsSync(metaReceiverMarkerPath(identity.gardenId)) ? "inactive" : "none";
45
+ }
46
+ /**
47
+ * Does the recorded transcript exist? A record is minted at SessionStart, but a harness
48
+ * that writes its transcript lazily (Claude Code writes on the first turn) leaves a
49
+ * citizen with no conversation behind it until someone actually says something. The PATH
50
+ * stays private — only its existence crosses to the listing.
51
+ */
52
+ function observeTranscript(identity) {
53
+ try {
54
+ // A record with no recorded transcript path has nothing to stat — that is the same
55
+ // observable state as a path that is not there, and neither is an `exists` claim.
56
+ return identity.transcriptPath && fs.existsSync(identity.transcriptPath) ? "exists" : "absent";
57
+ }
58
+ catch {
59
+ // A transcript we cannot stat is not a transcript we can claim exists.
60
+ return "absent";
61
+ }
62
+ }
63
+ /** The production observer: both axes, measured for one citizen. */
64
+ export function observePeerFacts(identity) {
65
+ return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
66
+ }
@@ -22,9 +22,18 @@
22
22
  * socketPath rows — the pre-record socket-scan worldview) is gone with the
23
23
  * `controlDir` it exposed. Socket paths are dispatch-internal transport.
24
24
  */
25
+ // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
26
+ // found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
27
+ // about it. They earn a column because for every claude-code row `liveness` reads
28
+ // `unsupported` — the socket probe does not apply — so two rows in one cwd, a live
29
+ // conversation and an abandoned startup session whose transcript was never written, used to
30
+ // be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
31
+ // same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
32
+ // existence only, never the path.
25
33
  function renderPeerLine(p) {
26
34
  const model = p.model ?? "(unknown)";
27
- return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}`;
35
+ return (`- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
36
+ `transcript=${p.transcript} cwd=${p.cwd} model=${model}`);
28
37
  }
29
38
  function renderDiagnosticLine(d) {
30
39
  switch (d.kind) {
@@ -151,8 +151,14 @@ export async function decideDispatch(input, deps) {
151
151
  // fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
152
152
  const deliverability = await deps.mailboxDeliverabilityFor(identity);
153
153
  const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
154
- if (!receipt.ok)
155
- return reject(receipt);
154
+ // The predicate's reason travels with the reject (#101 갭 C). It is attached only when
155
+ // undeliverability is what produced the reject — an intent-shaped refusal on a
156
+ // DELIVERABLE target must not be dressed up as a receiver problem.
157
+ if (!receipt.ok) {
158
+ return deliverability.deliverable
159
+ ? reject(receipt)
160
+ : reject(receipt, { kind: "mailbox-undeliverable", reason: deliverability.reason });
161
+ }
156
162
  // the only allow cell here is fire-and-forget → meta-mailbox send.
157
163
  const plan = {
158
164
  transport: "meta-mailbox",
@@ -29,7 +29,7 @@
29
29
  * claiming "no conflict". The decider's later `inspectSocket` probe is a SEPARATE step.
30
30
  */
31
31
  import { sendRpcCommand as realSendRpc, } from "./entwurf-control-rpc.js";
32
- import { mailboxConversationalDeliverable, receiverMarkerMatchesIdentity, } from "./entwurf-deliverability.js";
32
+ import { mailboxConversationalDeliverable, resolveMailboxReceiverFacts, } from "./entwurf-deliverability.js";
33
33
  import { isOutOfSocketDomainGardenIdConflict } from "./entwurf-facts.js";
34
34
  import { isLivenessSupported } from "./entwurf-v2-contract.js";
35
35
  import { decideDispatch, } from "./entwurf-v2-decider.js";
@@ -38,7 +38,7 @@ import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.js";
38
38
  import { makeNativePushSend } from "./entwurf-v2-native-push.js";
39
39
  import { executeControlSocketSend, } from "./entwurf-v2-send.js";
40
40
  import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
41
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, } from "./meta-session.js";
41
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readAddressableMetaIdentity, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "./meta-session.js";
42
42
  import { resolveNativePushAdapter as realResolveNativePushAdapter, } from "./native-push/adapter.js";
43
43
  import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isRecordLessSocketCandidate, } from "./socket-discovery.js";
44
44
  import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
@@ -77,6 +77,8 @@ export function makeProductionEntwurfV2Deps(opts) {
77
77
  metaRecordExists: s.metaRecordExists ?? metaRecordExistsByGardenId,
78
78
  readIdentity: s.readIdentity ?? readAddressableMetaIdentity,
79
79
  readReceiverMarker: s.readReceiverMarker ?? ((gid) => readMetaReceiverMarker({ gardenId: gid })),
80
+ readSenderMarker: s.readSenderMarker ??
81
+ ((backend, ownerPid) => readMetaSenderMarker({ backend: requireBackend(backend), ownerPid })),
80
82
  inspectPath: s.inspectPath ?? inspectControlSocketPath,
81
83
  acquireLock: s.acquireLock ?? realAcquireLock,
82
84
  releaseLock: s.releaseLock ?? realReleaseLock,
@@ -98,22 +100,32 @@ export function makeProductionEntwurfV2Deps(opts) {
98
100
  enqueue: io.enqueue,
99
101
  });
100
102
  // ── ONE deliverability seam (SE-2 2d-3): wake-mode capability AND a live active-
101
- // receiver (a presence marker that matches THIS identity). The SAME closure is injected
102
- // into the decider AND the dead-fallback, so a direct send and a re-resolved fallback
103
- // send can never drift to different deliverability verdicts. recordBacked is true by
104
- // construction resolveTarget already proved the record exists before any unsupported-
105
- // backend mailbox route, and the closure is only consulted on that route. A null /
106
- // dead-owner / identity-mismatched marker is fail-closed to inactive (SE-2): a reply to a
107
- // terminated self-fetch citizen is rejected, not enqueued as mailbox garbage. ──────────
103
+ // receiver. The SAME closure is injected into the decider AND the dead-fallback, so a
104
+ // direct send and a re-resolved fallback send can never drift to different deliverability
105
+ // verdicts. recordBacked is true by construction resolveTarget already proved the record
106
+ // exists before any unsupported-backend mailbox route, and the closure is only consulted on
107
+ // that route. A null / dead-owner / identity-mismatched marker is fail-closed to inactive
108
+ // (SE-2): a reply to a terminated self-fetch citizen is rejected, not enqueued as mailbox
109
+ // garbage.
110
+ //
111
+ // The two receiver facts come from the SHARED composition (#101 결함 B), never from one
112
+ // value copied into both slots. `ownerAlive` is the marker↔identity match on a live owner;
113
+ // `watchArmed` is the separate measurement that this owner is STILL serving this garden.
114
+ // They were the same expression until a Claude session switch inside one pid left a retired
115
+ // garden's marker reading as an armed doorbell and a real message rotted unread in its
116
+ // mailbox. `entwurf_self` calls the same composition, so a citizen's own replyability can
117
+ // never disagree with what dispatch decided about it. ──────────
108
118
  const mailboxDeliverabilityFor = (identity) => {
109
119
  const wakeMode = metaCapabilityFor(identity.backend).wakeMode;
110
- const marker = io.readReceiverMarker(identity.gardenId);
111
- const matched = receiverMarkerMatchesIdentity(marker, identity);
120
+ const { ownerAlive, watchArmed } = resolveMailboxReceiverFacts(identity, {
121
+ readReceiverMarker: io.readReceiverMarker,
122
+ readSenderMarker: io.readSenderMarker,
123
+ });
112
124
  return mailboxConversationalDeliverable({
113
125
  wakeMode,
114
126
  recordBacked: true,
115
- ownerAlive: matched,
116
- watchArmed: matched,
127
+ ownerAlive,
128
+ watchArmed,
117
129
  });
118
130
  };
119
131
  // ── target resolution (QB1 + QB2) ─────────────────────────────────────────
@@ -103,6 +103,12 @@ export function renderEntwurfV2Result(result) {
103
103
  `\n ${c.detail}` +
104
104
  (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
105
105
  }
106
+ // #101 갭 C: name WHICH receiver axis failed. "mailbox-undeliverable" alone sent a
107
+ // caller looking for a dead session when the session was alive and had simply
108
+ // switched to another garden — the predicate knew that and the surface threw it away.
109
+ if (result.diagnostic?.kind === "mailbox-undeliverable") {
110
+ text += `\n mailbox-undeliverable: ${result.diagnostic.reason}`;
111
+ }
106
112
  return { text, isError: true };
107
113
  }
108
114
  case "executed": {
@@ -1661,6 +1661,44 @@ export function readMetaReceiverMarker(opts) {
1661
1661
  return null;
1662
1662
  }
1663
1663
  }
1664
+ /**
1665
+ * Retire a receiver presence marker THIS owner pid wrote (#101 결함 A).
1666
+ *
1667
+ * A native process that switches sessions in place — an in-session `/resume` or `/clear`
1668
+ * fires a second SessionStart inside the same pid under a new native session id — leaves
1669
+ * the previous garden's marker behind, naming a live owner whose watch is gone. Measured
1670
+ * on oracle 2026-09-04: one pid (143742) held two gardens' markers four seconds apart, and
1671
+ * mail sent to the retired one sat unread.
1672
+ *
1673
+ * ONLY THE MARKER. The meta-record stays: records are identity, and deleting one
1674
+ * outside `meta-bridge-fresh-cut` would fight store certification (Hard Rule 7/8).
1675
+ * A retired citizen keeps its record, its transcript and its listing — it loses only
1676
+ * the claim that a doorbell is armed for it, which is exactly the claim that stopped
1677
+ * being true.
1678
+ *
1679
+ * Fail-closed the other way too: the marker is read back first and removed ONLY when its
1680
+ * `ownerPid` field equals the one passed here — pid equality, which is what a caller can
1681
+ * actually check; it does not prove the same PROCESS wrote it (a reused pid compares equal),
1682
+ * and the reader-side start-key guard is where that finer distinction lives. It is enough for
1683
+ * 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
1684
+ * that cannot happen is a log line, not a broken session start.
1685
+ */
1686
+ export function removeMetaReceiverMarker(opts) {
1687
+ try {
1688
+ const receiversDir = opts.receiversDir ?? defaultMetaReceiversDir();
1689
+ const file = metaReceiverMarkerPath(opts.gardenId, receiversDir);
1690
+ // verifyOwner:false — the point is the OWNERSHIP field, not the owner's liveness;
1691
+ // a marker whose owner already died is still this pid's to clean up when it names it.
1692
+ const marker = readMetaReceiverMarker({ markerPath: file, verifyOwner: false });
1693
+ if (!marker || marker.ownerPid !== opts.ownerPid)
1694
+ return false;
1695
+ fs.unlinkSync(file);
1696
+ return true;
1697
+ }
1698
+ catch {
1699
+ return false;
1700
+ }
1701
+ }
1664
1702
  /**
1665
1703
  * Idempotent fs upsert (writes v3 identity). CERTIFY the whole active store, then
1666
1704
  * decide create-vs-attach on record EXISTENCE and write atomically as v3. On