@junghanacs/entwurf 0.17.2 → 0.18.1

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 (65) hide show
  1. package/AGENTS.md +6 -5
  2. package/CHANGELOG.md +299 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +9 -5
  5. package/VERIFY.md +3 -3
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +46 -12
  8. package/docs/setup-clean-host.md +35 -3
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +6 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +38 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  21. package/mcp/entwurf-bridge/src/index.ts +16 -8
  22. package/package.json +10 -9
  23. package/pi-extensions/entwurf-control.ts +12 -2
  24. package/pi-extensions/lib/acp/acp-client.ts +3 -3
  25. package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
  26. package/pi-extensions/lib/acp/backend.ts +3 -3
  27. package/pi-extensions/lib/acp/event-mapper.ts +5 -5
  28. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  29. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  30. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  31. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  32. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  33. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  34. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  35. package/pi-extensions/lib/entwurf-v2-surface.ts +6 -0
  36. package/pi-extensions/lib/meta-session.ts +44 -0
  37. package/pi-extensions/meta-bridge-hook.ts +72 -3
  38. package/run.sh +146 -56
  39. package/scripts/check-acp-usage-accounting.ts +9 -9
  40. package/scripts/check-bridge-delivery.ts +42 -1
  41. package/scripts/check-entwurf-fact-provider.ts +22 -1
  42. package/scripts/check-entwurf-facts.ts +17 -2
  43. package/scripts/check-entwurf-peers-surface.ts +20 -2
  44. package/scripts/check-entwurf-self-address.ts +20 -2
  45. package/scripts/check-entwurf-v2-decider.ts +6 -3
  46. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  47. package/scripts/check-entwurf-v2-production.ts +72 -1
  48. package/scripts/check-gate-qualification.ts +63 -12
  49. package/scripts/check-meta-hook-session-switch.ts +560 -0
  50. package/scripts/check-release-gate-outcomes.ts +160 -3
  51. package/scripts/meta-bridge-doctor.sh +29 -4
  52. package/scripts/mutants/bridge-command-boot.json +3 -3
  53. package/scripts/mutants/gate-qualification.json +28 -0
  54. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  55. package/scripts/mutants/pack-install.json +14 -3
  56. package/scripts/mutants/release-gate.json +22 -0
  57. package/scripts/raw-acp-compaction-measure/README.md +77 -0
  58. package/scripts/raw-acp-compaction-measure/probe.ts +196 -0
  59. package/scripts/raw-claude-session-switch/README.md +170 -0
  60. package/scripts/smoke-acp-cortex-live.ts +22 -2
  61. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  62. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  63. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  64. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  65. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -12,7 +12,7 @@ only on Linux because its strict live-owner join uses `/proc`.
12
12
  | npm/pnpm | npm is bundled with Node; pnpm is required for source setup | package or source installation |
13
13
  | Python 3 | required by `setup`/`install` (project-path normalization + settings writers); `--help`/`check-bridge` stay Python-free | pi/Claude/agy/Copilot wiring writers |
14
14
  | entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
15
- | pi | optional-by-presence, `>=0.84.4 <0.85` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
15
+ | pi | optional-by-presence, `>=0.85.1 <0.86` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
16
16
  | Claude Code | optional, **`>=2.1.217`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
17
17
  | GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
18
18
  | OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
@@ -110,7 +110,7 @@ packageRoot↔installerRoot coupling mismatch and a package/provider managed-pat
110
110
  Install the exact release floor, then wire the project:
111
111
 
112
112
  ```bash
113
- npm install -g @earendil-works/pi-coding-agent@0.84.4
113
+ npm install -g @earendil-works/pi-coding-agent@0.85.1
114
114
  pi --version
115
115
 
116
116
  cd ~/entwurf-smoke
@@ -118,7 +118,7 @@ entwurf install .
118
118
  pi -e "$(npm root -g)/@junghanacs/entwurf" --list-models entwurf
119
119
  ```
120
120
 
121
- The supported range is `>=0.84.4 <0.85`. It is a hard minimum: installing this
121
+ The supported range is `>=0.85.1 <0.86`. It is a hard minimum: installing this
122
122
  release onto a 0.83.x pi host upgrades the runtime rather than keeping the older
123
123
  minor. A host using only the external MCP bridge can skip pi until it needs a
124
124
  control socket; no delivery rail launches a pi process.
@@ -239,6 +239,38 @@ announces a tool the MCP hand provides. Install it without them and it will log
239
239
  `arm-deferred`, give up after ~20s, and `doctor-omp-receive` will name the missing sibling
240
240
  as a note rather than a fault.
241
241
 
242
+ ### The OMP version rule — a weak floor, deliberately (#91)
243
+
244
+ **entwurf sets no OMP version floor in code, and will not grow one on schedule.** Detection is
245
+ presence-only (`command -v "${OMP_BIN:-omp}"`); there is no `entwurf.ompFloor`, no coherence
246
+ gate, and no exact pin — unlike Node (`engines.node`), pi (`>=0.85.1 <0.86`) and Claude Code
247
+ (`entwurf.claudeCodeFloor`), each of which has an enforcement point. That asymmetry is a
248
+ decision, not an omission. A floor is the answer to a vendor that fails SILENTLY — Claude Code
249
+ earned one because an older binary validates the exec manifest, drops `args` at runtime, and
250
+ reports success. OMP has never been observed to fail that way, it publishes at close to a
251
+ daily cadence, and when its contact surface breaks the two LIVE smokes go loudly red.
252
+
253
+ What stands instead is a **weak floor: the last version with a LIVE receipt.**
254
+
255
+ > **OMP minimum: `18.1.10`** — `[측정 2026-09-04, thinkpad, Linux x86-64, omp/18.1.10]`
256
+ > `smoke-omp-receive-live` 11 assertions ok (garden `20260904T224103-d36fed`) and
257
+ > `smoke-omp-fresh-live` 21 assertions ok (garden `20260904T224132-351877`, model
258
+ > `openai-codex/gpt-5.6-sol`), with `doctor-omp-receive` PASS and `doctor-omp-mcp` ok. That
259
+ > update skipped one minor and ten patches from 18.0.0 and broke nothing.
260
+ >
261
+ > **This number moves only when a NEW LIVE receipt exists** — never on a release cadence, a
262
+ > changelog read, or a static gate. Run newer OMP freely; the floor records what was proven,
263
+ > not what is permitted.
264
+ >
265
+ > **The drift sentinels are `smoke-omp-fresh-live` and `smoke-omp-receive-live`**, both
266
+ > release-gate MUST steps. Green after an update means keep going. Red means open a NEW issue
267
+ > carrying the first vendor contact point that broke and its reproduction receipt — do not
268
+ > reopen the closed adoption question. If a SILENT failure is ever observed (green smokes over
269
+ > a dead contact point), that is the evidence a real floor needs, and it earns its own issue
270
+ > for the same reason Claude Code's floor exists.
271
+
272
+ The evidence and the reasoning are in **#91**; this paragraph is its durable form.
273
+
242
274
  **The receiver arms per session, and only for the visible TUI host.** Opening omp arms it;
243
275
  `/new` re-arms it for the replacement citizen and retires the previous one; closing omp
244
276
  retires it. A task subagent arms nothing. While nothing is armed, dispatch to that garden
@@ -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
  : {
@@ -123,8 +123,8 @@ export const claudeAdapter = {
123
123
  },
124
124
  // MEASURED (2026-09-02, #93): claude-agent-acp's `usage_update` carries a
125
125
  // session-cumulative ESTIMATED cost (`cost.amount = message.total_cost_usd`)
126
- // and a context-occupancy scalar (`used`; both read at 0.73.0
127
- // `dist/acp-agent.js:2918-2924`). Both are measured, so backend.ts seals them.
126
+ // and a context-occupancy scalar (`used`; both read at 0.75.1
127
+ // `dist/acp-agent.js:3467-3482`). Both are measured, so backend.ts seals them.
128
128
  // The token partition is NOT declared here: ACP's only token carrier is a
129
129
  // per-turn round-trip aggregate, which is not what pi's four fields mean.
130
130
  sealsTurnAccounting: true,
@@ -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