@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
@@ -28,6 +28,20 @@
28
28
  * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
29
  * re-arm the idle watch — the record's address is restored, the wake is not.
30
30
  *
31
+ * SESSION SWITCH (#101). One Claude process serves one session at a time, but it can
32
+ * change which. Measured on oracle 2026-09-04 (raw lab S1-S6, Claude Code 2.1.260): a bare
33
+ * `claude` mints a NEW session (`source=startup`), and an in-session `/resume` or `/clear`
34
+ * then fires a SECOND SessionStart under the SAME pid for a DIFFERENT native id
35
+ * (`source=resume` / `source=clear`). The first garden is left behind — in the #101 field
36
+ * case its transcript was never written at all — while its receiver marker still names a
37
+ * LIVE owner, so a sender reads an armed doorbell nobody holds. This hook therefore reads
38
+ * the sender marker BEFORE overwriting it and retires the previous garden's receiver marker
39
+ * (marker only — records are identity and are never deleted here; and only a marker this
40
+ * pid owns). Compaction (`source=compact`) re-fires SessionStart for the SAME native id, so
41
+ * the same rule retires nothing there — measured, not assumed. The envelope's `source` is
42
+ * logged beside all of it and decides nothing: the switch is settled by what is on disk,
43
+ * which holds on every host and vendor version.
44
+ *
31
45
  * LAUNCH: never invoked directly by Claude. `hooks.json` declares the EXEC form
32
46
  * (`command` = `<plugin-root>/scripts/hook-launch.sh`, `args` = [node, this file]),
33
47
  * and the launcher `exec`s that argv — so this process inherits the launcher's pid
@@ -45,7 +59,7 @@
45
59
  */
46
60
  import * as fs from "node:fs";
47
61
  import * as path from "node:path";
48
- import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
62
+ import { defaultMetaMailboxDir, defaultMetaSessionsDir, isPlausibleOwnerPid, readMetaSenderMarker, removeMetaReceiverMarker, upsertMetaSession, writeMetaReceiverMarker, writeMetaSenderMarker, } from "./lib/meta-session.js";
49
63
  function logLine(level, message) {
50
64
  try {
51
65
  // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
@@ -168,6 +182,13 @@ function main() {
168
182
  ? env.model_id
169
183
  : undefined;
170
184
  const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
185
+ // `source` (startup | resume | clear | compact) is Claude's own word for WHY this
186
+ // SessionStart fired. It is logged on every line below and decides nothing: a session
187
+ // switch is settled by what is on disk (the sender marker's garden), which is true on
188
+ // every host and every vendor version. Logging it is how this host finally gets a
189
+ // receipt for the envelope order the #101 diagnosis could only read from vendor docs —
190
+ // and the raw lab (scripts/raw-claude-session-switch) reads these lines, not a guess.
191
+ const source = typeof env.source === "string" && env.source.length > 0 ? env.source : "(unset)";
171
192
  if (!sessionId || !transcriptPath) {
172
193
  // A degraded envelope: cannot mint an honest reference record. Log + no-op
173
194
  // rather than write a half-record or guess a transcript path. LEVEL depends
@@ -185,7 +206,7 @@ function main() {
185
206
  input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
186
207
  });
187
208
  gardenId = result.record.gardenId;
188
- logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
209
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, source=${source}, native=${sessionId})`);
189
210
  }
190
211
  catch (err) {
191
212
  // Best-effort: a broken record store must surface via the doctor, not by
@@ -210,9 +231,48 @@ function main() {
210
231
  // an env var. Missing launcher provenance or an implausible parent yields no marker.
211
232
  const ownerPid = resolveMetaHookOwnerPid();
212
233
  if (ownerPid !== null) {
234
+ // SESSION SWITCH RETIREMENT (#101 결함 A). One Claude process serves ONE session at a
235
+ // time, but it can switch which: an in-session `/resume` or `/clear` fires a second
236
+ // SessionStart under the same pid for a different native id, leaving the session the
237
+ // process started with behind (measured on oracle, meta-bridge-hook.log 2026-09-04
238
+ // 13:13:04 `source=startup` → 13:13:37 `source=resume`; the field case at 09:31:35 →
239
+ // 09:31:39 is the same shape). Whatever it was serving before is no longer being
240
+ // drained, so the marker advertising its doorbell has to go.
241
+ //
242
+ // The evidence is the sender marker as it stands RIGHT NOW — pid → the garden this
243
+ // process serves — which is why this reads it BEFORE the write below overwrites it with
244
+ // the new garden. No vendor field is consulted: `source` is logged, not branched on, so
245
+ // a host or version that words it differently changes nothing here. A same-garden
246
+ // re-registration (every UserPromptSubmit, a CwdChanged, a re-fired SessionStart) finds
247
+ // an equal garden id and retires NOTHING — the marker it would remove is the live one.
248
+ //
249
+ // Only the marker, never the record (see removeMetaReceiverMarker), and only a marker
250
+ // this pid owns.
251
+ //
252
+ // AND ONLY ON AN EVENT THAT CAN ARM THE REPLACEMENT (cross-review, 2026-09-04). This
253
+ // block sits before the UserPromptSubmit early-return, and UPS cannot emit watchPaths —
254
+ // so a retirement reached from there would take a doorbell down with nothing in the same
255
+ // run able to put one back. A watch is retired only by a run that arms one; that is the
256
+ // whole rule. `armProvenanceFor` is the same predicate the arm block below uses, so the
257
+ // two can never disagree about which events those are.
258
+ //
259
+ // It is NOT a defence against a keystroke from a session this pid has left: measured on
260
+ // oracle 2026-09-04 (raw lab S1-S6, meta-bridge-hook.log), every UserPromptSubmit named
261
+ // the native id its own pid's preceding SessionStart had established — 8 of 8, none
262
+ // otherwise. The hook runs synchronously inside the session's own process, so a UPS
263
+ // envelope is that session speaking, and the sender pointer it moves is authoritative.
264
+ const previous = armProvenanceFor(eventName) !== null
265
+ ? readMetaSenderMarker({ backend: "claude-code", ownerPid, verifyOwner: false })
266
+ : null;
267
+ if (previous && previous.gardenId !== gardenId) {
268
+ const retired = removeMetaReceiverMarker({ gardenId: previous.gardenId, ownerPid });
269
+ logLine("INFO", retired
270
+ ? `retired receiver marker ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`
271
+ : `no receiver marker to retire for ${previous.gardenId} — owner pid ${ownerPid} switched to ${gardenId} (event=${eventName}, source=${source})`);
272
+ }
213
273
  try {
214
274
  writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
215
- logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
275
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName}, source=${source})`);
216
276
  }
217
277
  catch (err) {
218
278
  logLine("WARN", `sender marker write failed (event=${eventName}, pid=${ownerPid}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
@@ -259,7 +319,7 @@ function main() {
259
319
  ownerPid,
260
320
  armProvenance,
261
321
  });
262
- logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
322
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName} source=${source}`);
263
323
  }
264
324
  catch (err) {
265
325
  logLine("WARN", `receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`);
@@ -66,7 +66,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
66
66
  import { z } from "zod";
67
67
 
68
68
  import { controlSocketPathIn, defaultControlSocketDir } from "../../../pi-extensions/lib/control-socket-path.js";
69
- import { receiverMarkerMatchesIdentity } from "../../../pi-extensions/lib/entwurf-deliverability.ts";
69
+ import { resolveMailboxReceiverFacts } from "../../../pi-extensions/lib/entwurf-deliverability.ts";
70
70
  import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.ts";
71
71
  import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.ts";
72
72
  import { computeSelfAddressability, type MetaDeliveryDomain } from "../../../pi-extensions/lib/entwurf-self-address.ts";
@@ -91,6 +91,8 @@ import {
91
91
  readActiveStoreEntries,
92
92
  readMetaInbox,
93
93
  readMetaReceiverMarker,
94
+ readMetaSenderMarker,
95
+ requireBackend,
94
96
  } from "../../../pi-extensions/lib/meta-session.ts";
95
97
  import { freshCall, renderFreshCall } from "../../../pi-extensions/lib/mux-fresh-call.ts";
96
98
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.ts";
@@ -272,9 +274,12 @@ async function buildTrustedMetaSenderEnvelope(cwd: string = process.cwd()): Prom
272
274
  // admits another self-fetch citizen.
273
275
  // none ← neither. omp today: no mailbox drain, no native-push adapter. Rendering
274
276
  // this as self-fetch printed a mailboxPath nothing drains.
275
- // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the receiver
276
- // presence marker (readMetaReceiverMarker folds a dead/reused owner to null, so a match
277
- // means a live, ARMED receiver the sender marker proves identity, never an armed watch).
277
+ // self-fetch (claude-code/copilot): can this citizen's own inbox wake? → the SHARED
278
+ // receiver composition `resolveMailboxReceiverFacts`, the same one the v2 dispatch seam
279
+ // uses, so a citizen's self-reported replyability can never disagree with what dispatch
280
+ // decides about it. It reads the presence marker (a dead/reused owner already folds to
281
+ // null) AND, where the watch owner is the sender-marker process, the #101 join that says
282
+ // the owner is still serving THIS garden rather than one it switched away from.
278
283
  // native-push (antigravity): there is no inbox and no watch. A reply is injected into a
279
284
  // live app-server conversation, so only an adapter probe can answer. Composing the
280
285
  // receiver atom here would demand `watchArmed` from a backend that never arms one, and
@@ -298,14 +303,17 @@ async function buildTrustedMetaSenderEnvelope(cwd: string = process.cwd()): Prom
298
303
  }
299
304
  : metaDeliveryDomain === "self-fetch"
300
305
  ? (() => {
301
- const receiver = readMetaReceiverMarker({ gardenId: identity.gardenId });
302
- const active = receiverMarkerMatchesIdentity(receiver, identity);
306
+ const receiver = resolveMailboxReceiverFacts(identity, {
307
+ readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId }),
308
+ readSenderMarker: (backend: string, ownerPid: number) =>
309
+ readMetaSenderMarker({ backend: requireBackend(backend), ownerPid }),
310
+ });
303
311
  return {
304
312
  origin: "meta-session" as const,
305
313
  metaDeliveryDomain,
306
314
  recordBacked: true,
307
- ownerAlive: active,
308
- watchArmed: active,
315
+ ownerAlive: receiver.ownerAlive,
316
+ watchArmed: receiver.watchArmed,
309
317
  };
310
318
  })()
311
319
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -143,6 +143,7 @@
143
143
  "check-omp-receive-arm": "./run.sh check-omp-receive-arm",
144
144
  "check-omp-fresh-preflight": "./run.sh check-omp-fresh-preflight",
145
145
  "check-hook-launch-topology": "./run.sh check-hook-launch-topology",
146
+ "check-meta-hook-session-switch": "./run.sh check-meta-hook-session-switch",
146
147
  "check-acp-carrier-augment": "./run.sh check-acp-carrier-augment",
147
148
  "check-agy-permission-matrix": "./run.sh check-agy-permission-matrix",
148
149
  "check-gate-qualification": "./run.sh check-gate-qualification",
@@ -165,7 +166,7 @@
165
166
  "check:toolchain": "pnpm lint && pnpm typecheck",
166
167
  "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface",
167
168
  "check:contracts": "./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-resume-args && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-entwurf-facts && ./run.sh check-control-socket-path && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment",
168
- "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
169
+ "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex",
169
170
  "check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
170
171
  },
171
172
  "pi": {
@@ -109,6 +109,110 @@ export function receiverMarkerMatchesIdentity(
109
109
  );
110
110
  }
111
111
 
112
+ // ── the watch-owner ↔ sender-marker join (#101 결함 B) ──────────────────────
113
+ // A receiver marker proves "a LIVE process once armed a watch for this garden".
114
+ // It does NOT prove "that process is serving this garden RIGHT NOW". One native
115
+ // process can hold markers for several gardens and drain exactly one of them:
116
+ // Claude Code's session switch (an in-session `/resume` or `/clear`) fires a second
117
+ // SessionStart inside the SAME pid under a NEW native session id, so the first
118
+ // garden's marker keeps naming a live owner forever while the doorbell it advertises
119
+ // is gone. Measured on oracle 2026-09-04 (#101): one pid held both markers, a
120
+ // 09:33 enqueue to the retired garden sat unread as a bare `.msg` for ≥50 minutes —
121
+ // the "enqueue into a void" this module's header exists to refuse.
122
+ //
123
+ // The join that decides it is already on disk: the SENDER marker is keyed by owner
124
+ // pid and rewritten with the CURRENT garden on every arm/prompt, so
125
+ // `meta-senders/<backend>/<ownerPid>.json.gardenId` is the answer to "which garden
126
+ // does this process serve now?". A receiver marker naming a different garden than
127
+ // its own owner's sender marker is a retired watch.
128
+ //
129
+ // SCOPE IS NOT UNIVERSAL, AND THE MARKER SAYS SO. The join only exists where the
130
+ // watch owner IS the process the sender marker is keyed to. That is true for the
131
+ // Claude hook (one `ownerPid` variable writes both markers —
132
+ // `meta-bridge-hook.ts` sender + receiver arm) and false BY CONSTRUCTION for
133
+ // Copilot, whose watch lives in a forked first-party extension child
134
+ // (`extension.mjs` writes `ownerPid: process.pid`) while its sender marker is keyed
135
+ // to the CLI parent (`meta-bridge-hook-copilot.ts` uses `process.ppid`). Applying
136
+ // the join there would make every Copilot citizen permanently undeliverable — a
137
+ // regression on a shipped lane, not a fix. `ownerKind` is recorded on the marker for
138
+ // exactly this reason ("the marker records which, because the pid a reader verifies
139
+ // differs" — AGENTS.md, self-fetch domain), so it is the axis, not `backend`.
140
+ //
141
+ // `omp-host` is a CANDIDATE, deliberately not admitted here: OMP already retires the
142
+ // previous garden in-process on its `/new` edge (the unarm the claude hook lacked),
143
+ // so the cell this join closes has a different owner there. Admitting it needs its
144
+ // own measurement, not this file's optimism.
145
+ export const SENDER_JOINED_RECEIVER_OWNER_KINDS: readonly string[] = ["claude-code-cli"];
146
+
147
+ /** Does this watch owner share its pid with the backend's sender marker? */
148
+ export function receiverOwnerKindJoinsSender(ownerKind: string): boolean {
149
+ return SENDER_JOINED_RECEIVER_OWNER_KINDS.includes(ownerKind);
150
+ }
151
+
152
+ /** The receiver-marker fields the join reads (a structural shape, like ReceiverIdentityFacts). */
153
+ export interface ReceiverOwnerFacts extends ReceiverIdentityFacts {
154
+ ownerPid: number;
155
+ ownerKind: string;
156
+ }
157
+
158
+ /** The sender-marker fields the join reads: which garden this owner pid serves NOW. */
159
+ export interface SenderOwnerFacts {
160
+ gardenId: string;
161
+ backend: string;
162
+ }
163
+
164
+ /**
165
+ * Is the watch owner still serving THIS garden? Fail-closed inside the join's scope:
166
+ * an absent/unreadable sender marker, or one naming another garden or backend, means
167
+ * the watch is retired. Outside the scope (an ownerKind whose watch owner is not the
168
+ * sender-marker process) the join does not apply and the marker's own live-owner guard
169
+ * is the whole rule — returning true here is NOT optimism, it is "this axis says
170
+ * nothing", and the caller has already required the marker to match the identity.
171
+ */
172
+ export function receiverOwnerServesGarden(
173
+ marker: ReceiverOwnerFacts,
174
+ senderMarker: SenderOwnerFacts | null | undefined,
175
+ ): boolean {
176
+ if (!receiverOwnerKindJoinsSender(marker.ownerKind)) return true;
177
+ return !!senderMarker && senderMarker.gardenId === marker.gardenId && senderMarker.backend === marker.backend;
178
+ }
179
+
180
+ /** The two marker readers the mailbox receiver facts are composed from (injected — this module does no IO). */
181
+ export interface MailboxReceiverReaders {
182
+ /** Read the receiver presence marker for a garden id (null = absent/corrupt/dead owner). */
183
+ readReceiverMarker: (gardenId: string) => ReceiverOwnerFacts | null;
184
+ /** Read the sender marker for an owner pid (null = absent/corrupt/dead owner). */
185
+ readSenderMarker: (backend: string, ownerPid: number) => SenderOwnerFacts | null;
186
+ }
187
+
188
+ /**
189
+ * THE single composition of the two mailbox receiver facts, over injected readers.
190
+ * Both production consumers — the v2 `mailboxDeliverabilityFor` seam and the MCP
191
+ * bridge's `entwurf_self` — call THIS, so a direct send, a re-resolved fallback send
192
+ * and a citizen's own replyability can never drift to different verdicts.
193
+ *
194
+ * ownerAlive — a live-owner marker that names THIS identity (the reader already ran
195
+ * the plausibility + start-key guards; this adds the identity match).
196
+ * watchArmed — that owner is still serving this garden (the join above). It is a
197
+ * MEASUREMENT, never a copy of ownerAlive: copying it is what let a
198
+ * retired watch read as an armed doorbell (#101 결함 B).
199
+ *
200
+ * `recordBacked` is NOT decided here — it stays the caller's explicit fact, so an
201
+ * absent record and a dead owner stay distinguishable in the reason string.
202
+ */
203
+ export function resolveMailboxReceiverFacts(
204
+ identity: ReceiverIdentityFacts,
205
+ readers: MailboxReceiverReaders,
206
+ ): { ownerAlive: boolean; watchArmed: boolean } {
207
+ const marker = readers.readReceiverMarker(identity.gardenId);
208
+ const ownerAlive = receiverMarkerMatchesIdentity(marker, identity);
209
+ if (!ownerAlive || !marker) return { ownerAlive: false, watchArmed: false };
210
+ const senderMarker = receiverOwnerKindJoinsSender(marker.ownerKind)
211
+ ? readers.readSenderMarker(marker.backend, marker.ownerPid)
212
+ : null;
213
+ return { ownerAlive, watchArmed: receiverOwnerServesGarden(marker, senderMarker) };
214
+ }
215
+
112
216
  export interface MailboxDeliverabilityFacts extends MetaReceiverActiveFacts {
113
217
  /** The target backend's wake mode (from the capability registry). */
114
218
  wakeMode?: WakeMode | string;
@@ -27,7 +27,13 @@
27
27
  * one diagnostic carries the fact. (pi + same-gid socket = the normal merge.)
28
28
  */
29
29
 
30
- import { type FactList, isOutOfSocketDomainGardenIdConflict, resolveFactList } from "./entwurf-facts.ts";
30
+ import {
31
+ type FactList,
32
+ isOutOfSocketDomainGardenIdConflict,
33
+ type PeerObserver,
34
+ resolveFactList,
35
+ } from "./entwurf-facts.ts";
36
+ import { observePeerFacts } from "./entwurf-peer-observe.ts";
31
37
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
32
38
  import {
33
39
  type ActiveStoreEntry,
@@ -93,6 +99,12 @@ export interface EntwurfFactsDeps {
93
99
  readRecord: (filename: string) => string;
94
100
  /** Socket axis: injected into scanSocketProbes (controlDir/readdir/probe). */
95
101
  socket?: Partial<SocketScanDeps>;
102
+ /** Observation axis (#101): per-citizen receiver + transcript facts. Defaults to the
103
+ * REAL measurement — the same seam shape `makeProductionEntwurfV2Deps` uses, so the two
104
+ * wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
105
+ * gate still drives the assembly with a fake and no filesystem. A caller that injects
106
+ * nothing gets facts; a caller that injects `() => UNOBSERVED_PEER` says so on every row. */
107
+ observe?: PeerObserver;
96
108
  }
97
109
 
98
110
  function diagnosticSortKey(d: EntwurfDiagnostic): string {
@@ -113,10 +125,12 @@ function diagnosticSortKey(d: EntwurfDiagnostic): string {
113
125
  }
114
126
 
115
127
  /**
116
- * Assemble the facts-only listing. Pure over its injected deps (no direct IO) so
117
- * the gate drives it without a filesystem; slice 4c supplies the real readdir /
118
- * readFile / probe. Probes are gid + liveness only (#50 C4 the per-socket
119
- * get_info enrich left with the socket-only quasi-citizen listing).
128
+ * Assemble the facts-only listing. Driven entirely through injected deps, so the gate
129
+ * runs it without a filesystem; slice 4c supplies the real readdir / readFile / probe.
130
+ * The one dep with a REAL default is `observe` (#101)see its comment: a per-citizen
131
+ * measurement both wiring sites must make identically is safer as one default than as two
132
+ * call-site arguments. Probes are gid + liveness only (#50 C4 — the per-socket get_info
133
+ * enrich left with the socket-only quasi-citizen listing).
120
134
  */
121
135
  export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfFactsResult> {
122
136
  const diagnostics: EntwurfDiagnostic[] = [];
@@ -183,7 +197,7 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
183
197
  // fire as the last line of defense, never caught here.
184
198
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
185
199
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
186
- const facts: FactList = resolveFactList(cleanIdentities, cleanProbes);
200
+ const facts: FactList = resolveFactList(cleanIdentities, cleanProbes, deps.observe ?? observePeerFacts);
187
201
 
188
202
  // 5. #50 C4 demotion: a record-less socket is a diagnostic, not a listing
189
203
  // section. One diagnostic per socket (subjects aggregate at render, F8);
@@ -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
@@ -47,8 +50,44 @@ export interface PeerFact {
47
50
  recordUpdatedAt: string;
48
51
  // — the single computed fact: 4-value liveness (R1/R3b). NOT a verb. —
49
52
  liveness: FactLiveness;
53
+ // — observed state (#101). Facts, not verbs: they say what was found on disk, not
54
+ // what a caller may do about it. For claude-code every row reads
55
+ // `liveness=unsupported` — the control-socket probe does not apply — so the listing
56
+ // showed nothing that separated a live citizen from a phantom, and a caller picking
57
+ // "the newest record in this cwd" picked the phantom as often as the real one.
58
+ receiver: ReceiverObservation;
59
+ transcript: TranscriptObservation;
50
60
  }
51
61
 
62
+ /**
63
+ * The mailbox receiver axis, as OBSERVED for this citizen.
64
+ * active a presence marker whose owner is live AND still serving this garden.
65
+ * inactive a marker exists but fails that test (dead owner, drifted identity, or an
66
+ * owner that switched to another garden).
67
+ * none no marker at all — never armed, or retired.
68
+ * n/a this backend has no mailbox receiver axis (pi, antigravity).
69
+ * unobserved nobody measured it. Only reachable when a caller drives the fact core
70
+ * without an observer; it is the honest word for "not looked at", never a
71
+ * quiet stand-in for `none`.
72
+ */
73
+ export type ReceiverObservation = "active" | "inactive" | "none" | "n/a" | "unobserved";
74
+
75
+ /** Does the recorded transcript exist on disk? `absent` is what a never-used registration
76
+ * looks like: a record with no conversation behind it. */
77
+ export type TranscriptObservation = "exists" | "absent" | "unobserved";
78
+
79
+ export interface PeerObservations {
80
+ receiver: ReceiverObservation;
81
+ transcript: TranscriptObservation;
82
+ }
83
+
84
+ /** What a caller that measured nothing must say. Explicit and greppable — a row that
85
+ * silently defaulted to `none`/`absent` would be a fabricated fact. */
86
+ export const UNOBSERVED_PEER: PeerObservations = { receiver: "unobserved", transcript: "unobserved" };
87
+
88
+ /** Measure the two observed axes for one citizen. Injected, so this module stays pure. */
89
+ export type PeerObserver = (identity: MetaIdentity) => PeerObservations;
90
+
52
91
  /**
53
92
  * Compose a `PeerFact` from a citizen's identity and an optional socket probe.
54
93
  *
@@ -61,7 +100,11 @@ export interface PeerFact {
61
100
  *
62
101
  * Pure: same inputs → same output, no IO.
63
102
  */
64
- export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness | null): PeerFact {
103
+ export function resolvePeerFact(
104
+ identity: MetaIdentity,
105
+ socket: SocketLiveness | null,
106
+ observations: PeerObservations = UNOBSERVED_PEER,
107
+ ): PeerFact {
65
108
  return {
66
109
  gardenId: identity.gardenId,
67
110
  backend: identity.backend,
@@ -71,6 +114,8 @@ export function resolvePeerFact(identity: MetaIdentity, socket: SocketLiveness |
71
114
  createdAt: identity.createdAt,
72
115
  recordUpdatedAt: identity.recordUpdatedAt,
73
116
  liveness: factLivenessOf(identity.backend, socket),
117
+ receiver: observations.receiver,
118
+ transcript: observations.transcript,
74
119
  };
75
120
  }
76
121
 
@@ -172,7 +217,11 @@ export interface FactList {
172
217
  * A gardenId is never emitted as both a `PeerFact` and a `RecordLessSocketFact`;
173
218
  * a record-less socket becomes a `PeerFact` the moment a record claims the gid.
174
219
  */
175
- export function resolveFactList(identities: MetaIdentity[], socketProbes: SocketProbe[]): FactList {
220
+ export function resolveFactList(
221
+ identities: MetaIdentity[],
222
+ socketProbes: SocketProbe[],
223
+ observe: PeerObserver = () => UNOBSERVED_PEER,
224
+ ): FactList {
176
225
  const probeMap = new Map<string, SocketProbe>();
177
226
  for (const probe of socketProbes) {
178
227
  if (probeMap.has(probe.gardenId)) {
@@ -207,7 +256,7 @@ export function resolveFactList(identities: MetaIdentity[], socketProbes: Socket
207
256
  }
208
257
  socket = null;
209
258
  }
210
- peers.push(resolvePeerFact(identity, socket));
259
+ peers.push(resolvePeerFact(identity, socket, observe(identity)));
211
260
  consumed.add(gid);
212
261
  }
213
262
 
@@ -0,0 +1,77 @@
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
+
23
+ import * as fs from "node:fs";
24
+ import { resolveMailboxReceiverFacts } from "./entwurf-deliverability.ts";
25
+ import type { PeerObservations, ReceiverObservation, TranscriptObservation } from "./entwurf-facts.ts";
26
+ import {
27
+ type MetaBackend,
28
+ type MetaIdentity,
29
+ metaCapabilityFor,
30
+ metaReceiverMarkerPath,
31
+ readMetaReceiverMarker,
32
+ readMetaSenderMarker,
33
+ requireBackend,
34
+ } from "./meta-session.ts";
35
+
36
+ /**
37
+ * The mailbox receiver axis for one citizen.
38
+ *
39
+ * `n/a` is not a failure: a backend with no mailbox (pi's control socket, antigravity's
40
+ * native push) has no receiver marker to be right or wrong about, and printing `none`
41
+ * there would invent a missing thing. The `inactive` / `none` split is what a reader
42
+ * needs to tell "a watch was armed and is no longer valid" from "no watch was ever
43
+ * armed here", so it is taken from the marker FILE's existence rather than from the
44
+ * reader's null — the reader folds absent, corrupt and dead-owner into one null.
45
+ */
46
+ function observeReceiver(identity: MetaIdentity): ReceiverObservation {
47
+ if (metaCapabilityFor(identity.backend).wakeMode !== "self-fetch") return "n/a";
48
+ const facts = resolveMailboxReceiverFacts(identity, {
49
+ readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId }),
50
+ readSenderMarker: (backend: string, ownerPid: number) =>
51
+ readMetaSenderMarker({ backend: requireBackend(backend) as MetaBackend, ownerPid }),
52
+ });
53
+ if (facts.ownerAlive && facts.watchArmed) return "active";
54
+ return fs.existsSync(metaReceiverMarkerPath(identity.gardenId)) ? "inactive" : "none";
55
+ }
56
+
57
+ /**
58
+ * Does the recorded transcript exist? A record is minted at SessionStart, but a harness
59
+ * that writes its transcript lazily (Claude Code writes on the first turn) leaves a
60
+ * citizen with no conversation behind it until someone actually says something. The PATH
61
+ * stays private — only its existence crosses to the listing.
62
+ */
63
+ function observeTranscript(identity: MetaIdentity): TranscriptObservation {
64
+ try {
65
+ // A record with no recorded transcript path has nothing to stat — that is the same
66
+ // observable state as a path that is not there, and neither is an `exists` claim.
67
+ return identity.transcriptPath && fs.existsSync(identity.transcriptPath) ? "exists" : "absent";
68
+ } catch {
69
+ // A transcript we cannot stat is not a transcript we can claim exists.
70
+ return "absent";
71
+ }
72
+ }
73
+
74
+ /** The production observer: both axes, measured for one citizen. */
75
+ export function observePeerFacts(identity: MetaIdentity): PeerObservations {
76
+ return { receiver: observeReceiver(identity), transcript: observeTranscript(identity) };
77
+ }
@@ -39,9 +39,20 @@ export interface EntwurfPeersRender {
39
39
  payload: EntwurfPeersPayload;
40
40
  }
41
41
 
42
+ // `receiver` and `transcript` are OBSERVED facts, not routing (#101): they say what was
43
+ // found on disk for this citizen, and a caller still asks entwurf_v2 what may be done
44
+ // about it. They earn a column because for every claude-code row `liveness` reads
45
+ // `unsupported` — the socket probe does not apply — so two rows in one cwd, a live
46
+ // conversation and an abandoned startup session whose transcript was never written, used to
47
+ // be indistinguishable here. `receiver=active|inactive|none|n/a` is a projection of the
48
+ // same measurement dispatch uses; `transcript=exists|absent` is the record's transcript,
49
+ // existence only, never the path.
42
50
  function renderPeerLine(p: PeerFact): string {
43
51
  const model = p.model ?? "(unknown)";
44
- return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}`;
52
+ return (
53
+ `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} receiver=${p.receiver} ` +
54
+ `transcript=${p.transcript} cwd=${p.cwd} model=${model}`
55
+ );
45
56
  }
46
57
 
47
58
  function renderDiagnosticLine(d: EntwurfDiagnostic): string {
@@ -128,13 +128,24 @@ export type ExecutionPlan =
128
128
  // claim so 5c's at-most-once re-resolve runs under the same nonce) and null for the
129
129
  // lock-free meta-mailbox path (?7).
130
130
  //
131
- // A reject's optional machine-readable diagnostic. Only `target-locked` carries one:
132
- // the `LockConflict` (holder pid/host/createdAt, lockPath, human detail) the lock
133
- // primitive produced on contention. It rides ALONGSIDE the receipt the receipt
134
- // schema is unchanged; 5d's surface renders it onto the reject. (B3: without this the
135
- // holder evidence was dropped at the decider boundary, so a PID-reuse permanent lock
136
- // could not be observed/cleared — F2-P2 "관측 가능해야 수용".)
137
- export type RejectDiagnostic = { kind: "target-locked"; conflict: LockConflict };
131
+ // A reject's optional machine-readable diagnostic, riding ALONGSIDE the receipt — the
132
+ // receipt schema is unchanged and 5d's surface renders the diagnostic onto the reject.
133
+ // Two kinds, both here for the same reason: the evidence that decided the reject was
134
+ // produced one layer down and would otherwise be dropped at this boundary, leaving the
135
+ // caller a verdict with no way to see or clear its cause (F2-P2 "관측 가능해야 수용").
136
+ //
137
+ // target-locked the `LockConflict` (holder pid/host/createdAt, lockPath, human
138
+ // detail) the lock primitive produced on contention. B3: without
139
+ // it a PID-reuse permanent lock could not be observed/cleared.
140
+ // mailbox-undeliverable WHICH receiver axis failed, in the deliverability predicate's own
141
+ // words — no backing record vs. a dead owner vs. a watch that is no
142
+ // longer armed are three different situations with three different
143
+ // fixes, and the bare `mailbox-undeliverable` reason told a caller
144
+ // none of them (#101 갭 C). The predicate already computes the
145
+ // sentence; this carries it instead of discarding it.
146
+ export type RejectDiagnostic =
147
+ | { kind: "target-locked"; conflict: LockConflict }
148
+ | { kind: "mailbox-undeliverable"; reason: string };
138
149
 
139
150
  export type DispatchDecision =
140
151
  | { kind: "reject"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
@@ -327,7 +338,14 @@ export async function decideDispatch(input: DispatchInput, deps: DispatchDecider
327
338
  // fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
328
339
  const deliverability = await deps.mailboxDeliverabilityFor(identity);
329
340
  const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
330
- if (!receipt.ok) return reject(receipt);
341
+ // The predicate's reason travels with the reject (#101 갭 C). It is attached only when
342
+ // undeliverability is what produced the reject — an intent-shaped refusal on a
343
+ // DELIVERABLE target must not be dressed up as a receiver problem.
344
+ if (!receipt.ok) {
345
+ return deliverability.deliverable
346
+ ? reject(receipt)
347
+ : reject(receipt, { kind: "mailbox-undeliverable", reason: deliverability.reason });
348
+ }
331
349
  // the only allow cell here is fire-and-forget → meta-mailbox send.
332
350
  const plan: ExecutionPlan = {
333
351
  transport: "meta-mailbox",