@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,1951 @@
1
+ /**
2
+ * meta-session — 1.0.0 garden-native meta-bridge, step 2: the RECORD AUTHORITY.
3
+ *
4
+ * Backend-agnostic garden layer (#30). A *meta-session* is the bib card for a
5
+ * native backend session (Claude Code / Antigravity / Codex) that has NO pi
6
+ * JSONL of its own: an opaque pointer record that makes the native session a
7
+ * garden citizen — addressable + wakeable by a garden id — WITHOUT pretending pi
8
+ * owns its transcript (Hard Rule #8: reference the backend transcript, never
9
+ * hydrate or replay it).
10
+ *
11
+ * Two layers, clearly sectioned:
12
+ * 1. RECORD functions + types (mint / serialize / parse / scanByNativeId /
13
+ * decideUpsert / read-receipt mutators), the backend-agnostic authority.
14
+ * Pure beyond an injected `now`, with ONE exception since 3D-3: mint/parse
15
+ * read backend capability (wakeMode/deliveryLevel) from the packaged registry
16
+ * via a cached fs read (loadMetaCapabilityRegistry) — see that seam below.
17
+ * 2. The thin FS-BOUND STORE (step 3): `upsertMetaSession` wraps the pure core
18
+ * (readdir → `scanByNativeId` → `decideUpsert` → atomic write) with the real
19
+ * filesystem. It lives in this module (not a sibling `*-store.ts`) on purpose:
20
+ * the typecheck fence forbids a root-config lib importing another `.ts` lib
21
+ * via a `.ts` specifier (tsc-emit) while the same `.js` specifier is
22
+ * unresolvable under `node --experimental-strip-types`, so a separate store
23
+ * file could not be unit-tested by the deterministic strip-types gate. Only
24
+ * node builtins are added here, so `check-meta-session` stays strip-types
25
+ * clean. The hook deploy + the thin CLI/argv shell that invokes this is
26
+ * step 4 (its stdin contract couples to the Claude `SessionStart` payload).
27
+ *
28
+ * Cutting the record/seam FIRST is deliberate ("record authority FIRST, hook
29
+ * LAST"): the schema and the lookup authority are backend-agnostic, so the
30
+ * per-backend adapter seam gets cut here, before any "hook = Claude Code"
31
+ * assumption can ossify.
32
+ *
33
+ * Authority rules imported from the 0.9.0 substrate and #30 refinements:
34
+ * - garden id = `generateSessionId` (the single SSOT grammar), minted at the
35
+ * session's true birth. Reused, never re-derived.
36
+ * - lookup authority = SCAN the record bodies by top-level `native_session_id`
37
+ * (see scanByNativeId), symmetric with 0.9.0 `findSessionFileById`. Any
38
+ * native→garden index is an OPTIONAL derived cache, never the source of
39
+ * truth — "needs a DB" is the denote-instinct tripwire.
40
+ * - create-vs-attach keys on RECORD EXISTENCE, not the backend `source` field
41
+ * (decideUpsert). Idempotent: duplicate hook fires / same-id re-entry are
42
+ * absorbed. The CLI is named `upsert` so no one re-introduces `source`
43
+ * branching.
44
+ * - read-receipt is PRE-DRILLED into the schema now (bbot review #4). The
45
+ * mailbox/outbox is post-MVP, but retrofitting the receipt field later would
46
+ * touch the schema twice. The "last 1 cm" (did the body reach model-visible
47
+ * context?) is kept honest as per-peer metadata, never abstracted away.
48
+ *
49
+ * Crash, don't warn: every malformed record / bad id / bad backend throws
50
+ * `MetaRecordError`. A broken meta-record must surface as a broken meta-record.
51
+ */
52
+
53
+ import { execFileSync } from "node:child_process";
54
+ import * as crypto from "node:crypto";
55
+ import * as fs from "node:fs";
56
+ import * as os from "node:os";
57
+ import * as path from "node:path";
58
+ import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Errors
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** A meta-record is malformed, or an input violates the record contract. */
65
+ export class MetaRecordError extends Error {
66
+ constructor(message: string) {
67
+ super(message);
68
+ this.name = "MetaRecordError";
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Schema
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Bump only on a breaking record-shape change; parse refuses other versions. */
77
+ export const META_SCHEMA_VERSION = 1 as const;
78
+
79
+ /**
80
+ * The three native meta-bridge backends, declared from the start so the
81
+ * per-backend adapter seam is forced (a different native layout each — that
82
+ * difference is the whole reason for a thin adapter). Discriminator field on
83
+ * every record.
84
+ */
85
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex"] as const;
86
+ export type MetaBackend = (typeof META_BACKENDS)[number];
87
+
88
+ /**
89
+ * How the body reaches model-visible context — the honest "last 1 cm". Claude's
90
+ * doorbell wakes the model but the body is SELF-FETCHED (the model must
91
+ * voluntarily call its inbox-read MCP tool); agy/codex DIRECT-INJECT the body
92
+ * into the turn. This is exposed as peer metadata, never abstracted, so "I sent
93
+ * it, why didn't it read?" never becomes a debugging hole.
94
+ */
95
+ export type WakeMode = "self-fetch" | "direct-inject";
96
+
97
+ /**
98
+ * Static, backend-derived honesty metadata (the data half of the adapter seam).
99
+ * `wakeMode` + `deliveryLevel` ride onto each record at mint so the sender
100
+ * contract can stay uniform on address/queue while being honest on HOW delivery
101
+ * lands. `deliveryLevel` is a DELIVERY.md D-coordinate (a capability hint, not a
102
+ * guarantee). `nativeIdLabel` documents what the join key actually is per
103
+ * backend (Claude sessionId / agy conversationId / codex threadId) — naming, not
104
+ * behavior; the behavioral half (where sessions live, how to read liveness, hook
105
+ * deploy unit) lands with the step-4 adapters.
106
+ */
107
+ export interface MetaBackendDescriptor {
108
+ backend: MetaBackend;
109
+ wakeMode: WakeMode;
110
+ deliveryLevel: string;
111
+ nativeIdLabel: string;
112
+ }
113
+
114
+ export const META_BACKEND_DESCRIPTORS: Record<MetaBackend, MetaBackendDescriptor> = {
115
+ "claude-code": {
116
+ backend: "claude-code",
117
+ wakeMode: "self-fetch",
118
+ deliveryLevel: "D6",
119
+ nativeIdLabel: "sessionId",
120
+ },
121
+ antigravity: {
122
+ backend: "antigravity",
123
+ wakeMode: "direct-inject",
124
+ deliveryLevel: "D6",
125
+ nativeIdLabel: "conversationId",
126
+ },
127
+ codex: {
128
+ backend: "codex",
129
+ wakeMode: "direct-inject",
130
+ deliveryLevel: "D6",
131
+ nativeIdLabel: "threadId",
132
+ },
133
+ };
134
+
135
+ /**
136
+ * The read-receipt aspect, PRE-DRILLED (bbot review #4). The mailbox/outbox is
137
+ * post-MVP — these timestamps stay null until that path lands — but the slot is
138
+ * here so adding it later does not touch the schema twice.
139
+ * - lastEnqueuedAt : a sender wrote a message body to this peer's mailbox.
140
+ * - lastDeliveredAt: the doorbell rang / the body was injected ("`.delivered`"
141
+ * marker). For Claude self-fetch this means "doorbell rang", NOT "model read".
142
+ * - lastReadAt : the inbox-read MCP call — THIS is the real read-receipt
143
+ * (makes Claude's D7 observable). For direct-inject backends delivered==read.
144
+ */
145
+ export interface MetaDelivery {
146
+ wakeMode: WakeMode;
147
+ deliveryLevel: string;
148
+ lastEnqueuedAt: string | null;
149
+ lastDeliveredAt: string | null;
150
+ lastReadAt: string | null;
151
+ }
152
+
153
+ /**
154
+ * The opaque pointer record. Body is SSOT; the on-disk filename
155
+ * (`<garden_id>.meta.json`) is only a denote-sortable surface (garden_id leads
156
+ * with the birth timestamp). NEVER parse the filename for authority.
157
+ */
158
+ export interface MetaRecord {
159
+ schemaVersion: typeof META_SCHEMA_VERSION;
160
+ gardenId: string;
161
+ backend: MetaBackend;
162
+ nativeSessionId: string;
163
+ transcriptPath: string;
164
+ cwd: string;
165
+ createdAt: string;
166
+ lastSeen: string;
167
+ delivery: MetaDelivery;
168
+ }
169
+
170
+ /** Fields the caller supplies; garden id + timestamps + delivery are derived. */
171
+ export interface MetaMintInput {
172
+ backend: MetaBackend;
173
+ nativeSessionId: string;
174
+ transcriptPath: string;
175
+ cwd: string;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Validation helpers (crash, don't warn)
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function requireNonEmptyString(value: unknown, field: string): string {
183
+ if (typeof value !== "string" || value.length === 0) {
184
+ throw new MetaRecordError(`meta-record field "${field}" must be a non-empty string (got ${describe(value)}).`);
185
+ }
186
+ return value;
187
+ }
188
+
189
+ function requireBackend(value: unknown): MetaBackend {
190
+ if (typeof value !== "string" || !META_BACKENDS.includes(value as MetaBackend)) {
191
+ throw new MetaRecordError(
192
+ `meta-record "backend" must be one of ${META_BACKENDS.join(" | ")} (got ${describe(value)}).`,
193
+ );
194
+ }
195
+ return value as MetaBackend;
196
+ }
197
+
198
+ function requireGardenId(value: unknown): string {
199
+ const id = requireNonEmptyString(value, "gardenId");
200
+ if (!SESSION_ID_RE.test(id)) {
201
+ throw new MetaRecordError(`meta-record "gardenId" must match YYYYMMDDTHHMMSS-[0-9a-f]{6} (got "${id}").`);
202
+ }
203
+ return id;
204
+ }
205
+
206
+ function requireNullableString(value: unknown, field: string): string | null {
207
+ if (value === null) return null;
208
+ if (typeof value !== "string" || value.length === 0) {
209
+ throw new MetaRecordError(
210
+ `meta-record field "${field}" must be a non-empty string or null (got ${describe(value)}).`,
211
+ );
212
+ }
213
+ return value;
214
+ }
215
+
216
+ function describe(value: unknown): string {
217
+ if (value === null) return "null";
218
+ if (typeof value === "string") return `string ${JSON.stringify(value)}`;
219
+ return `${typeof value}`;
220
+ }
221
+
222
+ function isoNow(now: Date): string {
223
+ return now.toISOString();
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Record functions (pure beyond an injected `now`, except mint/parse read the
228
+ // packaged capability registry via the cached metaCapabilityFor seam — 3D-3)
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Mint a brand-new meta-record at the session's true birth. Generates the garden
233
+ * id from the SSOT grammar, stamps createdAt == lastSeen, and seeds the
234
+ * delivery/read-receipt slot from the backend descriptor (timestamps null).
235
+ */
236
+ export function mintMetaRecord(input: MetaMintInput, now: Date = new Date()): MetaRecord {
237
+ const backend = requireBackend(input.backend);
238
+ // 3D-3: backend honesty metadata is sourced from the capability registry, not
239
+ // META_BACKEND_DESCRIPTORS (which now survives only as the drift-guard reference).
240
+ const capability = metaCapabilityFor(backend);
241
+ const ts = isoNow(now);
242
+ return {
243
+ schemaVersion: META_SCHEMA_VERSION,
244
+ gardenId: generateSessionId(now),
245
+ backend,
246
+ nativeSessionId: requireNonEmptyString(input.nativeSessionId, "nativeSessionId"),
247
+ transcriptPath: requireNonEmptyString(input.transcriptPath, "transcriptPath"),
248
+ cwd: requireNonEmptyString(input.cwd, "cwd"),
249
+ createdAt: ts,
250
+ lastSeen: ts,
251
+ delivery: {
252
+ wakeMode: capability.wakeMode,
253
+ deliveryLevel: capability.deliveryLevel,
254
+ lastEnqueuedAt: null,
255
+ lastDeliveredAt: null,
256
+ lastReadAt: null,
257
+ },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Canonical serialization: stable key order (object built in order), 2-space
263
+ * indent, trailing newline. Deterministic — the same record always serializes
264
+ * byte-identically, so a temp-dir test can assert round-trip stability.
265
+ */
266
+ export function serializeMetaRecord(record: MetaRecord): string {
267
+ const ordered = {
268
+ schemaVersion: record.schemaVersion,
269
+ gardenId: record.gardenId,
270
+ backend: record.backend,
271
+ nativeSessionId: record.nativeSessionId,
272
+ transcriptPath: record.transcriptPath,
273
+ cwd: record.cwd,
274
+ createdAt: record.createdAt,
275
+ lastSeen: record.lastSeen,
276
+ delivery: {
277
+ wakeMode: record.delivery.wakeMode,
278
+ deliveryLevel: record.delivery.deliveryLevel,
279
+ lastEnqueuedAt: record.delivery.lastEnqueuedAt,
280
+ lastDeliveredAt: record.delivery.lastDeliveredAt,
281
+ lastReadAt: record.delivery.lastReadAt,
282
+ },
283
+ };
284
+ return `${JSON.stringify(ordered, null, 2)}\n`;
285
+ }
286
+
287
+ /** Parse + fully validate untrusted JSON text into a MetaRecord. Throws on any drift. */
288
+ export function parseMetaRecord(json: string): MetaRecord {
289
+ let raw: unknown;
290
+ try {
291
+ raw = JSON.parse(json);
292
+ } catch (err) {
293
+ throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
294
+ }
295
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
296
+ throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
297
+ }
298
+ const obj = raw as Record<string, unknown>;
299
+ if (obj.schemaVersion !== META_SCHEMA_VERSION) {
300
+ throw new MetaRecordError(
301
+ `meta-record "schemaVersion" must be ${META_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
302
+ );
303
+ }
304
+ const delivery = obj.delivery;
305
+ if (typeof delivery !== "object" || delivery === null || Array.isArray(delivery)) {
306
+ throw new MetaRecordError(`meta-record "delivery" must be an object (got ${describe(delivery)}).`);
307
+ }
308
+ const d = delivery as Record<string, unknown>;
309
+ const backend = requireBackend(obj.backend);
310
+ const wakeMode = requireNonEmptyString(d.wakeMode, "delivery.wakeMode");
311
+ if (wakeMode !== "self-fetch" && wakeMode !== "direct-inject") {
312
+ throw new MetaRecordError(
313
+ `meta-record "delivery.wakeMode" must be self-fetch | direct-inject (got "${wakeMode}").`,
314
+ );
315
+ }
316
+ // wakeMode is backend-DETERMINED (Claude doorbell = self-fetch; agy/codex =
317
+ // direct-inject). A record whose stored wakeMode contradicts its backend is
318
+ // corrupt — a Claude record claiming direct-inject would silently mis-route
319
+ // the "last 1 cm" delivery contract. Refuse it. 3D-3: the canonical is sourced
320
+ // from the capability registry, not META_BACKEND_DESCRIPTORS.
321
+ const canonicalWakeMode = metaCapabilityFor(backend).wakeMode;
322
+ if (wakeMode !== canonicalWakeMode) {
323
+ throw new MetaRecordError(
324
+ `meta-record "delivery.wakeMode" (${wakeMode}) contradicts backend "${backend}" ` +
325
+ `(canonical ${canonicalWakeMode}). Delivery mode is backend-determined; this record is corrupt.`,
326
+ );
327
+ }
328
+ return {
329
+ schemaVersion: META_SCHEMA_VERSION,
330
+ gardenId: requireGardenId(obj.gardenId),
331
+ backend,
332
+ nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
333
+ transcriptPath: requireNonEmptyString(obj.transcriptPath, "transcriptPath"),
334
+ cwd: requireNonEmptyString(obj.cwd, "cwd"),
335
+ createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
336
+ lastSeen: requireNonEmptyString(obj.lastSeen, "lastSeen"),
337
+ delivery: {
338
+ wakeMode,
339
+ deliveryLevel: requireNonEmptyString(d.deliveryLevel, "delivery.deliveryLevel"),
340
+ lastEnqueuedAt: requireNullableString(d.lastEnqueuedAt, "delivery.lastEnqueuedAt"),
341
+ lastDeliveredAt: requireNullableString(d.lastDeliveredAt, "delivery.lastDeliveredAt"),
342
+ lastReadAt: requireNullableString(d.lastReadAt, "delivery.lastReadAt"),
343
+ },
344
+ };
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // meta-record v2 — identity-only shape (0.11 Stage 0 step 3A)
349
+ //
350
+ // v2 strips the delivery/read-receipt aspect OUT of the record (it moves to a
351
+ // separate mailbox state file in step 3B) and keeps only IDENTITY: who this
352
+ // citizen is, never its delivery bookkeeping. The deltas vs v1 (verified
353
+ // against the frozen ledger in NEXT.md):
354
+ // - backend gains `pi` (the 4th meta backend — pi sessions become citizens)
355
+ // - transcriptPath required → nullable (pi birth may not know it yet)
356
+ // - new nullable identity fields: model, parentGardenId, isEntwurf
357
+ // - lastSeen → recordUpdatedAt (a record touch time, NOT liveness)
358
+ // - delivery{} removed entirely
359
+ //
360
+ // This block is READER + NORMALIZER ONLY. There is deliberately NO v2 writer /
361
+ // serializer / disk upsert here yet: step 3A's gate is "synthetic v1 fixture →
362
+ // normalized v2 identity golden GREEN", and 3A must not introduce a v2 writer
363
+ // before that golden + its GPT review (NEXT.md 끊을 지점 ①).
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /** Bump only on a breaking v2 identity-shape change; the v2 parser refuses other versions. */
367
+ export const META_SCHEMA_VERSION_V2 = 2 as const;
368
+
369
+ /** v2 backends = the three v1 backends + `pi` (pi joins as the 4th meta citizen). */
370
+ export const META_BACKENDS_V2 = ["claude-code", "antigravity", "codex", "pi"] as const;
371
+ export type MetaBackendV2 = (typeof META_BACKENDS_V2)[number];
372
+
373
+ /**
374
+ * The v2 identity-only record. Field order mirrors the frozen ledger's jsonc so
375
+ * a future serializer stays byte-stable. No delivery aspect — that is mailbox
376
+ * state (step 3B), referenced by gardenId, never embedded in identity.
377
+ */
378
+ export interface MetaIdentity {
379
+ schemaVersion: typeof META_SCHEMA_VERSION_V2;
380
+ gardenId: string;
381
+ backend: MetaBackendV2;
382
+ nativeSessionId: string;
383
+ cwd: string;
384
+ model: string | null;
385
+ transcriptPath: string | null;
386
+ parentGardenId: string | null;
387
+ isEntwurf: boolean;
388
+ createdAt: string;
389
+ recordUpdatedAt: string;
390
+ }
391
+
392
+ function requireBackendV2(value: unknown): MetaBackendV2 {
393
+ if (typeof value !== "string" || !META_BACKENDS_V2.includes(value as MetaBackendV2)) {
394
+ throw new MetaRecordError(
395
+ `meta-record "backend" must be one of ${META_BACKENDS_V2.join(" | ")} (got ${describe(value)}).`,
396
+ );
397
+ }
398
+ return value as MetaBackendV2;
399
+ }
400
+
401
+ function requireBoolean(value: unknown, field: string): boolean {
402
+ if (typeof value !== "boolean") {
403
+ throw new MetaRecordError(`meta-record field "${field}" must be a boolean (got ${describe(value)}).`);
404
+ }
405
+ return value;
406
+ }
407
+
408
+ function requireNullableGardenId(value: unknown, field: string): string | null {
409
+ if (value === null) return null;
410
+ const id = requireNonEmptyString(value, field);
411
+ if (!SESSION_ID_RE.test(id)) {
412
+ throw new MetaRecordError(
413
+ `meta-record "${field}" must be null or match YYYYMMDDTHHMMSS-[0-9a-f]{6} (got "${id}").`,
414
+ );
415
+ }
416
+ return id;
417
+ }
418
+
419
+ /**
420
+ * Explicit v1 name for the dual-read pair. `parseMetaRecord` predates the v2
421
+ * split and stays the canonical v1 parser (existing callers untouched); this
422
+ * alias makes the V1/V2 symmetry legible at call sites.
423
+ */
424
+ export const parseMetaRecordV1 = parseMetaRecord;
425
+
426
+ /**
427
+ * The EXACT key set a v2 identity record may carry. v2 is a fresh schema, so the
428
+ * parser is strict: any key outside this set — including stale v1 fields like
429
+ * `delivery` or `lastSeen` — is a half-migrated / corrupt record and must
430
+ * fail-fast, never be silently normalized away. Frozen against the ledger jsonc.
431
+ */
432
+ const META_IDENTITY_V2_KEYS: readonly string[] = [
433
+ "schemaVersion",
434
+ "gardenId",
435
+ "backend",
436
+ "nativeSessionId",
437
+ "cwd",
438
+ "model",
439
+ "transcriptPath",
440
+ "parentGardenId",
441
+ "isEntwurf",
442
+ "createdAt",
443
+ "recordUpdatedAt",
444
+ ];
445
+
446
+ /** Parse + fully validate untrusted JSON into a v2 MetaIdentity. Throws on any drift. */
447
+ export function parseMetaRecordV2(json: string): MetaIdentity {
448
+ let raw: unknown;
449
+ try {
450
+ raw = JSON.parse(json);
451
+ } catch (err) {
452
+ throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
453
+ }
454
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
455
+ throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
456
+ }
457
+ const obj = raw as Record<string, unknown>;
458
+ if (obj.schemaVersion !== META_SCHEMA_VERSION_V2) {
459
+ throw new MetaRecordError(
460
+ `meta-record "schemaVersion" must be ${META_SCHEMA_VERSION_V2} (got ${describe(obj.schemaVersion)}).`,
461
+ );
462
+ }
463
+ // Strict keyset: reject stale v1 fields (delivery/lastSeen) and any unknown
464
+ // key. A v2 record carrying v1 leftovers is half-migrated/corrupt — surface
465
+ // it, do not silently drop it during normalize.
466
+ const stray = Object.keys(obj).filter((k) => !META_IDENTITY_V2_KEYS.includes(k));
467
+ if (stray.length > 0) {
468
+ throw new MetaRecordError(
469
+ `v2 meta-record carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
470
+ `(allowed: ${META_IDENTITY_V2_KEYS.join(", ")}). Stale v1 fields (delivery/lastSeen) or unknown keys are rejected.`,
471
+ );
472
+ }
473
+ return {
474
+ schemaVersion: META_SCHEMA_VERSION_V2,
475
+ gardenId: requireGardenId(obj.gardenId),
476
+ backend: requireBackendV2(obj.backend),
477
+ nativeSessionId: requireNonEmptyString(obj.nativeSessionId, "nativeSessionId"),
478
+ cwd: requireNonEmptyString(obj.cwd, "cwd"),
479
+ model: requireNullableString(obj.model, "model"),
480
+ transcriptPath: requireNullableString(obj.transcriptPath, "transcriptPath"),
481
+ parentGardenId: requireNullableGardenId(obj.parentGardenId, "parentGardenId"),
482
+ isEntwurf: requireBoolean(obj.isEntwurf, "isEntwurf"),
483
+ createdAt: requireNonEmptyString(obj.createdAt, "createdAt"),
484
+ recordUpdatedAt: requireNonEmptyString(obj.recordUpdatedAt, "recordUpdatedAt"),
485
+ };
486
+ }
487
+
488
+ /**
489
+ * Lazy-normalize a parsed v1 OR v2 record into the v2 identity shape. The dual-
490
+ * read seam: consumers read either disk version and normalize to ONE identity
491
+ * type. Discriminates on `schemaVersion` (TS narrows the union):
492
+ * - v1: lastSeen → recordUpdatedAt, delivery dropped, model/parentGardenId
493
+ * default null, isEntwurf default false, transcriptPath carried (v1 always
494
+ * has one).
495
+ * - v2: already identity — returned as a fresh, key-stable copy.
496
+ * v1 identity is LOSSLESS through this (the golden gate proves it); the only v1
497
+ * data not carried is delivery, which is intentionally out of identity.
498
+ */
499
+ export function normalizeMetaIdentity(record: MetaRecord | MetaIdentity): MetaIdentity {
500
+ if (record.schemaVersion === META_SCHEMA_VERSION_V2) {
501
+ return {
502
+ schemaVersion: META_SCHEMA_VERSION_V2,
503
+ gardenId: record.gardenId,
504
+ backend: record.backend,
505
+ nativeSessionId: record.nativeSessionId,
506
+ cwd: record.cwd,
507
+ model: record.model,
508
+ transcriptPath: record.transcriptPath,
509
+ parentGardenId: record.parentGardenId,
510
+ isEntwurf: record.isEntwurf,
511
+ createdAt: record.createdAt,
512
+ recordUpdatedAt: record.recordUpdatedAt,
513
+ };
514
+ }
515
+ return {
516
+ schemaVersion: META_SCHEMA_VERSION_V2,
517
+ gardenId: record.gardenId,
518
+ backend: record.backend,
519
+ nativeSessionId: record.nativeSessionId,
520
+ cwd: record.cwd,
521
+ model: null,
522
+ transcriptPath: record.transcriptPath,
523
+ parentGardenId: null,
524
+ isEntwurf: false,
525
+ createdAt: record.createdAt,
526
+ recordUpdatedAt: record.lastSeen,
527
+ };
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // v2 write shape + dual-read dispatcher (0.11 Stage 0 step 3D-1)
532
+ //
533
+ // Pure functions only: the canonical v2 serializer and the version-dispatching
534
+ // reader. NO fs upsert, NO live readMetaInbox/enqueueMetaMessage change, NO
535
+ // record.delivery removal — those are 3D-2/3/4. This step just makes "write a v2
536
+ // identity" and "read any version into an identity" exist + gated, so 3D-4 can
537
+ // wire the FS upsert onto a proven writer.
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * Canonical serialization of a v2 identity: stable key order (the frozen ledger
542
+ * jsonc order), 2-space indent, trailing newline. Deterministic — re-serializing
543
+ * the same identity is byte-identical, and the output round-trips through
544
+ * parseMetaRecordV2. This is the v2 WRITE shape; the FS upsert that uses it is
545
+ * step 3D-4, not here.
546
+ */
547
+ export function serializeMetaIdentity(identity: MetaIdentity): string {
548
+ const ordered = {
549
+ schemaVersion: identity.schemaVersion,
550
+ gardenId: identity.gardenId,
551
+ backend: identity.backend,
552
+ nativeSessionId: identity.nativeSessionId,
553
+ cwd: identity.cwd,
554
+ model: identity.model,
555
+ transcriptPath: identity.transcriptPath,
556
+ parentGardenId: identity.parentGardenId,
557
+ isEntwurf: identity.isEntwurf,
558
+ createdAt: identity.createdAt,
559
+ recordUpdatedAt: identity.recordUpdatedAt,
560
+ };
561
+ return `${JSON.stringify(ordered, null, 2)}\n`;
562
+ }
563
+
564
+ /**
565
+ * Fields a v2 caller supplies; garden id + timestamps are derived. The nullable
566
+ * identity axes (model/transcriptPath/parentGardenId) are OPTIONAL at the input
567
+ * boundary so attach can distinguish three intents (3D-4 G5): `undefined` = keep
568
+ * the existing value, `null` = explicit unknown/clear, a string = set/refresh.
569
+ * mint (create) has no existing value, so undefined collapses to null.
570
+ */
571
+ export interface MetaIdentityMintInput {
572
+ backend: MetaBackendV2;
573
+ nativeSessionId: string;
574
+ cwd: string;
575
+ model?: string | null;
576
+ transcriptPath?: string | null;
577
+ parentGardenId?: string | null;
578
+ isEntwurf?: boolean;
579
+ }
580
+
581
+ /**
582
+ * Mint a brand-new v2 identity at the session's true birth (3D-4). The v2 analog
583
+ * of mintMetaRecord — generates the garden id, stamps createdAt == recordUpdatedAt,
584
+ * and carries identity only (no delivery; the receipt lives in mailbox state).
585
+ * Omitted nullable axes default to null / isEntwurf false.
586
+ */
587
+ export function mintMetaIdentity(input: MetaIdentityMintInput, now: Date = new Date()): MetaIdentity {
588
+ const backend = requireBackendV2(input.backend);
589
+ const ts = isoNow(now);
590
+ return {
591
+ schemaVersion: META_SCHEMA_VERSION_V2,
592
+ gardenId: generateSessionId(now),
593
+ backend,
594
+ nativeSessionId: requireNonEmptyString(input.nativeSessionId, "nativeSessionId"),
595
+ cwd: requireNonEmptyString(input.cwd, "cwd"),
596
+ model: requireNullableString(input.model ?? null, "model"),
597
+ transcriptPath: requireNullableString(input.transcriptPath ?? null, "transcriptPath"),
598
+ parentGardenId: requireNullableGardenId(input.parentGardenId ?? null, "parentGardenId"),
599
+ isEntwurf: input.isEntwurf === undefined ? false : requireBoolean(input.isEntwurf, "isEntwurf"),
600
+ createdAt: ts,
601
+ recordUpdatedAt: ts,
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Dual-read dispatcher: peek schemaVersion on untrusted JSON and route to the
607
+ * matching strict parser (v1 record or v2 identity). The lazy-normalize seam — a
608
+ * consumer reads either on-disk version through ONE call. Returns the parsed
609
+ * record in its OWN shape (v1 keeps delivery; v2 is identity); compose with
610
+ * normalizeMetaIdentity, or use parseMetaIdentity, to collapse to identity.
611
+ */
612
+ export function parseMetaRecordAny(json: string): MetaRecord | MetaIdentity {
613
+ let raw: unknown;
614
+ try {
615
+ raw = JSON.parse(json);
616
+ } catch (err) {
617
+ throw new MetaRecordError(`meta-record is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
618
+ }
619
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
620
+ throw new MetaRecordError(`meta-record must be a JSON object (got ${describe(raw)}).`);
621
+ }
622
+ const version = (raw as Record<string, unknown>).schemaVersion;
623
+ if (version === META_SCHEMA_VERSION) return parseMetaRecordV1(json);
624
+ if (version === META_SCHEMA_VERSION_V2) return parseMetaRecordV2(json);
625
+ throw new MetaRecordError(
626
+ `meta-record "schemaVersion" must be ${META_SCHEMA_VERSION} or ${META_SCHEMA_VERSION_V2} (got ${describe(version)}).`,
627
+ );
628
+ }
629
+
630
+ /** Dual-read straight to a normalized v2 identity (parse any version, normalize). */
631
+ export function parseMetaIdentity(json: string): MetaIdentity {
632
+ return normalizeMetaIdentity(parseMetaRecordAny(json));
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // capability source — backend capability registry (0.11 Stage 0 step 3C)
637
+ //
638
+ // v2 identity (step 3A) drops the backend honesty metadata (wakeMode /
639
+ // deliveryLevel / nativeIdLabel) out of the per-session record: it is NOT per
640
+ // session, it is per BACKEND. Its new home is a registry data file
641
+ // `pi/entwurf-capabilities.json` (frozen decision 1 — a registry FILE, sibling
642
+ // concern to the launch-allowlist `entwurf-targets.json`). "이 시민은 self-fetch
643
+ // 인가 / pi 는 어떻게 깨우나" is answered by capability, not by identity.
644
+ //
645
+ // This block is the SCHEMA + PARSER + path resolver. As of 3C it did NOT re-wire
646
+ // the live consumers (`META_BACKEND_DESCRIPTORS` was the authority mint/parse read).
647
+ // 3D-3 then cut mint/parse over to this registry via the `metaCapabilityFor` seam
648
+ // (defined below `metaCapabilitiesFilePath`): the registry is now the LIVE source of
649
+ // wakeMode/deliveryLevel, and `META_BACKEND_DESCRIPTORS` survives only as the
650
+ // drift-guard reference. Removing wakeMode from the record itself lands in step 3D-4.
651
+ // The 3C gate (check-entwurf-capabilities) still asserts the JSON AGREES with the
652
+ // const for the three existing backends (the drift guard) and COVERS exactly
653
+ // META_BACKENDS_V2 (pi included).
654
+ //
655
+ // pi's wakeMode = direct-inject (NOT self-fetch): pi's live wake path is the
656
+ // entwurf-control socket — `pi.sendMessage(... triggerTurn ...)` injects the
657
+ // body straight into the model-visible turn, which is direct-inject by the
658
+ // WakeMode definition (the last-1cm: who puts the body in front of the model).
659
+ // self-fetch is Claude's mailbox path (the model must call its inbox-read MCP).
660
+ // pi's dormant→resume→mailbox path is self-fetch-shaped, so pi is really
661
+ // BIMODAL; a single wakeMode field cannot express both. Splitting it
662
+ // (mailboxWakeMode vs controlSocketWakeMode) is out of 3C scope — for now the
663
+ // single field reports pi's primary live capability (direct-inject) honestly.
664
+ // ---------------------------------------------------------------------------
665
+
666
+ /** Bump only on a breaking capability-registry shape change; the parser refuses other versions. */
667
+ export const CAPABILITY_SCHEMA_VERSION = 1 as const;
668
+
669
+ /** One backend's capability — the honesty metadata that leaves the v2 record. */
670
+ export interface MetaCapability {
671
+ wakeMode: WakeMode;
672
+ deliveryLevel: string;
673
+ nativeIdLabel: string;
674
+ }
675
+
676
+ /** The whole registry: schema version + one capability per v2 backend. */
677
+ export interface MetaCapabilityRegistry {
678
+ schemaVersion: typeof CAPABILITY_SCHEMA_VERSION;
679
+ backends: Record<MetaBackendV2, MetaCapability>;
680
+ }
681
+
682
+ const CAPABILITY_TOP_KEYS: readonly string[] = ["schemaVersion", "backends"];
683
+ const CAPABILITY_ENTRY_KEYS: readonly string[] = ["wakeMode", "deliveryLevel", "nativeIdLabel"];
684
+
685
+ function requireWakeMode(value: unknown, field: string): WakeMode {
686
+ if (value !== "self-fetch" && value !== "direct-inject") {
687
+ throw new MetaRecordError(`capability "${field}" must be self-fetch | direct-inject (got ${describe(value)}).`);
688
+ }
689
+ return value;
690
+ }
691
+
692
+ function parseCapabilityEntry(value: unknown, backend: string): MetaCapability {
693
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
694
+ throw new MetaRecordError(`capability for "${backend}" must be an object (got ${describe(value)}).`);
695
+ }
696
+ const obj = value as Record<string, unknown>;
697
+ const stray = Object.keys(obj).filter((k) => !CAPABILITY_ENTRY_KEYS.includes(k));
698
+ if (stray.length > 0) {
699
+ throw new MetaRecordError(
700
+ `capability for "${backend}" carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
701
+ `(allowed: ${CAPABILITY_ENTRY_KEYS.join(", ")}).`,
702
+ );
703
+ }
704
+ return {
705
+ wakeMode: requireWakeMode(obj.wakeMode, `${backend}.wakeMode`),
706
+ deliveryLevel: requireNonEmptyString(obj.deliveryLevel, `${backend}.deliveryLevel`),
707
+ nativeIdLabel: requireNonEmptyString(obj.nativeIdLabel, `${backend}.nativeIdLabel`),
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Parse + fully validate untrusted JSON into a capability registry. Strict:
713
+ * schemaVersion fence, top-level + per-entry keyset, and COVERAGE — the backend
714
+ * keys must be exactly META_BACKENDS_V2 (no missing, no extra). A registry that
715
+ * forgets pi, or smuggles an unknown backend, is rejected.
716
+ */
717
+ export function parseMetaCapabilityRegistry(json: string): MetaCapabilityRegistry {
718
+ let raw: unknown;
719
+ try {
720
+ raw = JSON.parse(json);
721
+ } catch (err) {
722
+ throw new MetaRecordError(
723
+ `capability registry is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
724
+ );
725
+ }
726
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
727
+ throw new MetaRecordError(`capability registry must be a JSON object (got ${describe(raw)}).`);
728
+ }
729
+ const obj = raw as Record<string, unknown>;
730
+ if (obj.schemaVersion !== CAPABILITY_SCHEMA_VERSION) {
731
+ throw new MetaRecordError(
732
+ `capability registry "schemaVersion" must be ${CAPABILITY_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
733
+ );
734
+ }
735
+ const topStray = Object.keys(obj).filter((k) => !CAPABILITY_TOP_KEYS.includes(k));
736
+ if (topStray.length > 0) {
737
+ throw new MetaRecordError(
738
+ `capability registry carries unexpected key(s) ${topStray.map((k) => `"${k}"`).join(", ")} ` +
739
+ `(allowed: ${CAPABILITY_TOP_KEYS.join(", ")}).`,
740
+ );
741
+ }
742
+ const backends = obj.backends;
743
+ if (typeof backends !== "object" || backends === null || Array.isArray(backends)) {
744
+ throw new MetaRecordError(`capability registry "backends" must be an object (got ${describe(backends)}).`);
745
+ }
746
+ const present = Object.keys(backends).sort();
747
+ const expected = [...META_BACKENDS_V2].sort();
748
+ if (present.length !== expected.length || !expected.every((b, i) => b === present[i])) {
749
+ throw new MetaRecordError(
750
+ `capability registry must cover exactly ${expected.join(", ")} (got ${present.join(", ")}).`,
751
+ );
752
+ }
753
+ const entries = backends as Record<string, unknown>;
754
+ const out = {} as Record<MetaBackendV2, MetaCapability>;
755
+ for (const backend of META_BACKENDS_V2) {
756
+ out[backend] = parseCapabilityEntry(entries[backend], backend);
757
+ }
758
+ return { schemaVersion: CAPABILITY_SCHEMA_VERSION, backends: out };
759
+ }
760
+
761
+ /**
762
+ * The packaged capability registry path. Two layouts resolve:
763
+ * - repo / npm package: `pi-extensions/lib/` → `<root>/pi/entwurf-capabilities.json`.
764
+ * - bundled meta-bridge plugin: `../../pi` would ESCAPE the plugin dir (the plugin
765
+ * is installed under a version dir in the Claude plugin cache), so the registry
766
+ * travels AT the plugin root and resolves via `../` from `lib/`.
767
+ * meta-bridge-install.sh copies it there; doctor-meta-bridge asserts its presence.
768
+ * Repo path is tried first, so repo/package behaviour is unchanged; the bundle
769
+ * fallback only engages where the repo layout is absent.
770
+ */
771
+ export function metaCapabilitiesFilePath(): string {
772
+ const repoPath = path.join(import.meta.dirname, "..", "..", "pi", "entwurf-capabilities.json");
773
+ if (fs.existsSync(repoPath)) return repoPath;
774
+ return path.join(import.meta.dirname, "..", "entwurf-capabilities.json");
775
+ }
776
+
777
+ // ---------------------------------------------------------------------------
778
+ // capability live source (0.11 Stage 0 step 3D-3)
779
+ //
780
+ // 3C shipped the registry FILE + parser but left META_BACKEND_DESCRIPTORS as the
781
+ // authority that mint/parse read (3C header: "Cutting the live const over to this
782
+ // registry ... lands in step 3D"). 3D-3 is that cut-over: mint/parse now read
783
+ // backend honesty metadata (wakeMode/deliveryLevel) from the registry via the seam
784
+ // below, NOT from the const. The const survives ONLY as the drift-guard reference
785
+ // in check-entwurf-capabilities (registry ≡ const for the 3 existing backends), so
786
+ // the cut-over is behaviour-preserving. The record.delivery.wakeMode SLOT stays
787
+ // (its removal is 3D-4); only its SOURCE moves.
788
+ // ---------------------------------------------------------------------------
789
+
790
+ /** Memoized packaged registry; the file is immutable at runtime, so caching is honest (not stateful lying). */
791
+ let cachedMetaCapabilities: MetaCapabilityRegistry | null = null;
792
+
793
+ /**
794
+ * Load + memoize the packaged capability registry — the live source of backend
795
+ * honesty metadata as of 3D-3. A missing/corrupt file throws (the registry is a
796
+ * packaged invariant; check-pack guarantees its presence).
797
+ */
798
+ export function loadMetaCapabilityRegistry(): MetaCapabilityRegistry {
799
+ if (cachedMetaCapabilities === null) {
800
+ cachedMetaCapabilities = parseMetaCapabilityRegistry(fs.readFileSync(metaCapabilitiesFilePath(), "utf8"));
801
+ }
802
+ return cachedMetaCapabilities;
803
+ }
804
+
805
+ /**
806
+ * The capability for one backend, from the registry (3D-3 live source). The
807
+ * optional `registry` injection lets a gate prove the value is registry-DRIVEN
808
+ * (feed a doctored registry → the lookup follows it), distinguishing "read from
809
+ * the registry" from "hardcoded off the const". Param is `MetaBackendV2` (all 4):
810
+ * `backends` is `Record<MetaBackendV2, …>`, so the lookup is total — mint/parse
811
+ * still pass the 3 `MetaBackend` values (a subset), and the v2 decider's mailbox
812
+ * deliverability passes the unsupported backends (which are MetaBackendV2 to the
813
+ * type system even though pi never reaches that call at runtime).
814
+ */
815
+ export function metaCapabilityFor(
816
+ backend: MetaBackendV2,
817
+ registry: MetaCapabilityRegistry = loadMetaCapabilityRegistry(),
818
+ ): MetaCapability {
819
+ return registry.backends[backend];
820
+ }
821
+
822
+ /** Denote-sortable on-disk filename. Body is SSOT; do NOT parse this for authority. Accepts v1 record or v2 identity. */
823
+ export function metaRecordFilename(record: MetaRecord | MetaIdentity): string {
824
+ return `${record.gardenId}.meta.json`;
825
+ }
826
+
827
+ /**
828
+ * THE lookup authority. Scan the record BODIES in a meta-session directory and
829
+ * return the one whose top-level `nativeSessionId` matches, or null. This is the
830
+ * `.meta.json` analog of 0.9.0 `findSessionFileById` (which header-scans pi
831
+ * JSONLs). NOT a filename parse, NOT an index lookup — those are at best derived
832
+ * caches. The directory listing + record reading is injected so this stays a
833
+ * pure function (the step-3 CLI supplies the real fs).
834
+ *
835
+ * The scan runs to completion (does NOT stop at the first match): the
836
+ * native→garden mapping MUST be unique, so two records claiming the same
837
+ * `nativeSessionId` is an authority ambiguity — `MetaRecordError`, fail-fast,
838
+ * never silently pick one (that would make `upsert` mint a second id / route a
839
+ * message to the wrong garden citizen).
840
+ *
841
+ * Unreadable / malformed entries are surfaced honestly via `onSkip` (a corrupt
842
+ * record is a real problem, not something to silently swallow); a throwing
843
+ * reader for one file does not abort the whole scan.
844
+ */
845
+ export function scanByNativeId(
846
+ entries: readonly string[],
847
+ nativeSessionId: string,
848
+ readRecord: (filename: string) => string,
849
+ onSkip?: (filename: string, err: Error) => void,
850
+ ): MetaRecord | null {
851
+ const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
852
+ const matches: { filename: string; record: MetaRecord }[] = [];
853
+ for (const filename of entries) {
854
+ if (!filename.endsWith(".meta.json")) continue;
855
+ let record: MetaRecord;
856
+ try {
857
+ record = parseMetaRecord(readRecord(filename));
858
+ } catch (err) {
859
+ onSkip?.(filename, err instanceof Error ? err : new Error(String(err)));
860
+ continue;
861
+ }
862
+ if (record.nativeSessionId === target) matches.push({ filename, record });
863
+ }
864
+ if (matches.length > 1) {
865
+ throw new MetaRecordError(
866
+ `ambiguous meta-record authority: nativeSessionId "${target}" matched ${matches.length} records ` +
867
+ `(${matches.map((m) => m.filename).join(", ")}). The native→garden mapping must be unique — ` +
868
+ `fail-fast rather than silently picking one. Remove the duplicate(s).`,
869
+ );
870
+ }
871
+ return matches.length === 1 ? (matches[0] as { record: MetaRecord }).record : null;
872
+ }
873
+
874
+ /**
875
+ * The dual-read identity scan (0.11 Stage 0 step 3D-4 commit1, additive). Same
876
+ * lookup authority as scanByNativeId — scan the BODIES, match on top-level
877
+ * `nativeSessionId`, fail-fast on duplicates — but reads v1 AND v2 records (via
878
+ * parseMetaIdentity) and returns normalized identity. This is the scan the v2
879
+ * upsert uses (3D-4): once upsert writes v2, the existence check MUST recognize v2
880
+ * records or it would mint a duplicate id for an existing citizen (G1). scanByNativeId
881
+ * remains the v1-only raw scan for v1-fixture gates. Identity-only: it reads
882
+ * backend/nativeSessionId, never delivery.
883
+ */
884
+ export function scanIdentityByNativeId(
885
+ entries: readonly string[],
886
+ nativeSessionId: string,
887
+ readRecord: (filename: string) => string,
888
+ onSkip?: (filename: string, err: Error) => void,
889
+ ): MetaIdentity | null {
890
+ const target = requireNonEmptyString(nativeSessionId, "nativeSessionId");
891
+ const matches: { filename: string; identity: MetaIdentity }[] = [];
892
+ for (const filename of entries) {
893
+ if (!filename.endsWith(".meta.json")) continue;
894
+ let identity: MetaIdentity;
895
+ try {
896
+ identity = parseMetaIdentity(readRecord(filename));
897
+ } catch (err) {
898
+ onSkip?.(filename, err instanceof Error ? err : new Error(String(err)));
899
+ continue;
900
+ }
901
+ if (identity.nativeSessionId === target) matches.push({ filename, identity });
902
+ }
903
+ if (matches.length > 1) {
904
+ throw new MetaRecordError(
905
+ `ambiguous meta-record authority: nativeSessionId "${target}" matched ${matches.length} records ` +
906
+ `(${matches.map((m) => m.filename).join(", ")}). The native→garden mapping must be unique — ` +
907
+ `fail-fast rather than silently picking one. Remove the duplicate(s).`,
908
+ );
909
+ }
910
+ return matches.length === 1 ? (matches[0] as { identity: MetaIdentity }).identity : null;
911
+ }
912
+
913
+ /** One unreadable meta-record, surfaced as an explicit fact — file + message
914
+ * ONLY, never a half-parsed identity field. A salvaged gid-looking string
915
+ * presented as a fact is a synthetic backdoor; verbatim-or-nothing. */
916
+ export interface MetaRecordReadError {
917
+ filename: string;
918
+ message: string;
919
+ }
920
+
921
+ export interface ListIdentitiesResult {
922
+ identities: MetaIdentity[];
923
+ errors: MetaRecordReadError[];
924
+ }
925
+
926
+ /**
927
+ * Scan every meta-record in a store into identities + explicit read errors.
928
+ * Pure over injected (entries, readRecord) so gates drive it without IO; the
929
+ * fact-provider (slice 4b) supplies the real readdir/readFile.
930
+ *
931
+ * A record that fails to parse — or whose body gardenId drifts from its filename
932
+ * (the same authority check as `readMetaIdentityByGardenId`) — is NEITHER
933
+ * silently skipped (that hides a broken citizen = lie by omission) NOR allowed to
934
+ * throw the whole listing (one corrupt file must not blind `entwurf_peers` — the
935
+ * 0.10 "corrupt blocks registration forever" lesson). It becomes an explicit
936
+ * error entry carrying ONLY filename + message. Duplicate gardenId across files
937
+ * is impossible: the filename IS `<gardenId>.meta.json`, so the filesystem
938
+ * already enforces uniqueness — only body/filename drift can split authority.
939
+ *
940
+ * mode "collect" (default) returns partial results; "strict" throws if ANY
941
+ * record was unreadable (doctor / gate callers wanting all-or-nothing).
942
+ */
943
+ export function listAllMetaIdentities(
944
+ entries: readonly string[],
945
+ readRecord: (filename: string) => string,
946
+ opts: { mode?: "collect" | "strict" } = {},
947
+ ): ListIdentitiesResult {
948
+ const identities: MetaIdentity[] = [];
949
+ const errors: MetaRecordReadError[] = [];
950
+ for (const filename of entries) {
951
+ if (!filename.endsWith(".meta.json")) continue;
952
+ let identity: MetaIdentity;
953
+ try {
954
+ identity = parseMetaIdentity(readRecord(filename));
955
+ } catch (err) {
956
+ errors.push({ filename, message: err instanceof Error ? err.message : String(err) });
957
+ continue;
958
+ }
959
+ const expected = filename.slice(0, -".meta.json".length);
960
+ if (identity.gardenId !== expected) {
961
+ errors.push({
962
+ filename,
963
+ message: `body/filename drift: body gardenId "${identity.gardenId}" ≠ filename. The body is the authority; this file is corrupt.`,
964
+ });
965
+ continue;
966
+ }
967
+ identities.push(identity);
968
+ }
969
+ if (opts.mode === "strict" && errors.length > 0) {
970
+ throw new MetaRecordError(
971
+ `listAllMetaIdentities(strict): ${errors.length} unreadable meta-record(s): ${errors
972
+ .map((e) => `${e.filename} (${e.message})`)
973
+ .join("; ")}`,
974
+ );
975
+ }
976
+ identities.sort((a, b) => (a.gardenId < b.gardenId ? -1 : a.gardenId > b.gardenId ? 1 : 0));
977
+ errors.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
978
+ return { identities, errors };
979
+ }
980
+
981
+ export type UpsertAction = "create" | "attach";
982
+
983
+ export interface UpsertDecision {
984
+ action: UpsertAction;
985
+ record: MetaIdentity;
986
+ }
987
+
988
+ /**
989
+ * The pure core of the `upsert` CLI (3D-4: v2 identity). Keyed on RECORD
990
+ * EXISTENCE, never on a backend `source` field:
991
+ * - existing present → ATTACH: keep identity (gardenId, createdAt,
992
+ * nativeSessionId), bump recordUpdatedAt, and apply the 3-value merge to the
993
+ * nullable axes + always-refresh cwd. Identity drift (a different backend for
994
+ * the same nativeSessionId) is corruption → throw.
995
+ * - absent → CREATE: mint a fresh v2 identity.
996
+ *
997
+ * 3-value attach merge (G5): for model/transcriptPath/parentGardenId an input of
998
+ * `undefined` KEEPS the existing value (a pi-birth caller that does not know the
999
+ * transcript must not wipe a previously-recorded one), `null` explicitly clears
1000
+ * it, a string sets it. cwd is required and always refreshed.
1001
+ *
1002
+ * Idempotent by construction: calling it twice with the same input yields one
1003
+ * attach after the first create, never a second id. `existing` is the normalized
1004
+ * identity from scanIdentityByNativeId (dual-read v1+v2).
1005
+ */
1006
+ export function decideUpsert(
1007
+ existing: MetaIdentity | null,
1008
+ input: MetaIdentityMintInput,
1009
+ now: Date = new Date(),
1010
+ ): UpsertDecision {
1011
+ const backend = requireBackendV2(input.backend);
1012
+ const nativeSessionId = requireNonEmptyString(input.nativeSessionId, "nativeSessionId");
1013
+ const cwd = requireNonEmptyString(input.cwd, "cwd");
1014
+
1015
+ if (existing === null) {
1016
+ return { action: "create", record: mintMetaIdentity(input, now) };
1017
+ }
1018
+ if (existing.nativeSessionId !== nativeSessionId) {
1019
+ throw new MetaRecordError(
1020
+ `decideUpsert called with existing record for a different nativeSessionId ` +
1021
+ `(existing="${existing.nativeSessionId}", input="${nativeSessionId}"). ` +
1022
+ `The caller must pass the record found by scanIdentityByNativeId(input.nativeSessionId).`,
1023
+ );
1024
+ }
1025
+ if (existing.backend !== backend) {
1026
+ throw new MetaRecordError(
1027
+ `meta-record identity drift: nativeSessionId "${nativeSessionId}" is bound to backend ` +
1028
+ `"${existing.backend}" but upsert input says "${backend}". A native session cannot change backend.`,
1029
+ );
1030
+ }
1031
+ // 3-value merge (G5): undefined keeps existing, null clears, string sets. The
1032
+ // nullable axes are validated the same way mint validates them.
1033
+ const model = input.model === undefined ? existing.model : requireNullableString(input.model, "model");
1034
+ const transcriptPath =
1035
+ input.transcriptPath === undefined
1036
+ ? existing.transcriptPath
1037
+ : requireNullableString(input.transcriptPath, "transcriptPath");
1038
+ const parentGardenId =
1039
+ input.parentGardenId === undefined
1040
+ ? existing.parentGardenId
1041
+ : requireNullableGardenId(input.parentGardenId, "parentGardenId");
1042
+ const isEntwurf = input.isEntwurf === undefined ? existing.isEntwurf : requireBoolean(input.isEntwurf, "isEntwurf");
1043
+ return {
1044
+ action: "attach",
1045
+ record: { ...existing, cwd, model, transcriptPath, parentGardenId, isEntwurf, recordUpdatedAt: isoNow(now) },
1046
+ };
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------------------
1050
+ // read-receipt mutators — V1-RECORD ONLY (3D-4 H3). These mutate record.delivery,
1051
+ // which exists only on the v1 schema. The LIVE enqueue/read path no longer calls
1052
+ // them (3D-4 the cut: the receipt lives in the mailbox state store, stamped by
1053
+ // stampMailboxReceipt). They are retained for the v1-fixture / dual-read gates that
1054
+ // still exercise a raw v1 record; do NOT re-wire them into the live path.
1055
+ // ---------------------------------------------------------------------------
1056
+
1057
+ /** A sender enqueued a body to this peer's mailbox. (v1-record only — see section note.) */
1058
+ export function markEnqueued(record: MetaRecord, now: Date = new Date()): MetaRecord {
1059
+ return { ...record, delivery: { ...record.delivery, lastEnqueuedAt: isoNow(now) } };
1060
+ }
1061
+
1062
+ /** The doorbell rang / body injected ("`.delivered`"). For self-fetch ≠ read. */
1063
+ export function markDelivered(record: MetaRecord, now: Date = new Date()): MetaRecord {
1064
+ return { ...record, delivery: { ...record.delivery, lastDeliveredAt: isoNow(now) } };
1065
+ }
1066
+
1067
+ /** The inbox-read MCP call — the real read-receipt (makes Claude D7 observable). */
1068
+ export function markRead(record: MetaRecord, now: Date = new Date()): MetaRecord {
1069
+ return { ...record, delivery: { ...record.delivery, lastReadAt: isoNow(now) } };
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // FS-bound store (step 3) — the thin real-filesystem wrapper around the pure
1074
+ // core. Only node builtins beyond the pure layer, so the deterministic gate
1075
+ // stays strip-types clean (see module header for why this is not a sibling file).
1076
+ // ---------------------------------------------------------------------------
1077
+
1078
+ function expandTilde(p: string): string {
1079
+ if (p === "~") return os.homedir();
1080
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
1081
+ return p;
1082
+ }
1083
+
1084
+ /**
1085
+ * The pi agent dir — the persistence root pi owns. `PI_CODING_AGENT_DIR` lets an
1086
+ * isolated install / test relocate it (symmetric with how pi's own sessions
1087
+ * isolate); otherwise it is the fixed `~/.pi/agent`. A stable `~/` path, so the
1088
+ * meta-bridge hook never needs this baked into config — it resolves at runtime.
1089
+ */
1090
+ function piAgentDir(): string {
1091
+ return process.env.PI_CODING_AGENT_DIR
1092
+ ? path.resolve(expandTilde(process.env.PI_CODING_AGENT_DIR))
1093
+ : path.join(os.homedir(), ".pi", "agent");
1094
+ }
1095
+
1096
+ /**
1097
+ * Where meta-records live. Under the pi agent dir (pi owns persistence), so an
1098
+ * isolated install / test that sets `PI_CODING_AGENT_DIR` gets isolated
1099
+ * meta-sessions too — symmetric with how pi's own sessions isolate. A direct
1100
+ * `ENTWURF_META_SESSIONS_DIR` override wins (used by tests / unusual deployments).
1101
+ */
1102
+ export function defaultMetaSessionsDir(): string {
1103
+ if (process.env.ENTWURF_META_SESSIONS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_SESSIONS_DIR));
1104
+ return path.join(piAgentDir(), "meta-sessions");
1105
+ }
1106
+
1107
+ /**
1108
+ * Where per-garden-id idle-wake mailboxes live: `<pi-agent-dir>/meta-mailbox`.
1109
+ * Deliberately a SIBLING of meta-sessions, not nested inside it — the record
1110
+ * store is the authority (scanned for identity) while the mailbox is volatile
1111
+ * signal/body traffic; keeping them apart means a mailbox poke never risks a
1112
+ * record-dir readdir picking up a non-record file. The watched signal for a
1113
+ * session is `<this>/<gardenId>/inbox.signal`. Same runtime resolution as
1114
+ * meta-sessions (no config baking); `ENTWURF_META_MAILBOX_DIR` overrides for tests.
1115
+ */
1116
+ export function defaultMetaMailboxDir(): string {
1117
+ if (process.env.ENTWURF_META_MAILBOX_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_MAILBOX_DIR));
1118
+ return path.join(piAgentDir(), "meta-mailbox");
1119
+ }
1120
+
1121
+ /**
1122
+ * Where native-backend SENDER markers live: `<pi-agent-dir>/meta-senders`.
1123
+ *
1124
+ * The problem this closes: a native Claude Code session that SENDS via the
1125
+ * user-scope entwurf-bridge MCP has no `PI_SESSION_ID` — at tool-call time the
1126
+ * MCP process does not know which garden-id session it belongs to, so the sender
1127
+ * envelope degrades to anonymous `external-mcp` and the receiver has no reply
1128
+ * address. The hook DOES know the garden-id (it just minted the record), and the
1129
+ * hook + the MCP child run under the SAME Claude Code parent process. So the hook
1130
+ * writes a marker keyed by that parent pid; the MCP reads the marker for its OWN
1131
+ * `process.ppid` and promotes itself to a replyable meta-session sender. This
1132
+ * uses process ancestry, NOT cwd inference (same repo / multiple sessions would
1133
+ * make cwd ambiguous). `ENTWURF_META_SENDERS_DIR` overrides for tests.
1134
+ */
1135
+ export function defaultMetaSendersDir(): string {
1136
+ if (process.env.ENTWURF_META_SENDERS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_SENDERS_DIR));
1137
+ return path.join(piAgentDir(), "meta-senders");
1138
+ }
1139
+
1140
+ /**
1141
+ * Where native-backend RECEIVER presence markers live: `<pi-agent-dir>/meta-receivers`.
1142
+ *
1143
+ * The problem this closes (SE-2): a meta-record proves a session once EXISTED, not
1144
+ * that it is still a live receiver that a reply could reach. A self-fetch backend
1145
+ * (Claude Code) has no control socket to probe, so "is this receiver active right
1146
+ * now?" needs its own signal. The SessionStart/CwdChanged/FileChanged hook — the
1147
+ * event that actually arms the watchPaths idle-wake — writes a presence marker keyed
1148
+ * by GARDEN id (the universal address a sender targets), carrying the watch owner pid
1149
+ * + its start-key. A reader trusts it only while that pid is still the same live
1150
+ * process (start-key match); a terminated session leaves a marker whose owner is gone,
1151
+ * so it reads as inactive instead of a ghost active-receiver. UNLIKE the sender marker
1152
+ * (keyed by owner pid, a pid→garden hint), this is keyed by garden id because the
1153
+ * deliverability question starts from a target garden id. `ENTWURF_META_RECEIVERS_DIR`
1154
+ * overrides for tests.
1155
+ */
1156
+ export function defaultMetaReceiversDir(): string {
1157
+ if (process.env.ENTWURF_META_RECEIVERS_DIR) return path.resolve(expandTilde(process.env.ENTWURF_META_RECEIVERS_DIR));
1158
+ return path.join(piAgentDir(), "meta-receivers");
1159
+ }
1160
+
1161
+ /**
1162
+ * A boot-unique identity for a live process: pid is reused, but pid + start-time
1163
+ * is unique within a boot. Linux reads `/proc/<pid>/stat` field 22 (starttime in
1164
+ * clock ticks); macOS/BSD falls back to `ps -o lstart=`. Returns "" when the pid
1165
+ * is gone or unreadable — a "" key never matches, so a dead/reused owner fails
1166
+ * the marker check. This is what stops a stale marker (process exited, pid reused
1167
+ * by a new Claude session) from granting the wrong garden-id sender identity.
1168
+ */
1169
+ export function processStartKey(pid: number): string {
1170
+ if (!Number.isInteger(pid) || pid <= 0) return "";
1171
+ try {
1172
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
1173
+ // comm (field 2) is parenthesized and may contain spaces/parens — split AFTER the last ')'.
1174
+ const fields = stat
1175
+ .slice(stat.lastIndexOf(")") + 1)
1176
+ .trim()
1177
+ .split(/\s+/);
1178
+ // after comm: index 0 = state(f3), 1 = ppid(f4), … 19 = starttime(f22).
1179
+ const starttime = fields[19];
1180
+ if (starttime && /^\d+$/.test(starttime)) return `linux:${starttime}`;
1181
+ } catch {
1182
+ // not Linux / no procfs
1183
+ }
1184
+ try {
1185
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
1186
+ if (out) return `ps:${out}`;
1187
+ } catch {
1188
+ // pid gone or ps unavailable
1189
+ }
1190
+ return "";
1191
+ }
1192
+
1193
+ /** The parent pid of a pid (one ancestry step), or null when unknown. */
1194
+ export function parentPid(pid: number): number | null {
1195
+ if (!Number.isInteger(pid) || pid <= 0) return null;
1196
+ try {
1197
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
1198
+ const fields = stat
1199
+ .slice(stat.lastIndexOf(")") + 1)
1200
+ .trim()
1201
+ .split(/\s+/);
1202
+ const ppid = Number(fields[1]); // f4
1203
+ if (Number.isInteger(ppid) && ppid > 0) return ppid;
1204
+ } catch {
1205
+ // not Linux
1206
+ }
1207
+ try {
1208
+ const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" }).trim();
1209
+ const ppid = Number(out);
1210
+ if (Number.isInteger(ppid) && ppid > 0) return ppid;
1211
+ } catch {
1212
+ // pid gone
1213
+ }
1214
+ return null;
1215
+ }
1216
+
1217
+ export interface MetaSenderMarker {
1218
+ backend: MetaBackend;
1219
+ gardenId: string;
1220
+ nativeSessionId: string;
1221
+ cwd: string;
1222
+ /** The pid this marker is keyed to (the shared native runner / Claude parent). */
1223
+ ownerPid: number;
1224
+ /** processStartKey(ownerPid) at write time — the pid-reuse guard. */
1225
+ ownerStartKey: string;
1226
+ updatedAt: string;
1227
+ }
1228
+
1229
+ /** `<sendersDir>/<backend>/<ownerPid>.json` — keyed by the shared parent pid. */
1230
+ export function metaSenderMarkerPath(
1231
+ backend: MetaBackend,
1232
+ ownerPid: number,
1233
+ sendersDir: string = defaultMetaSendersDir(),
1234
+ ): string {
1235
+ return path.join(sendersDir, backend, `${ownerPid}.json`);
1236
+ }
1237
+
1238
+ export interface WriteMetaSenderMarkerOptions {
1239
+ backend: MetaBackend;
1240
+ gardenId: string;
1241
+ nativeSessionId: string;
1242
+ cwd: string;
1243
+ ownerPid: number;
1244
+ sendersDir?: string;
1245
+ now?: Date;
1246
+ }
1247
+
1248
+ /** Write (atomically) the sender marker for a native session's parent pid. */
1249
+ export function writeMetaSenderMarker(opts: WriteMetaSenderMarkerOptions): string {
1250
+ const backend = requireBackend(opts.backend);
1251
+ const gardenId = requireGardenId(opts.gardenId);
1252
+ const file = metaSenderMarkerPath(backend, opts.ownerPid, opts.sendersDir ?? defaultMetaSendersDir());
1253
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1254
+ const marker: MetaSenderMarker = {
1255
+ backend,
1256
+ gardenId,
1257
+ nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
1258
+ cwd: requireNonEmptyString(opts.cwd, "cwd"),
1259
+ ownerPid: opts.ownerPid,
1260
+ ownerStartKey: processStartKey(opts.ownerPid),
1261
+ updatedAt: isoNow(opts.now ?? new Date()),
1262
+ };
1263
+ const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
1264
+ fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
1265
+ fs.renameSync(tmp, file);
1266
+ return file;
1267
+ }
1268
+
1269
+ export interface ReadMetaSenderMarkerOptions {
1270
+ /** Explicit marker file (test / explicit wiring). Wins over backend+ownerPid. */
1271
+ markerPath?: string;
1272
+ backend?: MetaBackend;
1273
+ ownerPid?: number;
1274
+ sendersDir?: string;
1275
+ /** Run the pid-reuse guard (verify the owner pid is still live). Default true —
1276
+ * set false only for unit assertions that exercise the marker without a live owner. */
1277
+ verifyOwner?: boolean;
1278
+ }
1279
+
1280
+ /**
1281
+ * Read the sender marker for this MCP process's owner. Returns null when absent
1282
+ * or corrupt — a marker we cannot trust means "no authoritative sender", which
1283
+ * the caller turns into external-non-replyable (or a hard reject under
1284
+ * REQUIRE_META_SENDER). Never throws: an unreadable marker must not break a send.
1285
+ */
1286
+ export function readMetaSenderMarker(opts: ReadMetaSenderMarkerOptions): MetaSenderMarker | null {
1287
+ let file = opts.markerPath;
1288
+ if (!file && opts.backend && typeof opts.ownerPid === "number") {
1289
+ file = metaSenderMarkerPath(opts.backend, opts.ownerPid, opts.sendersDir ?? defaultMetaSendersDir());
1290
+ }
1291
+ if (!file || !fs.existsSync(file)) return null;
1292
+ try {
1293
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
1294
+ const marker: MetaSenderMarker = {
1295
+ backend: requireBackend(raw.backend),
1296
+ gardenId: requireGardenId(raw.gardenId),
1297
+ nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
1298
+ cwd: requireNonEmptyString(raw.cwd, "cwd"),
1299
+ ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
1300
+ ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
1301
+ updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
1302
+ };
1303
+ // pid-reuse guard (unless explicitly disabled): the owner pid must STILL be
1304
+ // the same process that wrote the marker. A bare pid is reused; pid+startKey
1305
+ // is boot-unique, so a stale marker from a dead session fails here instead of
1306
+ // granting a wrong-identity send.
1307
+ if (opts.verifyOwner !== false) {
1308
+ if (!Number.isInteger(marker.ownerPid)) return null;
1309
+ const liveKey = processStartKey(marker.ownerPid);
1310
+ if (!liveKey || liveKey !== marker.ownerStartKey) return null;
1311
+ }
1312
+ return marker;
1313
+ } catch {
1314
+ return null;
1315
+ }
1316
+ }
1317
+
1318
+ // ── meta-receiver presence marker (SE-2 active-receiver signal) ──────────────
1319
+
1320
+ /**
1321
+ * The arm-capable hook events. Only these can emit watchPaths (and therefore arm
1322
+ * the idle-wake), so only these write a receiver presence marker. UserPromptSubmit
1323
+ * is deliberately absent: it can backfill the record but cannot re-arm the watch, so
1324
+ * it must NOT mint or refresh an "active receiver" claim it cannot back.
1325
+ */
1326
+ export const META_RECEIVER_ARM_PROVENANCES = ["session-start", "cwd-changed", "file-changed"] as const;
1327
+ export type MetaReceiverArmProvenance = (typeof META_RECEIVER_ARM_PROVENANCES)[number];
1328
+
1329
+ function requireArmProvenance(value: unknown): MetaReceiverArmProvenance {
1330
+ if (typeof value === "string" && (META_RECEIVER_ARM_PROVENANCES as readonly string[]).includes(value)) {
1331
+ return value as MetaReceiverArmProvenance;
1332
+ }
1333
+ throw new Error(
1334
+ `invalid armProvenance: ${JSON.stringify(value)} (expected one of ${META_RECEIVER_ARM_PROVENANCES.join(", ")})`,
1335
+ );
1336
+ }
1337
+
1338
+ export interface MetaReceiverMarker {
1339
+ gardenId: string;
1340
+ backend: MetaBackend;
1341
+ nativeSessionId: string;
1342
+ /** The pid holding the watchPaths idle-wake subscription (the native CLI = hook's process.ppid). */
1343
+ ownerPid: number;
1344
+ /** processStartKey(ownerPid) at write time — the dead-owner / pid-reuse guard. */
1345
+ ownerStartKey: string;
1346
+ /** The kind of process that owns the watch. Currently always the native CLI, not the plugin host. */
1347
+ ownerKind: string;
1348
+ /** Which arm-capable event wrote this presence (never user-prompt-submit). */
1349
+ armProvenance: MetaReceiverArmProvenance;
1350
+ updatedAt: string;
1351
+ }
1352
+
1353
+ /** `<receiversDir>/<gardenId>.json` — keyed by garden id (the universal address). */
1354
+ export function metaReceiverMarkerPath(gardenId: string, receiversDir: string = defaultMetaReceiversDir()): string {
1355
+ return path.join(receiversDir, `${requireGardenId(gardenId)}.json`);
1356
+ }
1357
+
1358
+ export interface WriteMetaReceiverMarkerOptions {
1359
+ gardenId: string;
1360
+ backend: MetaBackend;
1361
+ nativeSessionId: string;
1362
+ ownerPid: number;
1363
+ armProvenance: MetaReceiverArmProvenance;
1364
+ /** Defaults to "claude-code-cli" — the watchPaths subscriber. */
1365
+ ownerKind?: string;
1366
+ receiversDir?: string;
1367
+ now?: Date;
1368
+ }
1369
+
1370
+ /** Write (atomically) the receiver presence marker for a garden id. */
1371
+ export function writeMetaReceiverMarker(opts: WriteMetaReceiverMarkerOptions): string {
1372
+ const gardenId = requireGardenId(opts.gardenId);
1373
+ const backend = requireBackend(opts.backend);
1374
+ const file = metaReceiverMarkerPath(gardenId, opts.receiversDir ?? defaultMetaReceiversDir());
1375
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1376
+ const marker: MetaReceiverMarker = {
1377
+ gardenId,
1378
+ backend,
1379
+ nativeSessionId: requireNonEmptyString(opts.nativeSessionId, "nativeSessionId"),
1380
+ ownerPid: opts.ownerPid,
1381
+ ownerStartKey: processStartKey(opts.ownerPid),
1382
+ ownerKind: requireNonEmptyString(opts.ownerKind ?? "claude-code-cli", "ownerKind"),
1383
+ armProvenance: requireArmProvenance(opts.armProvenance),
1384
+ updatedAt: isoNow(opts.now ?? new Date()),
1385
+ };
1386
+ const tmp = `${file}.${crypto.randomBytes(4).toString("hex")}.tmp`;
1387
+ fs.writeFileSync(tmp, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
1388
+ fs.renameSync(tmp, file);
1389
+ return file;
1390
+ }
1391
+
1392
+ export interface ReadMetaReceiverMarkerOptions {
1393
+ /** Explicit marker file (test / explicit wiring). Wins over gardenId. */
1394
+ markerPath?: string;
1395
+ gardenId?: string;
1396
+ receiversDir?: string;
1397
+ /** Run the dead-owner / pid-reuse guard (verify the owner pid is still the same live process).
1398
+ * Default true — set false only for unit assertions that inspect a marker without a live owner. */
1399
+ verifyOwner?: boolean;
1400
+ }
1401
+
1402
+ /**
1403
+ * Read the receiver presence marker for a garden id. Returns null when absent,
1404
+ * corrupt, or (under verifyOwner) the owner pid is no longer the same live process —
1405
+ * each means "no active receiver", which the deliverability predicate turns into
1406
+ * not-deliverable. Never throws: an unreadable marker must not break a send path.
1407
+ * Record-backing is NOT checked here (the caller / predicate supplies recordBacked
1408
+ * as an explicit fact, so an absent record and a dead owner stay distinguishable).
1409
+ */
1410
+ export function readMetaReceiverMarker(opts: ReadMetaReceiverMarkerOptions): MetaReceiverMarker | null {
1411
+ let file = opts.markerPath;
1412
+ if (!file && opts.gardenId) {
1413
+ file = metaReceiverMarkerPath(opts.gardenId, opts.receiversDir ?? defaultMetaReceiversDir());
1414
+ }
1415
+ if (!file || !fs.existsSync(file)) return null;
1416
+ try {
1417
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
1418
+ const marker: MetaReceiverMarker = {
1419
+ gardenId: requireGardenId(raw.gardenId),
1420
+ backend: requireBackend(raw.backend),
1421
+ nativeSessionId: requireNonEmptyString(raw.nativeSessionId, "nativeSessionId"),
1422
+ ownerPid: typeof raw.ownerPid === "number" ? raw.ownerPid : Number.NaN,
1423
+ ownerStartKey: requireNonEmptyString(raw.ownerStartKey, "ownerStartKey"),
1424
+ ownerKind: requireNonEmptyString(raw.ownerKind, "ownerKind"),
1425
+ armProvenance: requireArmProvenance(raw.armProvenance),
1426
+ updatedAt: requireNonEmptyString(raw.updatedAt, "updatedAt"),
1427
+ };
1428
+ if (opts.verifyOwner !== false) {
1429
+ if (!Number.isInteger(marker.ownerPid)) return null;
1430
+ const liveKey = processStartKey(marker.ownerPid);
1431
+ if (!liveKey || liveKey !== marker.ownerStartKey) return null;
1432
+ }
1433
+ return marker;
1434
+ } catch {
1435
+ return null;
1436
+ }
1437
+ }
1438
+
1439
+ export interface UpsertMetaSessionOptions {
1440
+ input: MetaIdentityMintInput;
1441
+ /** Override the store directory (defaults to {@link defaultMetaSessionsDir}). */
1442
+ dir?: string;
1443
+ /** Override the mailbox dir (defaults to {@link defaultMetaMailboxDir}) — only the v1→v2 receipt migration touches it. */
1444
+ mailboxDir?: string;
1445
+ now?: Date;
1446
+ onSkip?: (filename: string, err: Error) => void;
1447
+ }
1448
+
1449
+ export interface UpsertMetaSessionResult {
1450
+ action: UpsertAction;
1451
+ record: MetaIdentity;
1452
+ dir: string;
1453
+ /** Absolute path of the written record. */
1454
+ path: string;
1455
+ }
1456
+
1457
+ /**
1458
+ * Idempotent fs upsert (3D-4: writes v2 identity). Scan the store by
1459
+ * `nativeSessionId` with the dual-read identity scan (sees v1 AND v2, so an
1460
+ * existing citizen is found regardless of schema — never duplicate-mint, G1),
1461
+ * decide create vs attach on EXISTENCE, and write atomically as v2. On attach the
1462
+ * file is the existing garden id's record (same path, rewritten in place, v1→v2);
1463
+ * on create it is a fresh `<gardenId>.meta.json`. A duplicate `nativeSessionId`
1464
+ * throws (via the scan) rather than silently picking one.
1465
+ *
1466
+ * Crash-order (3D-4): when the matched file is still v1, its delivery receipts are
1467
+ * migrated to the mailbox state store BEFORE the v2 rewrite. If the process dies
1468
+ * between the two, the record is still v1 → the next attach re-migrates (state-wins
1469
+ * merge is idempotent), so no receipt is lost. The reverse order would lose the
1470
+ * receipt permanently. The write is tmp-file + rename so a crash never leaves a
1471
+ * half-written record (#30 crash-safety).
1472
+ */
1473
+ export function upsertMetaSession(opts: UpsertMetaSessionOptions): UpsertMetaSessionResult {
1474
+ const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
1475
+ fs.mkdirSync(dir, { recursive: true });
1476
+ const entries = fs.readdirSync(dir);
1477
+ const readRaw = (filename: string) => fs.readFileSync(path.join(dir, filename), "utf8");
1478
+ const existing = scanIdentityByNativeId(entries, opts.input.nativeSessionId, readRaw, opts.onSkip);
1479
+
1480
+ // Crash-order: migrate a v1 file's receipts to mailbox state BEFORE rewriting it
1481
+ // as v2. Re-read the matched file raw to see if it is still v1 (carries delivery).
1482
+ if (existing !== null) {
1483
+ const raw = parseMetaRecordAny(readRaw(`${existing.gardenId}.meta.json`));
1484
+ if (raw.schemaVersion === META_SCHEMA_VERSION) {
1485
+ migrateV1DeliveryReceipts({ gardenId: existing.gardenId, delivery: raw.delivery, mailboxDir: opts.mailboxDir });
1486
+ }
1487
+ }
1488
+
1489
+ const decision = decideUpsert(existing, opts.input, opts.now);
1490
+ const file = path.join(dir, metaRecordFilename(decision.record));
1491
+ atomicWriteIdentity(file, decision.record);
1492
+ return { action: decision.action, record: decision.record, dir, path: file };
1493
+ }
1494
+
1495
+ /** tmp-file + rename so a crash never leaves a half-written record (v2 identity write). */
1496
+ function atomicWriteIdentity(file: string, identity: MetaIdentity): void {
1497
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1498
+ fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
1499
+ fs.renameSync(tmp, file);
1500
+ }
1501
+
1502
+ // ---------------------------------------------------------------------------
1503
+ // Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
1504
+ // authority (a sender may only deliver to a known garden citizen); the mailbox
1505
+ // under defaultMetaMailboxDir() carries the volatile signal + message bodies.
1506
+ //
1507
+ // The honest delivery contract (do not blur these):
1508
+ // - enqueue : a sender wrote a `.msg` body + poked `inbox.signal`
1509
+ // (markEnqueued). The poke is what the plugin's FileChanged
1510
+ // doorbell watches — it wakes an idle session.
1511
+ // - `.msg.delivered`: the doorbell rang (FileChanged moved `.msg` ->
1512
+ // `.msg.delivered` and announced it). A FILESYSTEM marker =
1513
+ // WAKE ATTEMPT, NOT a read.
1514
+ // - lastReadAt : the model called readMetaInbox (the inbox-read tool) and
1515
+ // the body actually reached model-visible context. THIS is
1516
+ // the real D7 read-receipt. For Claude self-fetch, delivered
1517
+ // != read; readMetaInbox is the only thing that sets it.
1518
+ // ---------------------------------------------------------------------------
1519
+
1520
+ /** Resolve + validate a garden id and return its record file path under a store dir. */
1521
+ function recordFileFor(sessionsDir: string, gardenId: string): string {
1522
+ const id = requireGardenId(gardenId);
1523
+ return path.join(path.resolve(expandTilde(sessionsDir)), `${id}.meta.json`);
1524
+ }
1525
+
1526
+ /**
1527
+ * Read + parse a V1 meta-record by garden id, or throw if unknown (3D-4: renamed
1528
+ * from readMetaRecordByGardenId, demoted to v1-only — it uses the strict v1 parser
1529
+ * and throws on a v2 file). The live path reads identity via
1530
+ * readMetaIdentityByGardenId (dual-read); this stays for v1-fixture / dual-read
1531
+ * gates that need the raw v1 record (with delivery).
1532
+ */
1533
+ export function readMetaRecordV1ByGardenId(
1534
+ gardenId: string,
1535
+ sessionsDir: string = defaultMetaSessionsDir(),
1536
+ ): MetaRecord {
1537
+ const id = requireGardenId(gardenId);
1538
+ const file = recordFileFor(sessionsDir, id);
1539
+ if (!fs.existsSync(file)) {
1540
+ throw new MetaRecordError(
1541
+ `no meta-record for garden id "${id}" under ${path.dirname(file)} — not a garden citizen, cannot deliver.`,
1542
+ );
1543
+ }
1544
+ const record = parseMetaRecord(fs.readFileSync(file, "utf8"));
1545
+ // The record BODY is the SSOT; the filename is only a denote-sortable surface.
1546
+ // A `<id>.meta.json` whose body carries a DIFFERENT gardenId is corrupt (a
1547
+ // renamed/clobbered file) and would misroute delivery — fail-fast, never trust
1548
+ // the filename over the body.
1549
+ if (record.gardenId !== id) {
1550
+ throw new MetaRecordError(
1551
+ `meta-record body/filename drift: ${id}.meta.json contains gardenId "${record.gardenId}". ` +
1552
+ `The body is the authority; this file is corrupt. Remove or fix it.`,
1553
+ );
1554
+ }
1555
+ return record;
1556
+ }
1557
+
1558
+ /**
1559
+ * The dual-read identity read-by-gardenId (0.11 Stage 0 step 3D-4 commit1,
1560
+ * additive). Same contract as readMetaRecordByGardenId — read the file, body is
1561
+ * SSOT, fail-fast on body/filename gardenId drift — but reads v1 AND v2 (via
1562
+ * parseMetaIdentity) and returns normalized identity. This is what the live path
1563
+ * uses (enqueue/read, the MCP sender-marker check) so it survives the v2 cut;
1564
+ * readMetaRecordV1ByGardenId remains the v1-only raw reader for v1-fixture gates.
1565
+ */
1566
+ export function readMetaIdentityByGardenId(
1567
+ gardenId: string,
1568
+ sessionsDir: string = defaultMetaSessionsDir(),
1569
+ ): MetaIdentity {
1570
+ const id = requireGardenId(gardenId);
1571
+ const file = recordFileFor(sessionsDir, id);
1572
+ if (!fs.existsSync(file)) {
1573
+ throw new MetaRecordError(
1574
+ `no meta-record for garden id "${id}" under ${path.dirname(file)} — not a garden citizen, cannot deliver.`,
1575
+ );
1576
+ }
1577
+ const identity = parseMetaIdentity(fs.readFileSync(file, "utf8"));
1578
+ if (identity.gardenId !== id) {
1579
+ throw new MetaRecordError(
1580
+ `meta-record body/filename drift: ${id}.meta.json contains gardenId "${identity.gardenId}". ` +
1581
+ `The body is the authority; this file is corrupt. Remove or fix it.`,
1582
+ );
1583
+ }
1584
+ return identity;
1585
+ }
1586
+
1587
+ /**
1588
+ * Probe-free existence check for a garden citizen's meta-record. Used by the 5d
1589
+ * entwurf_v2 production `resolveTarget`: a MISSING record is a soft `bad-target`
1590
+ * (identity:null), but a PRESENT-but-corrupt record must fail loud — so the producer
1591
+ * `existsSync`-checks here FIRST and only calls `readMetaIdentityByGardenId` when this
1592
+ * returns true, leaving drift/corruption as the lone throw (never matched by message
1593
+ * string). Validates the gid (F2-P1) like its read sibling.
1594
+ */
1595
+ export function metaRecordExistsByGardenId(gardenId: string, sessionsDir: string = defaultMetaSessionsDir()): boolean {
1596
+ const id = requireGardenId(gardenId);
1597
+ return fs.existsSync(recordFileFor(sessionsDir, id));
1598
+ }
1599
+
1600
+ export interface EnqueueMetaMessageOptions {
1601
+ gardenId: string;
1602
+ body: string;
1603
+ sessionsDir?: string;
1604
+ mailboxDir?: string;
1605
+ now?: Date;
1606
+ }
1607
+
1608
+ export interface EnqueueMetaMessageResult {
1609
+ gardenId: string;
1610
+ recordPath: string;
1611
+ messagePath: string;
1612
+ signalPath: string;
1613
+ }
1614
+
1615
+ /**
1616
+ * Deliver a message body to a garden citizen's mailbox: validate the record
1617
+ * exists, write the `.msg` body FIRST, stamp `lastEnqueuedAt`, then poke
1618
+ * `inbox.signal` LAST so the doorbell that fires on the poke always finds the
1619
+ * body already on disk (no wake-with-empty-mailbox race). Returns the paths so a
1620
+ * sender can show exactly what was queued.
1621
+ */
1622
+ export function enqueueMetaMessage(opts: EnqueueMetaMessageOptions): EnqueueMetaMessageResult {
1623
+ const now = opts.now ?? new Date();
1624
+ const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
1625
+ const recordFile = recordFileFor(sessionsDir, opts.gardenId);
1626
+ // 3D-4: read IDENTITY (dual-read v1+v2) — confirms the citizen exists and
1627
+ // normalizes the gardenId. The record is no longer mutated; the v2 record carries
1628
+ // no delivery, so the enqueue receipt lives SOLELY in the mailbox state store.
1629
+ const citizen = readMetaIdentityByGardenId(opts.gardenId, sessionsDir);
1630
+ if (typeof opts.body !== "string" || opts.body.length === 0) {
1631
+ throw new MetaRecordError("enqueueMetaMessage: body must be a non-empty string.");
1632
+ }
1633
+
1634
+ const dir = path.join(path.resolve(expandTilde(opts.mailboxDir ?? defaultMetaMailboxDir())), citizen.gardenId);
1635
+ fs.mkdirSync(dir, { recursive: true });
1636
+ // Sortable + unique: ISO stamp (colons/dots flattened for a clean filename) +
1637
+ // a short random tag so two sends in the same millisecond never collide.
1638
+ const stamp = `${isoNow(now).replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}`;
1639
+ const messagePath = path.join(dir, `${stamp}.msg`);
1640
+ fs.writeFileSync(messagePath, opts.body, { mode: 0o600 });
1641
+
1642
+ // 3D-4 the cut: the enqueue receipt lives SOLELY in the mailbox state store now
1643
+ // (record.delivery removed from the v2 record). No record write. Stamped before the
1644
+ // signal poke so all state is settled before the watch fires. A state-stamp throw
1645
+ // surfaces fail-loud — no rollback: the `.msg` may already be written, but the
1646
+ // caller never gets a silent success.
1647
+ stampMailboxReceipt({
1648
+ gardenId: citizen.gardenId,
1649
+ mailboxDir: opts.mailboxDir ?? defaultMetaMailboxDir(),
1650
+ field: "lastEnqueuedAt",
1651
+ now,
1652
+ });
1653
+
1654
+ // Poke LAST. Writing the timestamp changes the file's content+mtime, which is
1655
+ // what the plugin's FileChanged watch fires on.
1656
+ const signalPath = path.join(dir, "inbox.signal");
1657
+ fs.writeFileSync(signalPath, `${isoNow(now)}\n`, { mode: 0o600 });
1658
+
1659
+ return { gardenId: citizen.gardenId, recordPath: recordFile, messagePath, signalPath };
1660
+ }
1661
+
1662
+ export interface MetaInboxMessage {
1663
+ file: string;
1664
+ body: string;
1665
+ }
1666
+
1667
+ export interface ReadMetaInboxOptions {
1668
+ gardenId: string;
1669
+ sessionsDir?: string;
1670
+ mailboxDir?: string;
1671
+ now?: Date;
1672
+ }
1673
+
1674
+ export interface ReadMetaInboxResult {
1675
+ gardenId: string;
1676
+ messages: MetaInboxMessage[];
1677
+ /** The D7 read-receipt timestamp stamped on this read, or null if nothing was unread. */
1678
+ readAt: string | null;
1679
+ recordPath: string;
1680
+ }
1681
+
1682
+ /**
1683
+ * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
1684
+ * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
1685
+ * `*.read` so a re-read never double-returns, and — only if at least one message
1686
+ * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
1687
+ * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
1688
+ * reading nothing is not a receipt.
1689
+ */
1690
+ export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
1691
+ const now = opts.now ?? new Date();
1692
+ const sessionsDir = opts.sessionsDir ?? defaultMetaSessionsDir();
1693
+ const recordFile = recordFileFor(sessionsDir, opts.gardenId);
1694
+ // 3D-4: read IDENTITY (dual-read) — citizen-existence + normalized gardenId. The
1695
+ // record is not mutated; the read receipt lives solely in the mailbox state store.
1696
+ const citizen = readMetaIdentityByGardenId(opts.gardenId, sessionsDir);
1697
+
1698
+ const dir = path.join(path.resolve(expandTilde(opts.mailboxDir ?? defaultMetaMailboxDir())), citizen.gardenId);
1699
+ const entries = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
1700
+ // Unread = a body still ending in .msg or .msg.delivered (NOT yet .read).
1701
+ const unread = entries.filter((f) => f.endsWith(".msg") || f.endsWith(".msg.delivered")).sort();
1702
+
1703
+ const messages: MetaInboxMessage[] = [];
1704
+ for (const f of unread) {
1705
+ const full = path.join(dir, f);
1706
+ messages.push({ file: f, body: fs.readFileSync(full, "utf8") });
1707
+ fs.renameSync(full, `${full}.read`); // archive; .read no longer matches the doorbell's *.msg glob
1708
+ }
1709
+
1710
+ if (messages.length === 0) {
1711
+ return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
1712
+ }
1713
+
1714
+ // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
1715
+ // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
1716
+ // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
1717
+ // would report read-time as delivery-time, so it is left as the doorbell left it.
1718
+ // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
1719
+ // Inside the messages.length>0 branch by construction — an empty inbox already
1720
+ // early-returned (no .read archive, state untouched), so "read nothing" is no
1721
+ // receipt on the state either. A throw surfaces fail-loud — no rollback: the
1722
+ // messages are already archived (.read), but the caller never gets a silent success.
1723
+ const state = stampMailboxReceipt({
1724
+ gardenId: citizen.gardenId,
1725
+ mailboxDir: opts.mailboxDir ?? defaultMetaMailboxDir(),
1726
+ field: "lastReadAt",
1727
+ now,
1728
+ });
1729
+ return { gardenId: citizen.gardenId, messages, readAt: state.lastReadAt, recordPath: recordFile };
1730
+ }
1731
+
1732
+ // ---------------------------------------------------------------------------
1733
+ // mailbox receipt state — the receipt authority's new home (0.11 Stage 0 3B)
1734
+ //
1735
+ // Today the read-receipt lives at `record.delivery.lastReadAt` (stamped by
1736
+ // readMetaInbox). v2 identity (step 3A) drops `delivery{}` out of the record, so
1737
+ // the receipt timestamps need a new home BEFORE that removal (NEXT.md 고정순서
1738
+ // 4: "delivery 제거 전 mailbox receipt state schema 먼저 못박음 ... 대체 state
1739
+ // 없이 제거 금지"). That home is `<meta-mailbox>/<gardenId>/state.json` — a
1740
+ // SIBLING of the inbox.signal/.msg traffic it accounts for, so the receipt lives
1741
+ // with the mailbox (volatile delivery bookkeeping), not with identity.
1742
+ //
1743
+ // This block is the SCHEMA + STORE only. It does NOT yet re-wire the live
1744
+ // enqueue/read path (that dual-write + the eventual record.delivery removal land
1745
+ // in step 3D, behind NEXT.md 끊을 지점 ②, so the "정당한 update vs regression"
1746
+ // gate-rewrite stays in one reviewed place). wakeMode/deliveryLevel are NOT here
1747
+ // — those are capability, not receipt (step 3C).
1748
+ // ---------------------------------------------------------------------------
1749
+
1750
+ /** Bump only on a breaking receipt-state shape change; the parser refuses other versions. */
1751
+ export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
1752
+
1753
+ /**
1754
+ * The per-citizen mailbox receipt state. Holds exactly the three delivery
1755
+ * timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
1756
+ * capability, deliberately absent). Body is SSOT; the on-disk path is derived.
1757
+ */
1758
+ export interface MailboxReceiptState {
1759
+ schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;
1760
+ gardenId: string;
1761
+ lastEnqueuedAt: string | null;
1762
+ lastDeliveredAt: string | null;
1763
+ lastReadAt: string | null;
1764
+ }
1765
+
1766
+ /** The receipt timestamp fields a mutator may stamp (runtime SSOT for validation). */
1767
+ export const MAILBOX_RECEIPT_FIELDS = ["lastEnqueuedAt", "lastDeliveredAt", "lastReadAt"] as const;
1768
+ export type MailboxReceiptField = (typeof MAILBOX_RECEIPT_FIELDS)[number];
1769
+
1770
+ /**
1771
+ * Validate an untrusted field name at runtime. The TS `MailboxReceiptField`
1772
+ * type does not survive a JS call site or an `as` cast — an invalid field would
1773
+ * otherwise create a stray key in memory that `serialize` silently drops. Crash
1774
+ * instead, mirroring the record layer's "crash, don't warn".
1775
+ */
1776
+ function requireMailboxReceiptField(value: unknown): MailboxReceiptField {
1777
+ if (typeof value !== "string" || !MAILBOX_RECEIPT_FIELDS.includes(value as MailboxReceiptField)) {
1778
+ throw new MetaRecordError(
1779
+ `stampMailboxReceipt "field" must be one of ${MAILBOX_RECEIPT_FIELDS.join(" | ")} (got ${describe(value)}).`,
1780
+ );
1781
+ }
1782
+ return value as MailboxReceiptField;
1783
+ }
1784
+
1785
+ /** A fresh, never-touched receipt state for a citizen (all timestamps null). */
1786
+ export function emptyMailboxReceiptState(gardenId: string): MailboxReceiptState {
1787
+ return {
1788
+ schemaVersion: MAILBOX_RECEIPT_SCHEMA_VERSION,
1789
+ gardenId: requireGardenId(gardenId),
1790
+ lastEnqueuedAt: null,
1791
+ lastDeliveredAt: null,
1792
+ lastReadAt: null,
1793
+ };
1794
+ }
1795
+
1796
+ /** Canonical serialization: stable key order, 2-space indent, trailing newline. */
1797
+ export function serializeMailboxReceiptState(state: MailboxReceiptState): string {
1798
+ const ordered = {
1799
+ schemaVersion: state.schemaVersion,
1800
+ gardenId: state.gardenId,
1801
+ lastEnqueuedAt: state.lastEnqueuedAt,
1802
+ lastDeliveredAt: state.lastDeliveredAt,
1803
+ lastReadAt: state.lastReadAt,
1804
+ };
1805
+ return `${JSON.stringify(ordered, null, 2)}\n`;
1806
+ }
1807
+
1808
+ const MAILBOX_RECEIPT_KEYS: readonly string[] = [
1809
+ "schemaVersion",
1810
+ "gardenId",
1811
+ "lastEnqueuedAt",
1812
+ "lastDeliveredAt",
1813
+ "lastReadAt",
1814
+ ];
1815
+
1816
+ /** Parse + fully validate untrusted JSON into a MailboxReceiptState. Throws on any drift. */
1817
+ export function parseMailboxReceiptState(json: string): MailboxReceiptState {
1818
+ let raw: unknown;
1819
+ try {
1820
+ raw = JSON.parse(json);
1821
+ } catch (err) {
1822
+ throw new MetaRecordError(
1823
+ `mailbox receipt state is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
1824
+ );
1825
+ }
1826
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1827
+ throw new MetaRecordError(`mailbox receipt state must be a JSON object (got ${describe(raw)}).`);
1828
+ }
1829
+ const obj = raw as Record<string, unknown>;
1830
+ if (obj.schemaVersion !== MAILBOX_RECEIPT_SCHEMA_VERSION) {
1831
+ throw new MetaRecordError(
1832
+ `mailbox receipt state "schemaVersion" must be ${MAILBOX_RECEIPT_SCHEMA_VERSION} (got ${describe(obj.schemaVersion)}).`,
1833
+ );
1834
+ }
1835
+ const stray = Object.keys(obj).filter((k) => !MAILBOX_RECEIPT_KEYS.includes(k));
1836
+ if (stray.length > 0) {
1837
+ throw new MetaRecordError(
1838
+ `mailbox receipt state carries unexpected key(s) ${stray.map((k) => `"${k}"`).join(", ")} ` +
1839
+ `(allowed: ${MAILBOX_RECEIPT_KEYS.join(", ")}).`,
1840
+ );
1841
+ }
1842
+ return {
1843
+ schemaVersion: MAILBOX_RECEIPT_SCHEMA_VERSION,
1844
+ gardenId: requireGardenId(obj.gardenId),
1845
+ lastEnqueuedAt: requireNullableString(obj.lastEnqueuedAt, "lastEnqueuedAt"),
1846
+ lastDeliveredAt: requireNullableString(obj.lastDeliveredAt, "lastDeliveredAt"),
1847
+ lastReadAt: requireNullableString(obj.lastReadAt, "lastReadAt"),
1848
+ };
1849
+ }
1850
+
1851
+ /** The on-disk receipt-state path for a citizen: `<mailbox>/<gardenId>/state.json`. */
1852
+ export function mailboxReceiptStatePath(mailboxDir: string, gardenId: string): string {
1853
+ return path.join(path.resolve(expandTilde(mailboxDir)), requireGardenId(gardenId), "state.json");
1854
+ }
1855
+
1856
+ export interface MailboxReceiptOptions {
1857
+ gardenId: string;
1858
+ mailboxDir?: string;
1859
+ }
1860
+
1861
+ /**
1862
+ * Read a citizen's receipt state from disk, or an empty state if none exists
1863
+ * yet. Reading-nothing is not an error — a citizen that has never had a receipt
1864
+ * stamped simply has all-null timestamps (parallel to readMetaInbox treating an
1865
+ * empty inbox as "no receipt", not a failure).
1866
+ */
1867
+ export function readMailboxReceiptState(opts: MailboxReceiptOptions): MailboxReceiptState {
1868
+ const gardenId = requireGardenId(opts.gardenId);
1869
+ const file = mailboxReceiptStatePath(opts.mailboxDir ?? defaultMetaMailboxDir(), gardenId);
1870
+ if (!fs.existsSync(file)) return emptyMailboxReceiptState(gardenId);
1871
+ const state = parseMailboxReceiptState(fs.readFileSync(file, "utf8"));
1872
+ // Body is SSOT, and the body gardenId must agree with the path it was read
1873
+ // from — a state.json whose body claims a different citizen is corruption,
1874
+ // fail-fast (parallel to readMetaRecordByGardenId's body/filename drift rule).
1875
+ if (state.gardenId !== gardenId) {
1876
+ throw new MetaRecordError(
1877
+ `mailbox receipt state body/path gardenId drift — body gardenId=${state.gardenId}, read from <mailbox>/${gardenId}/state.json.`,
1878
+ );
1879
+ }
1880
+ return state;
1881
+ }
1882
+
1883
+ /**
1884
+ * Stamp ONE receipt field to `now` and atomically persist the state (read-
1885
+ * modify-write; creates the state on first stamp). Returns the updated state.
1886
+ * The atomic tmp+rename mirrors atomicWriteRecord so a concurrent reader never
1887
+ * observes a half-written state.json.
1888
+ */
1889
+ export function stampMailboxReceipt(
1890
+ opts: MailboxReceiptOptions & { field: MailboxReceiptField; now?: Date },
1891
+ ): MailboxReceiptState {
1892
+ const now = opts.now ?? new Date();
1893
+ const field = requireMailboxReceiptField(opts.field);
1894
+ const file = mailboxReceiptStatePath(opts.mailboxDir ?? defaultMetaMailboxDir(), opts.gardenId);
1895
+ const current = readMailboxReceiptState(opts);
1896
+ const updated: MailboxReceiptState = { ...current, [field]: isoNow(now) };
1897
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1898
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1899
+ fs.writeFileSync(tmp, serializeMailboxReceiptState(updated), { mode: 0o600 });
1900
+ fs.renameSync(tmp, file);
1901
+ return updated;
1902
+ }
1903
+
1904
+ /** The v1 delivery receipt timestamps that migrate to mailbox state (the 3 only — wakeMode/deliveryLevel are capability). */
1905
+ export interface V1DeliveryReceipts {
1906
+ lastEnqueuedAt: string | null;
1907
+ lastDeliveredAt: string | null;
1908
+ lastReadAt: string | null;
1909
+ }
1910
+
1911
+ /**
1912
+ * Migrate a v1 record's delivery receipts into the mailbox state store (3D-4),
1913
+ * called by upsert BEFORE it rewrites a v1 file as v2 so a pre-3D-2 receipt is not
1914
+ * lost. Per-field merge, STATE WINS: a v1 timestamp only fills a state field that
1915
+ * is still null (`state[f] ?? v1[f]`); a state value already there is never
1916
+ * overwritten. ONLY the 3 timestamps move — wakeMode/deliveryLevel are capability
1917
+ * (registry), and a stray key would trip the receipt-state strict keyset (H2).
1918
+ *
1919
+ * "Migrating nothing is not a receipt": if no v1 value fills a null state field
1920
+ * (state already wins on every field, or v1 had nothing), this is a NO-OP — no
1921
+ * write, no state.json creation — returning null. Otherwise it writes the merged
1922
+ * state atomically (tmp+rename, mirroring stampMailboxReceipt) and returns it.
1923
+ */
1924
+ export function migrateV1DeliveryReceipts(opts: {
1925
+ gardenId: string;
1926
+ delivery: V1DeliveryReceipts;
1927
+ mailboxDir?: string;
1928
+ }): MailboxReceiptState | null {
1929
+ const gardenId = requireGardenId(opts.gardenId);
1930
+ const mailboxDir = opts.mailboxDir ?? defaultMetaMailboxDir();
1931
+ const current = readMailboxReceiptState({ gardenId, mailboxDir });
1932
+ const merged: MailboxReceiptState = {
1933
+ ...current,
1934
+ lastEnqueuedAt: current.lastEnqueuedAt ?? opts.delivery.lastEnqueuedAt,
1935
+ lastDeliveredAt: current.lastDeliveredAt ?? opts.delivery.lastDeliveredAt,
1936
+ lastReadAt: current.lastReadAt ?? opts.delivery.lastReadAt,
1937
+ };
1938
+ if (
1939
+ merged.lastEnqueuedAt === current.lastEnqueuedAt &&
1940
+ merged.lastDeliveredAt === current.lastDeliveredAt &&
1941
+ merged.lastReadAt === current.lastReadAt
1942
+ ) {
1943
+ return null; // no-write / no-create — migrating nothing is not a receipt
1944
+ }
1945
+ const file = mailboxReceiptStatePath(mailboxDir, gardenId);
1946
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1947
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1948
+ fs.writeFileSync(tmp, serializeMailboxReceiptState(merged), { mode: 0o600 });
1949
+ fs.renameSync(tmp, file);
1950
+ return merged;
1951
+ }