@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
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,254 @@ 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
+
134
+ ## 0.17.2 - 2026-09-03
135
+
136
+ ### Added
137
+
138
+ - **An arriving letter is now visible to the operator, not just to the model (#98).** A sibling's
139
+ mail landed, was read, and left one line on the receiving screen — `Stop hook feedback` — with no
140
+ sender, no count, no garden id. Three surfaces changed, each measured in the
141
+ `scripts/raw-async-delivery/` lab before any product edit:
142
+ - **The doorbell says what it is.** The shipped `FileChanged` hook declares
143
+ `rewakeSummary: "entwurf inbox: sibling mail arrived"` and
144
+ `rewakeMessage: "entwurf mailbox notice:"`. The model no longer wakes to
145
+ `Stop hook blocking error from command "FileChanged"` — mail arriving was being named an error.
146
+ Both fields are `@internal` in the vendor and unsanitized, so `check-hook-launch-topology` lints
147
+ them (declared, no newline, not whitespace-only, length bound, no `[` in the prefix that would
148
+ double the doorbell's own `[entwurf inbox]`) and `smoke-meta-async-drift` carries both as
149
+ markers — a silent `@internal` removal is a future this repo cannot measure live.
150
+ - **The status line carries an unread badge.** `✉N` counts exactly what `entwurf_inbox_read`
151
+ would hand back (`*.msg` + `*.msg.delivered`, `.read` excluded — the same union as
152
+ `readMetaInbox`). No badge at zero. **`✉?` when the count could not be TAKEN** (no python3,
153
+ unusable garden id, unreadable directory): a measurement failure must not look like an empty
154
+ inbox.
155
+ - **A send names the file it enqueued.** `entwurf_v2 meta-mailbox → enqueued (2026-…-113443.msg)`.
156
+ `enqueueMetaMessage` already returned `messagePath`; the mailbox hand was flattening it to
157
+ `{success:true}`. Deliberately not a read stamp: at enqueue time `lastReadAt` belongs to the
158
+ PREVIOUS message, and surfacing it would read as "my message was read".
159
+
160
+ ### Fixed
161
+
162
+ - **A dead-socket send that falls back to the mailbox now names its file too (#98).** A
163
+ control-socket delivery whose socket was gone re-resolves to the mailbox and writes a `.msg`
164
+ exactly like the primary rail, but the sender's line said only `fallback-sent` — the one mailbox
165
+ delivery with no per-message identifier. The receipt is carried through `SendDrive` →
166
+ `ControlSocketSendResult` → `ExecutedOutcome` → surface. A socket-to-socket retry writes no file
167
+ and carries none; a `rejected` enqueue carries none; a dep that omits it degrades to the bare
168
+ outcome rather than printing `undefined`.
169
+ - **entwurf no longer owns the compaction switch (#94).** `autoCompactEnabled` and
170
+ `env.DISABLE_AUTOCOMPACT` moved from `MANAGED_SETTINGS_SCALARS` to `RETIRED_SETTINGS_SCALARS`,
171
+ the path `skipDangerousModePermissionPrompt` already walked. Retirement moves **ownership, not
172
+ state**: `relinquish_retired_scalar()` restores the install-state snapshot only when the current
173
+ value still equals the last managed one, so retirement alone turns compaction on for nobody —
174
+ turning it on is a separate operator act, and entwurf writing that value again would undo the
175
+ return. The doctor now stays green for an operator who turned compaction back on.
176
+ - **A correction that belongs in the record:** `env.DISABLE_AUTOCOMPACT` was a **no-op** at Claude
177
+ Code 2.1.259 — the only key that actually suppressed compaction was `autoCompactEnabled`. The
178
+ conclusion is unchanged; the reason narrows to one key.
179
+ - The lineage is also corrected. 0.5.0 *did* ship a pi-side compaction guard in real code;
180
+ `378c682` (v2 subtraction) deleted it and `623a4ea` later cleared the docs that outlived it by
181
+ seven days. The "zero code backing" in that commit message was a grep result at that moment, not
182
+ a claim that the guard never existed.
183
+
184
+ ### Changed
185
+
186
+ - **Three shipped comments stopped repeating two claims this release retired.** `doorbell.sh`,
187
+ `raw-async-delivery/README.md` and `smoke-meta-async-drift.sh` said that `asyncRewake` ignores
188
+ `rewakeMessage` and that stdout is dropped. Measured against three vendor binaries (2.1.236 /
189
+ 2.1.258 / 2.1.259), both are false: `rewakeMessage` *replaces* the prefix, and the body is
190
+ `stderr || stdout`. The marker strings were correct while their stated reasons were dead — a
191
+ sentinel nobody could act on. The lab README now carries the receipts under
192
+ `## Inherited facts corrected`, and the watcher documents what it observes rather than which
193
+ backends it expects.
194
+ - `lastDeliveredAt` is documented as a **reserved slot nobody stamps**, in five comments and four
195
+ test cells that had been pinning it green as `=== null` while 933 files carried a `.delivered`
196
+ suffix. The per-message facts are the suffixes (`.msg` → `.delivered` → `.read`); `state.json`
197
+ holds only the garden's last enqueue/read. Removing the field is a separate migration (its reader
198
+ is doubly strict and 182 v1 files are on disk), tracked apart from this release.
199
+
200
+ ### Upgrade note
201
+
202
+ **Run `entwurf setup` once after upgrading — every rail, not just Claude.** Two separate debts:
203
+
204
+ - The Claude plugin's hook template gained `rewakeSummary` and `rewakeMessage`, so until
205
+ `install-meta-bridge` runs, `doctor-meta-bridge` reports
206
+ `installed manifest DIFFERS … Re-run install-meta-bridge`.
207
+ - **This release changed `lib/meta-session.ts`, and four install paths deploy that file** —
208
+ `install-meta-bridge` (Claude), `install-omp-bridge` and `install-omp-receive` (OMP),
209
+ `install-copilot-bridge` (Copilot). Every one of them that is installed on the host now carries a
210
+ STALE writer until it is re-installed, and its own doctor says so by name. Re-installing only the
211
+ Claude rail leaves the others stale — measured on oracle during this cut, where it blocked
212
+ `smoke-omp-receive-live` and turned the first `--cut` run red.
213
+
214
+ `entwurf setup` is presence-driven and re-synthesizes exactly the units this host has, which is why
215
+ it is the upgrade command rather than any single `install-*`. An already-open Claude Code session
216
+ keeps the old manifest until it restarts.
217
+
218
+ 0.17.1 was tagged and released on GitHub but **not published to npm**; it is superseded by this
219
+ version.
220
+
221
+ ### Verification
222
+
223
+ All of the following ran on oracle (Linux, Claude Code 2.1.259, node 24.18.1, pi 0.84.4, omp 18.0.0).
224
+
225
+ - **`pnpm run check:full` — exit 0** (432s on the prepared tree; 437s on the pre-version HEAD).
226
+ - **`LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.17.2b.WW12BK --cut` — `cut: OK`,
227
+ exit 0.** **MUST PASS=23 FAIL=0 SKIP=0**, **BEHAVIOR PASS=1 FAIL=0 SKIP=0**. Run 20:39→21:26 KST
228
+ with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID`. Log:
229
+ `/tmp/entwurf-release-gate-0.17.2b.WW12BK/release-gate.log`. It carried `check:full` (429s) and
230
+ `check-gate-qualification` (**347/347 KILLED**) as MUST steps.
231
+ - **The first `--cut` attempt was RED, and that is the receipt for the upgrade note above.**
232
+ `smoke-omp-receive-live` failed with `STALE writer: source=634d5b96ed50 installed=229fef123589`
233
+ on `~/.omp/agent/extensions/entwurf-receive-omp/lib/meta-session.ts` — MUST PASS=22 FAIL=1,
234
+ `cut: BLOCKED`. Both OMP extensions on the host still carried the pre-release writer because only
235
+ the Claude rail had been re-installed. `install-omp-bridge` + `install-omp-receive` moved both to
236
+ `634d5b96ed50`, `doctor-omp-bridge` and `doctor-omp-receive` went PASS, and the re-run was green.
237
+ Nothing in the product changed between the two runs.
238
+ - **#94 measured on a live host.** After one `install-meta-bridge`, the install-state ledger no
239
+ longer carries `autoCompactEnabled` or `env.DISABLE_AUTOCOMPACT`, and `~/.claude/settings.json`
240
+ was **byte-identical** to its pre-install backup — the return does not rewrite the value it
241
+ returns. `doctor-meta-bridge` went FAIL→PASS, `smoke-meta-install-state` PASS,
242
+ `check-meta-doctor-oracle` PASS including the two new cells (`an operator who turned compaction
243
+ back ON is not drift`, `install-state still owns a retired scalar → FAIL naming its own cause`).
244
+ - **#98 B measured across the whole suffix lifecycle** in an isolated mailbox root: no badge at 0,
245
+ `✉1` on `.msg`, `✉2` after `.msg.delivered`, unchanged by a `.read` file, back to no badge when
246
+ all are read, and `✉?` on an unreadable directory.
247
+ - **`smoke-meta-async-drift` ends `pass=12 fail=0 drift=1`, exit 1** — the drift is
248
+ `codex 0.147.0` outside the `0.144.x` pin and **pre-dates this release**. Both markers this
249
+ release added (`rewakeMessage`, `rewakeSummary`) are present. The pin bump is deliberately not in
250
+ this lane.
251
+ - **Exact-SHA CI on the pre-version HEAD `4124e42`** — `check`, `install-surface`,
252
+ `artifact-consumer` all `success`.
253
+ Run: https://github.com/junghan0611/entwurf/actions/runs/33742448634
254
+
7
255
  ## 0.17.1 - 2026-09-03
8
256
 
9
257
  ### Fixed
@@ -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",