@junghanacs/entwurf 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +248 -0
  3. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  17. package/mcp/entwurf-bridge/src/index.ts +16 -8
  18. package/package.json +3 -2
  19. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  20. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  21. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  22. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  23. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  24. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  25. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  26. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  27. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  30. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
  32. package/pi-extensions/lib/meta-session.ts +76 -7
  33. package/pi-extensions/meta-bridge-hook.ts +72 -3
  34. package/run.sh +21 -0
  35. package/scripts/check-bridge-delivery.ts +42 -1
  36. package/scripts/check-entwurf-fact-provider.ts +22 -1
  37. package/scripts/check-entwurf-facts.ts +17 -2
  38. package/scripts/check-entwurf-peers-surface.ts +20 -2
  39. package/scripts/check-entwurf-self-address.ts +20 -2
  40. package/scripts/check-entwurf-v2-decider.ts +6 -3
  41. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  42. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  43. package/scripts/check-entwurf-v2-production.ts +72 -1
  44. package/scripts/check-entwurf-v2-runner.ts +18 -0
  45. package/scripts/check-entwurf-v2-send.ts +13 -1
  46. package/scripts/check-entwurf-v2-surface.ts +75 -3
  47. package/scripts/check-gate-qualification.ts +1 -0
  48. package/scripts/check-hook-launch-topology.ts +70 -1
  49. package/scripts/check-mailbox-receipt-state.ts +6 -0
  50. package/scripts/check-meta-doctor-oracle.sh +73 -0
  51. package/scripts/check-meta-hook-session-switch.ts +560 -0
  52. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  53. package/scripts/check-meta-manifest-schema.py +19 -1
  54. package/scripts/check-meta-session.ts +10 -2
  55. package/scripts/meta-bridge-doctor.sh +47 -7
  56. package/scripts/meta-bridge-state.py +23 -5
  57. package/scripts/meta-bridge-statusline.sh +65 -2
  58. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  59. package/scripts/raw-async-delivery/README.md +280 -9
  60. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  61. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  62. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  63. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  64. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  65. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  66. package/scripts/raw-claude-session-switch/README.md +170 -0
  67. package/scripts/smoke-acp-cortex-live.ts +22 -2
  68. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  69. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  70. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  71. package/scripts/smoke-meta-async-drift.sh +31 -8
  72. package/scripts/smoke-meta-install-state.sh +170 -11
  73. package/scripts/smoke-meta-keyset-guard.sh +4 -1
  74. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -0,0 +1,560 @@
1
+ /**
2
+ * check-meta-hook-session-switch — the regression line under a Claude Code SESSION
3
+ * SWITCH: one native process that stops serving one garden and starts serving another
4
+ * (#101). It is the gate that did not exist when the defect shipped, and its shape is
5
+ * the reason it did not: every claude-side cell the suite had asked about ONE session
6
+ * per process, so "one owner pid, two gardens" had no cell anywhere.
7
+ *
8
+ * WHAT A SESSION SWITCH IS. Opening `claude` mints a new session, and an in-session
9
+ * `/resume` (or `/clear`) then fires a SECOND SessionStart inside that one pid, for a
10
+ * DIFFERENT native id — the session the process started with is left behind. Measured on
11
+ * oracle 2026-09-04 (`~/.pi/agent/meta-bridge-hook.log`): the field case at pid 143742,
12
+ * 09:31:35 create `…-ac7a1a`, 09:31:39 attach `…-e09b66`, both arming a receiver marker
13
+ * under that one pid — the first garden's transcript was never written and a message
14
+ * enqueued to it at 09:33 was still an undelivered `.msg` an hour later; then reproduced
15
+ * deliberately at 13:13:04 → 13:13:37 (raw lab S4, `source=startup` → `source=resume`).
16
+ * `claude --resume`, with or without the picker, does NOT do this: it fires exactly one
17
+ * SessionStart carrying the real id (raw lab S2/S3), and compaction re-fires SessionStart
18
+ * for the SAME id (S6), which this rule leaves alone.
19
+ *
20
+ * WHAT IT PROVES, and each cell is a different half:
21
+ *
22
+ * A (hook) the second SessionStart RETIRES the marker of the garden this pid no
23
+ * longer serves — and only that one. A same-garden re-registration (every
24
+ * UserPromptSubmit) retires NOTHING, which is the cell that keeps the
25
+ * repair from eating the live citizen it was meant to protect.
26
+ * B (reader) even with a retired marker still on disk — an older deployed hook, a
27
+ * crash between the two events — the garden is NOT deliverable, because
28
+ * `watchArmed` is measured against the owner's sender marker rather than
29
+ * copied from the identity match. Fail-closed at the reader, so the repair
30
+ * does not depend on the writer having run.
31
+ * C (surface) the reject says WHICH axis failed, in the predicate's own words.
32
+ *
33
+ * THE HEADLINE. After a switch, exactly ONE of the two gardens is deliverable, and it is
34
+ * the one the operator is actually sitting in.
35
+ *
36
+ * HOW IT PLAYS CLAUDE. Like check-hook-launch-topology: the gate spawns the SHIPPED
37
+ * `hook-launch.sh` with the manifest's own argv, which `exec`s the payload, so the
38
+ * hook's parent is this gate process — one fake owner pid for every drive, which is
39
+ * exactly the topology the defect needs. Every meta root is sandboxed (agent dir + the
40
+ * four `ENTWURF_META_*_DIR`), so nothing here can read or write the operator's garden.
41
+ *
42
+ * WHAT IT DELIBERATELY DOES NOT PROVE. The vendor's `source` field (startup | resume |
43
+ * clear | compact) is LOGGED by the hook and branched on by nothing, so this gate asserts
44
+ * the log line, not a behaviour keyed to the value. The retirement is decided by what is
45
+ * on disk, which is true on every host and every Claude version; a live measurement of
46
+ * the envelope order belongs to the raw lab (scripts/raw-claude-session-switch), not here.
47
+ */
48
+
49
+ import assert from "node:assert/strict";
50
+ import { spawnSync } from "node:child_process";
51
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
52
+ import { tmpdir } from "node:os";
53
+ import * as path from "node:path";
54
+ import { fileURLToPath } from "node:url";
55
+ import { resolveMailboxReceiverFacts } from "../pi-extensions/lib/entwurf-deliverability.ts";
56
+ import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
57
+ import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
58
+ import type { DispatchDecision } from "../pi-extensions/lib/entwurf-v2-decider.ts";
59
+ import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
60
+ import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
61
+ import { renderEntwurfV2Result } from "../pi-extensions/lib/entwurf-v2-surface.ts";
62
+ import {
63
+ listAllMetaIdentitiesDir,
64
+ type MetaIdentity,
65
+ makeStoreRecordReader,
66
+ metaReceiverMarkerPath,
67
+ readActiveStoreEntries,
68
+ readMetaReceiverMarker,
69
+ readMetaSenderMarker,
70
+ upsertMetaSession,
71
+ writeMetaReceiverMarker,
72
+ } from "../pi-extensions/lib/meta-session.ts";
73
+
74
+ let passed = 0;
75
+ function ok(label: string, cond: boolean): void {
76
+ assert.ok(cond, label);
77
+ console.log(` ok ${label}`);
78
+ passed++;
79
+ }
80
+
81
+ const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
82
+ const PLUGIN_SRC = path.join(REPO_DIR, "pi", "meta-bridge", "entwurf-meta-receive");
83
+ const LAUNCHER = path.join(PLUGIN_SRC, "scripts", "hook-launch.sh");
84
+ const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
85
+
86
+ const manifest = JSON.parse(readFileSync(path.join(PLUGIN_SRC, "hooks", "hooks.json"), "utf8")) as {
87
+ hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command?: string; args?: string[] }> }>>;
88
+ };
89
+ const sessionStartLeaf = manifest.hooks.SessionStart?.[0]?.hooks[0] ?? {};
90
+
91
+ /** Stage a plugin bundle: entry + lib closure + the launcher, exactly as installed. */
92
+ function makeBundle(root: string): void {
93
+ mkdirSync(path.join(root, "lib"), { recursive: true });
94
+ mkdirSync(path.join(root, "scripts"), { recursive: true });
95
+ copyFileSync(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), path.join(root, "meta-bridge-hook.ts"));
96
+ copyFileSync(
97
+ path.join(REPO_DIR, "pi-extensions", "lib", "meta-session.ts"),
98
+ path.join(root, "lib", "meta-session.ts"),
99
+ );
100
+ copyFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "session-id.js"), path.join(root, "lib", "session-id.js"));
101
+ const launcher = path.join(root, "scripts", "hook-launch.sh");
102
+ copyFileSync(LAUNCHER, launcher);
103
+ chmodSync(launcher, 0o755);
104
+ }
105
+
106
+ /** Resolve one manifest element the way Claude does: plain-string substitution, no shell. */
107
+ function resolveEl(value: string, pluginRoot: string): string {
108
+ return value
109
+ .replace("__NODE_BIN__", process.execPath)
110
+ .replaceAll(PLACEHOLDER, pluginRoot)
111
+ .replace("__HOOK_ENTRY__", "meta-bridge-hook.ts");
112
+ }
113
+
114
+ // ── one sandbox for the whole switch story (the pid is what ties it together) ──
115
+ const AGENT_ROOT = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-agent-"));
116
+ const PLUGIN_ROOT = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-plugin-"));
117
+ const CWD = mkdtempSync(path.join(tmpdir(), "psa-hook-switch-cwd-"));
118
+ makeBundle(PLUGIN_ROOT);
119
+
120
+ const ROOTS = {
121
+ sessionsDir: path.join(AGENT_ROOT, "meta-sessions"),
122
+ sendersDir: path.join(AGENT_ROOT, "meta-senders"),
123
+ receiversDir: path.join(AGENT_ROOT, "meta-receivers"),
124
+ mailboxDir: path.join(AGENT_ROOT, "meta-mailbox"),
125
+ locksDir: path.join(AGENT_ROOT, "entwurf-v2-locks"),
126
+ socketsDir: path.join(AGENT_ROOT, "entwurf-control"),
127
+ };
128
+ const HOOK_LOG = path.join(AGENT_ROOT, "meta-bridge-hook.log");
129
+
130
+ function hookEnv(): NodeJS.ProcessEnv {
131
+ const env: NodeJS.ProcessEnv = {
132
+ ...process.env,
133
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
134
+ PI_CODING_AGENT_DIR: AGENT_ROOT,
135
+ ENTWURF_META_SESSIONS_DIR: ROOTS.sessionsDir,
136
+ ENTWURF_META_SENDERS_DIR: ROOTS.sendersDir,
137
+ ENTWURF_META_RECEIVERS_DIR: ROOTS.receiversDir,
138
+ ENTWURF_META_MAILBOX_DIR: ROOTS.mailboxDir,
139
+ };
140
+ // Only the launcher may stamp the provenance token; an inherited one would let the
141
+ // owner join pass vacuously if this gate were ever run from inside a hook.
142
+ delete env.ENTWURF_META_HOOK_LAUNCH;
143
+ return env;
144
+ }
145
+
146
+ // The gate's OWN readers must see the same sandbox as the hook children (the marker
147
+ // readers resolve their dirs from the environment at call time).
148
+ process.env.PI_CODING_AGENT_DIR = AGENT_ROOT;
149
+ process.env.ENTWURF_META_SESSIONS_DIR = ROOTS.sessionsDir;
150
+ process.env.ENTWURF_META_SENDERS_DIR = ROOTS.sendersDir;
151
+ process.env.ENTWURF_META_RECEIVERS_DIR = ROOTS.receiversDir;
152
+ process.env.ENTWURF_META_MAILBOX_DIR = ROOTS.mailboxDir;
153
+
154
+ /** Drive the shipped hook once, through the shipped launcher, as Claude would. */
155
+ function driveHook(opts: { event: string; nativeSessionId: string; source: string; transcriptPath: string }): void {
156
+ const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
157
+ const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
158
+ const result = spawnSync(exe, argv, {
159
+ encoding: "utf8",
160
+ input: JSON.stringify({
161
+ hook_event_name: opts.event,
162
+ session_id: opts.nativeSessionId,
163
+ transcript_path: opts.transcriptPath,
164
+ cwd: CWD,
165
+ source: opts.source,
166
+ }),
167
+ env: hookEnv(),
168
+ });
169
+ assert.equal(result.status, 0, `hook drive ${opts.nativeSessionId} exited ${result.status}: ${result.stderr}`);
170
+ }
171
+
172
+ /** The gate's own marker readers, pointed at the sandbox — one definition, so every cell that
173
+ * composes receiver facts reads the same two files the production seam would. */
174
+ const gateReaders = {
175
+ readReceiverMarker: (gardenId: string) => readMetaReceiverMarker({ gardenId, receiversDir: ROOTS.receiversDir }),
176
+ readSenderMarker: (backend: string, ownerPid: number) =>
177
+ readMetaSenderMarker({ backend: backend as "claude-code", ownerPid, sendersDir: ROOTS.sendersDir }),
178
+ };
179
+
180
+ function gardenIdFor(nativeSessionId: string): string {
181
+ const { identities } = listAllMetaIdentitiesDir(ROOTS.sessionsDir);
182
+ const hit = identities.find((i) => i.nativeSessionId === nativeSessionId);
183
+ assert.ok(hit, `no record for native session ${nativeSessionId}`);
184
+ return hit.gardenId;
185
+ }
186
+
187
+ function identityFor(gardenId: string): MetaIdentity {
188
+ const { identities } = listAllMetaIdentitiesDir(ROOTS.sessionsDir);
189
+ const hit = identities.find((i) => i.gardenId === gardenId);
190
+ assert.ok(hit, `no record for garden ${gardenId}`);
191
+ return hit;
192
+ }
193
+
194
+ // ── the switch: one pid, a startup session, then the one the operator resumed ───
195
+ // The abandoned session's transcript is NEVER written — that is what a session opened and
196
+ // left before its first turn looks like on disk, and it is why it was invisible to every
197
+ // surface that only reads records.
198
+ const GHOST_NATIVE = "79e05f96-abandoned-startup";
199
+ const REAL_NATIVE = "f654eed7-resumed-conversation";
200
+ const REAL_TRANSCRIPT = path.join(CWD, `${REAL_NATIVE}.jsonl`);
201
+ writeFileSync(REAL_TRANSCRIPT, "");
202
+
203
+ driveHook({
204
+ event: "SessionStart",
205
+ nativeSessionId: GHOST_NATIVE,
206
+ source: "startup",
207
+ transcriptPath: path.join(CWD, `${GHOST_NATIVE}.jsonl`),
208
+ });
209
+ const GHOST_GID = gardenIdFor(GHOST_NATIVE);
210
+ ok(
211
+ "the startup session arms a receiver marker (the state the switch has to clean up)",
212
+ readMetaReceiverMarker({ gardenId: GHOST_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
213
+ );
214
+
215
+ driveHook({
216
+ event: "SessionStart",
217
+ nativeSessionId: REAL_NATIVE,
218
+ source: "resume",
219
+ transcriptPath: REAL_TRANSCRIPT,
220
+ });
221
+ const REAL_GID = gardenIdFor(REAL_NATIVE);
222
+ ok("the switch mints a SECOND garden id (two records, one pid)", GHOST_GID !== REAL_GID);
223
+
224
+ // ── A. the hook retires the garden this pid stopped serving ───────────────────
225
+ ok(
226
+ "both records survive the switch — a retirement takes the marker, never the record [QK:MHSS-RECORDS-SURVIVE]",
227
+ existsSync(path.join(ROOTS.sessionsDir, `${GHOST_GID}.meta.json`)) &&
228
+ existsSync(path.join(ROOTS.sessionsDir, `${REAL_GID}.meta.json`)),
229
+ );
230
+ ok(
231
+ "the retired garden's receiver marker is GONE after the switch [QK:MHSS-RETIRE-PREV-GARDEN]",
232
+ !existsSync(metaReceiverMarkerPath(GHOST_GID, ROOTS.receiversDir)),
233
+ );
234
+ ok(
235
+ "the resumed garden keeps its receiver marker, owned by this pid",
236
+ readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
237
+ );
238
+ ok(
239
+ "the sender marker names the garden this pid serves NOW",
240
+ readMetaSenderMarker({ backend: "claude-code", ownerPid: process.pid, sendersDir: ROOTS.sendersDir })?.gardenId ===
241
+ REAL_GID,
242
+ );
243
+ {
244
+ const log = readFileSync(HOOK_LOG, "utf8");
245
+ ok(
246
+ "the hook logs the vendor's own `source` for both events (the receipt this host lacked) [QK:MHSS-SOURCE-LOGGED]",
247
+ /source=startup/.test(log) && /source=resume/.test(log),
248
+ );
249
+ ok(
250
+ "the retirement is named in the log, with the garden it let go",
251
+ new RegExp(`retired receiver marker ${GHOST_GID}`).test(log),
252
+ );
253
+ }
254
+
255
+ // ── A negative. A same-garden re-registration must retire NOTHING ─────────────
256
+ // Every UserPromptSubmit rewrites the sender marker with the SAME garden, and a
257
+ // SessionStart can re-fire for the session already being served. A retirement rule that
258
+ // only asked "did the sender marker exist?" would delete the live citizen's own doorbell
259
+ // on the next keystroke — a worse failure than the one being repaired.
260
+ // A same-garden re-registration must retire nothing — and must not even CLAIM to. The marker
261
+ // alone cannot say so on this path: the arm block later in the same run rewrites it, so an
262
+ // unconditional retirement would be invisible in the filesystem and visible only in the log it
263
+ // wrote on the way past. The log is therefore the assertion.
264
+ {
265
+ const before = readFileSync(HOOK_LOG, "utf8").length;
266
+ driveHook({
267
+ event: "SessionStart",
268
+ nativeSessionId: REAL_NATIVE,
269
+ source: "startup",
270
+ transcriptPath: REAL_TRANSCRIPT,
271
+ });
272
+ const delta = readFileSync(HOOK_LOG, "utf8").slice(before);
273
+ ok(
274
+ "a same-garden re-registration retires nothing, and claims no retirement [QK:MHSS-RETIRE-KEEPS-CURRENT]",
275
+ !/retired receiver marker/.test(delta) &&
276
+ readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
277
+ );
278
+ }
279
+ // UserPromptSubmit is the cell that MATTERS here, and it is why the guard is a
280
+ // garden COMPARISON rather than "did a sender marker exist?". It rewrites the sender
281
+ // marker on every keystroke but CANNOT re-arm the watch (watchPaths is not emittable
282
+ // from that event), so a retirement that fired here would delete the live doorbell with
283
+ // nothing to put it back — the session would go silently undeliverable mid-conversation.
284
+ driveHook({
285
+ event: "UserPromptSubmit",
286
+ nativeSessionId: REAL_NATIVE,
287
+ source: "startup",
288
+ transcriptPath: REAL_TRANSCRIPT,
289
+ });
290
+ ok(
291
+ "a keystroke (UserPromptSubmit, which cannot re-arm) retires NOTHING",
292
+ readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
293
+ );
294
+ // …and the same holds when the garden DISAGREES (cross-review, 2026-09-04). The cell above
295
+ // exercises only the same-garden branch, so the retirement's guard could be the garden
296
+ // comparison alone and still look green; this one drives a UPS envelope naming a different
297
+ // session and pins the OTHER half — the event class. UPS cannot emit watchPaths, so a
298
+ // retirement reached from there could only ever subtract a doorbell.
299
+ //
300
+ // This is a mechanism cell, not a threat model: a keystroke from a session the process has
301
+ // already left is not something this host produces (measured: every UPS named its own pid's
302
+ // current session, 8 of 8). The rule it pins is simply that a watch is retired by a run that
303
+ // arms one, and this envelope is the cheapest way to reach the wrong branch on purpose.
304
+ driveHook({
305
+ event: "UserPromptSubmit",
306
+ nativeSessionId: GHOST_NATIVE,
307
+ source: "startup",
308
+ transcriptPath: path.join(CWD, `${GHOST_NATIVE}.jsonl`),
309
+ });
310
+ ok(
311
+ "a MISMATCHED keystroke cannot disarm the live citizen either [QK:MHSS-RETIRE-ARM-CAPABLE-ONLY]",
312
+ readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir })?.ownerPid === process.pid,
313
+ );
314
+ // Put the pointer back where the operator's session left it, so the cells below measure the
315
+ // switch and not this deliberate corruption.
316
+ driveHook({
317
+ event: "UserPromptSubmit",
318
+ nativeSessionId: REAL_NATIVE,
319
+ source: "startup",
320
+ transcriptPath: REAL_TRANSCRIPT,
321
+ });
322
+
323
+ // ── B. the reader is fail-closed even when the stale marker is still there ────
324
+ // Re-plant exactly what the pre-repair hook left behind: a receiver marker for the
325
+ // retired garden, owned by a LIVE pid, identical to the record. Every axis the old
326
+ // predicate looked at says "active"; only the owner's sender marker says otherwise.
327
+ writeMetaReceiverMarker({
328
+ gardenId: GHOST_GID,
329
+ backend: "claude-code",
330
+ nativeSessionId: GHOST_NATIVE,
331
+ ownerPid: process.pid,
332
+ armProvenance: "session-start",
333
+ receiversDir: ROOTS.receiversDir,
334
+ });
335
+ const ghostIdentity = identityFor(GHOST_GID);
336
+ const realIdentity = identityFor(REAL_GID);
337
+ {
338
+ const ghostFacts = resolveMailboxReceiverFacts(ghostIdentity, gateReaders);
339
+ ok("the re-planted stale marker still passes the identity match (ownerAlive)", ghostFacts.ownerAlive);
340
+ ok(
341
+ "…but its watch is NOT armed — measured against the owner's sender marker, never copied [QK:MHSS-SENDER-JOIN-MEASURED]",
342
+ ghostFacts.watchArmed === false,
343
+ );
344
+ const realFacts = resolveMailboxReceiverFacts(realIdentity, gateReaders);
345
+ ok(
346
+ "the served garden is alive AND armed (the join is not a blanket refusal)",
347
+ realFacts.ownerAlive && realFacts.watchArmed,
348
+ );
349
+ }
350
+
351
+ // ── B + C through the PRODUCTION seam, which is where the false success shipped ─
352
+ function deps() {
353
+ return makeProductionEntwurfV2Deps({
354
+ senderProvider: () => ({
355
+ sessionId: "gate-sender",
356
+ agentId: "meta-session/claude-code",
357
+ cwd: CWD,
358
+ timestamp: new Date().toISOString(),
359
+ origin: "meta-session",
360
+ replyable: true,
361
+ }),
362
+ sessionsDir: ROOTS.sessionsDir,
363
+ mailboxDir: ROOTS.mailboxDir,
364
+ lockDir: ROOTS.locksDir,
365
+ controlSocketDir: ROOTS.socketsDir,
366
+ });
367
+ }
368
+
369
+ const ghostDecision = (await deps().decide({
370
+ target: GHOST_GID,
371
+ intent: "fire-and-forget",
372
+ message: "into the void?",
373
+ })) as DispatchDecision;
374
+ ok(
375
+ "production dispatch REFUSES the retired garden — the false deliverable, at the seam [QK:MHSS-SEAM-USES-JOIN]",
376
+ ghostDecision.kind === "reject" && ghostDecision.receipt.reason === "mailbox-undeliverable",
377
+ );
378
+ ok(
379
+ "the reject carries the predicate's reason instead of dropping it at the decider boundary [QK:MHSS-REJECT-CARRIES-REASON]",
380
+ ghostDecision.kind === "reject" &&
381
+ ghostDecision.diagnostic?.kind === "mailbox-undeliverable" &&
382
+ /idle-watch not armed/.test(ghostDecision.diagnostic.reason),
383
+ );
384
+
385
+ const realDecision = (await deps().decide({
386
+ target: REAL_GID,
387
+ intent: "fire-and-forget",
388
+ message: "to the conversation the operator is in",
389
+ })) as DispatchDecision;
390
+ ok(
391
+ "the served garden is still deliverable — exactly ONE of the two, and it is the live one [QK:MHSS-EXACTLY-ONE-DELIVERABLE]",
392
+ realDecision.kind === "execute" && realDecision.plan.transport === "meta-mailbox" && ghostDecision.kind === "reject",
393
+ );
394
+
395
+ // C — the surface has to SAY it. A caller that reads only "mailbox-undeliverable" goes
396
+ // looking for a dead session; this one was alive and had moved.
397
+ {
398
+ const rendered = renderEntwurfV2Result(
399
+ await runEntwurfV2({ target: GHOST_GID, intent: "fire-and-forget", message: "into the void?" }, deps()),
400
+ );
401
+ ok(
402
+ "the rendered reject names the failing receiver axis [QK:MHSS-REASON-RENDERED]",
403
+ rendered.isError && /mailbox-undeliverable: .*idle-watch not armed/.test(rendered.text),
404
+ );
405
+ }
406
+
407
+ // A real delivery to the live garden still lands, in the sandbox mailbox.
408
+ {
409
+ const result = await runEntwurfV2(
410
+ { target: REAL_GID, intent: "fire-and-forget", message: "hello, live citizen" },
411
+ deps(),
412
+ );
413
+ ok(
414
+ "a message to the served garden is enqueued into ITS mailbox",
415
+ result.kind === "executed" &&
416
+ result.transport === "meta-mailbox" &&
417
+ existsSync(path.join(ROOTS.mailboxDir, REAL_GID)),
418
+ );
419
+ }
420
+
421
+ // ── the join's THREE edges, each of which a mutant would otherwise walk through ─
422
+ // (cross-review, 2026-09-04: the cells above never varied ownerKind, never planted a stale
423
+ // sender start-key, and never disagreed on backend, so three real weakenings survived them.)
424
+ {
425
+ // EDGE 1 — the join is scoped by ownerKind, and outside that scope its ABSENCE is not a
426
+ // failure. A Copilot watch lives in a forked first-party extension child with its own pid,
427
+ // so `meta-senders/copilot/<that pid>.json` never exists by construction. If the join were
428
+ // applied there, every Copilot citizen would be permanently undeliverable — a regression on
429
+ // a shipped lane dressed as a fix.
430
+ const copilot = upsertMetaSession({
431
+ input: {
432
+ backend: "copilot",
433
+ nativeSessionId: "copilot-extension-owned",
434
+ transcriptPath: REAL_TRANSCRIPT,
435
+ cwd: CWD,
436
+ },
437
+ dir: ROOTS.sessionsDir,
438
+ });
439
+ writeMetaReceiverMarker({
440
+ gardenId: copilot.record.gardenId,
441
+ backend: "copilot",
442
+ nativeSessionId: copilot.record.nativeSessionId,
443
+ ownerPid: process.pid,
444
+ ownerKind: "copilot-extension",
445
+ armProvenance: "extension-join",
446
+ receiversDir: ROOTS.receiversDir,
447
+ });
448
+ const facts = resolveMailboxReceiverFacts(copilot.record, gateReaders);
449
+ ok(
450
+ "a copilot-extension watch is ARMED with no sender marker of its own — the join does not apply there [QK:MHSS-JOIN-SCOPE-OWNERKIND]",
451
+ facts.ownerAlive && facts.watchArmed,
452
+ );
453
+ }
454
+ {
455
+ // EDGE 2 — the sender marker is trusted only while its owner is the SAME live process. A
456
+ // marker left by a dead session (or a reused pid) must not answer "which garden does this
457
+ // pid serve now?", and the reader's start-key guard is what refuses it. A production reader
458
+ // that asked with `verifyOwner: false` would accept this file, and every fixture pid here is
459
+ // live, so nothing else in this gate could tell the difference.
460
+ const senderFile = path.join(ROOTS.sendersDir, "claude-code", `${process.pid}.json`);
461
+ const good = readFileSync(senderFile, "utf8");
462
+ writeFileSync(senderFile, good.replace(/"ownerStartKey": "[^"]*"/, '"ownerStartKey": "STALE-FROM-A-DEAD-SESSION"'));
463
+ const decision = (await deps().decide({
464
+ target: REAL_GID,
465
+ intent: "fire-and-forget",
466
+ message: "with a stale sender marker",
467
+ })) as DispatchDecision;
468
+ ok(
469
+ "a sender marker whose owner start-key no longer matches is refused, not read [QK:MHSS-SENDER-START-KEY-VERIFIED]",
470
+ decision.kind === "reject" && decision.receipt.reason === "mailbox-undeliverable",
471
+ );
472
+ writeFileSync(senderFile, good);
473
+ }
474
+ {
475
+ // EDGE 3 — the join compares backend as well as garden id. Two backends can key markers under
476
+ // one pid (a Claude CLI that also hosts another harness's writer), and a marker whose body
477
+ // names a different backend than the receiver it is being joined to is not that receiver's
478
+ // evidence. Every fixture above shares one backend, so the comparison was free to disappear.
479
+ const senderFile = path.join(ROOTS.sendersDir, "claude-code", `${process.pid}.json`);
480
+ const good = readFileSync(senderFile, "utf8");
481
+ writeFileSync(senderFile, good.replace(/"backend": "claude-code"/, '"backend": "copilot"'));
482
+ const decision = (await deps().decide({
483
+ target: REAL_GID,
484
+ intent: "fire-and-forget",
485
+ message: "with a backend-drifted sender marker",
486
+ })) as DispatchDecision;
487
+ ok(
488
+ "a sender marker naming another backend does not vouch for this receiver [QK:MHSS-JOIN-BACKEND-EQUALITY]",
489
+ decision.kind === "reject" && decision.receipt.reason === "mailbox-undeliverable",
490
+ );
491
+ writeFileSync(senderFile, good);
492
+ }
493
+
494
+ // ── D. the listing has to SHOW the difference (#101 갭 D) ────────────────────
495
+ // This is the surface a caller actually reads before dispatching. Both citizens are
496
+ // `liveness=unsupported` (claude-code has no control socket to probe), same cwd, same
497
+ // backend — identical rows, which is how a sibling picked the phantom. The two observed
498
+ // axes are what separate them, and they are measured through the REAL observer the
499
+ // provider defaults to, against this sandbox.
500
+ {
501
+ const listing = await listEntwurfFacts({
502
+ metaEntries: readActiveStoreEntries(ROOTS.sessionsDir),
503
+ readRecord: makeStoreRecordReader(ROOTS.sessionsDir),
504
+ socket: { dir: ROOTS.socketsDir },
505
+ });
506
+ const ghostRow = listing.facts.peers.find((p) => p.gardenId === GHOST_GID);
507
+ const realRow = listing.facts.peers.find((p) => p.gardenId === REAL_GID);
508
+ ok(
509
+ "both citizens are listed, and both read liveness=unsupported (the axis that cannot tell them apart)",
510
+ ghostRow?.liveness === "unsupported" && realRow?.liveness === "unsupported",
511
+ );
512
+ ok(
513
+ "the abandoned row shows a transcript that was never written [QK:MHSS-PEERS-TRANSCRIPT-OBSERVED]",
514
+ ghostRow?.transcript === "absent" && realRow?.transcript === "exists",
515
+ );
516
+ // The stale marker re-planted above is still on disk, so the ghost reads `inactive`
517
+ // (a marker that fails the join) rather than `none` (no marker at all) — the split a
518
+ // reader needs to tell "was armed, no longer valid" from "never armed".
519
+ ok(
520
+ "the abandoned row shows an inactive receiver beside the live one [QK:MHSS-PEERS-RECEIVER-OBSERVED]",
521
+ ghostRow?.receiver === "inactive" && realRow?.receiver === "active",
522
+ );
523
+ const { text } = renderEntwurfPeers(listing);
524
+ ok(
525
+ "the rendered row carries both observed columns [QK:MHSS-PEERS-COLUMNS-RENDERED]",
526
+ new RegExp(`${GHOST_GID}.*receiver=inactive.*transcript=absent`).test(text) &&
527
+ new RegExp(`${REAL_GID}.*receiver=active.*transcript=exists`).test(text),
528
+ );
529
+ ok("the listing still routes no verbs (facts only)", !/sendable|resumable|dispatch|mailboxDeliverable/.test(text));
530
+ }
531
+
532
+ // ── a retirement must never reach a watch ANOTHER live process holds ─────────
533
+ // The switch rule is "this pid stopped serving that garden", so the marker it removes
534
+ // has to be this pid's own. Re-plant the served garden's marker under a different LIVE
535
+ // pid (this gate's parent) and switch again: the hook still wants to retire that garden,
536
+ // and must not, because the doorbell now belongs to someone else. Getting this wrong is
537
+ // not a stale marker — it is one session silently disarming another's inbox.
538
+ {
539
+ writeMetaReceiverMarker({
540
+ gardenId: REAL_GID,
541
+ backend: "claude-code",
542
+ nativeSessionId: REAL_NATIVE,
543
+ ownerPid: process.ppid,
544
+ armProvenance: "session-start",
545
+ receiversDir: ROOTS.receiversDir,
546
+ });
547
+ driveHook({
548
+ event: "SessionStart",
549
+ nativeSessionId: "cafe0000-another-conversation",
550
+ source: "clear",
551
+ transcriptPath: path.join(CWD, "cafe0000.jsonl"),
552
+ });
553
+ ok(
554
+ "a retirement leaves a marker owned by another live pid alone [QK:MHSS-RETIRE-ONLY-OWN-OWNER]",
555
+ readMetaReceiverMarker({ gardenId: REAL_GID, receiversDir: ROOTS.receiversDir, verifyOwner: false })?.ownerPid ===
556
+ process.ppid,
557
+ );
558
+ }
559
+
560
+ console.log(`\ncheck-meta-hook-session-switch: ${passed} assertions passed`);
@@ -10,7 +10,11 @@
10
10
  * (enqueue/read no longer touch the record — invariant ⑤);
11
11
  * - enqueue stamps state.lastEnqueuedAt, read stamps state.lastReadAt, and the
12
12
  * enqueue receipt survives the read (field isolation on the state store);
13
- * - lastDeliveredAt is never invented by read (doorbell owns delivery-time);
13
+ * - lastDeliveredAt is never invented by read. #98 5a corrects why: NOBODY stamps it —
14
+ * the shipped doorbell.sh writes nothing to state.json — so it is a reserved slot that
15
+ * is null on every citizen. The per-message receipts are the file SUFFIXES
16
+ * (`.msg` → `.msg.delivered` → `.msg.delivered.read`); state.json carries only the
17
+ * garden-wide last enqueue/read. Removing the field is a migration, not this gate (#98 5b);
14
18
  * - an empty inbox is a no-op on BOTH the record (untouched) and the state
15
19
  * (state.json never created) — invariant ⑥;
16
20
  * - a state-store drift makes the read throw fail-loud (partial failure surfaces).
@@ -95,7 +99,10 @@ try {
95
99
  const stateAfterRead = readMailboxReceiptState({ gardenId: gidA, mailboxDir });
96
100
  ok("read: state.lastReadAt stamped", stateAfterRead.lastReadAt === RD_ISO);
97
101
  ok("read: enqueue receipt SURVIVES the read (field isolation)", stateAfterRead.lastEnqueuedAt === ENQ_ISO);
98
- ok("read: lastDeliveredAt never invented by read (doorbell owns it)", stateAfterRead.lastDeliveredAt === null);
102
+ ok(
103
+ "read: lastDeliveredAt never invented by read (reserved slot, no writer — see header)",
104
+ stateAfterRead.lastDeliveredAt === null,
105
+ );
99
106
 
100
107
  // --- empty inbox: no-op on BOTH record and state --------------------------
101
108
  const gidB = seed("n-state-b");
@@ -114,7 +114,25 @@ if hooks is not None:
114
114
  # argv. The marketplace/plugin manifests below stay minimal: the closed-
115
115
  # schema lesson that produced this gate was about a DECORATIVE key
116
116
  # (`description`), and that lesson is untouched.
117
- subset(f"hooks.{event}[{j}].hooks[{k}]", h, {"type", "command", "args", "asyncRewake", "timeout"})
117
+ #
118
+ # rewakeSummary/rewakeMessage (#98, 2026-09-03) join the same category:
119
+ # load-bearing, not decorative. They are the ONLY way the doorbell names
120
+ # itself — without `rewakeSummary` the operator's row is the engine default
121
+ # `Stop hook feedback` (no sender, no count, no garden id), and without
122
+ # `rewakeMessage` the model wakes to `Stop hook blocking error from command
123
+ # "FileChanged":`, i.e. an arriving letter announced as an error. Both were
124
+ # measured to load from a local plugin's hooks.json with no first-party gate
125
+ # on Claude 2.1.236/2.1.258/2.1.259, and `claude plugin validate` accepts
126
+ # them (install-meta-bridge runs it). On a host old enough not to know them,
127
+ # the #51 measurement above applies unchanged: the key is accepted and the
128
+ # value silently dropped, so the surface degrades to today's default rather
129
+ # than failing the install. Their VALUES are linted (single line, non-blank,
130
+ # length) by check-hook-launch-topology; this gate pins only the keyset.
131
+ subset(
132
+ f"hooks.{event}[{j}].hooks[{k}]",
133
+ h,
134
+ {"type", "command", "args", "asyncRewake", "timeout", "rewakeSummary", "rewakeMessage"},
135
+ )
118
136
 
119
137
  # Every hook launches through the shipped launcher in EXEC form: `command` is
120
138
  # hook-launch.sh and the baked argv travels in `args`. No shell is on the path, so
@@ -455,8 +455,16 @@ check("readMetaInbox: drains a fresh .msg, returns the body, stamps lastReadAt (
455
455
  assert.equal(read.readAt, T1.toISOString(), "readAt returned");
456
456
  const st = readMailboxReceiptState({ gardenId: fx.gardenId, mailboxDir: fx.mailboxDir });
457
457
  assert.equal(st.lastReadAt, T1.toISOString(), "lastReadAt stamped in mailbox state = the honest read receipt");
458
- // #5 honesty: lastDeliveredAt is the doorbell's to stamp; readMetaInbox must NOT invent it.
459
- assert.equal(st.lastDeliveredAt, null, "lastDeliveredAt left null (read does not record a delivery time)");
458
+ // #5 honesty: readMetaInbox must NOT invent a delivery time. #98 5a corrects the old
459
+ // reason next to this pin ("the doorbell's to stamp"): the shipped doorbell.sh writes
460
+ // nothing to state.json, so lastDeliveredAt is a RESERVED SLOT no writer fills — this
461
+ // null is permanent, not "not yet". The per-message delivery fact is the `.delivered`
462
+ // file suffix; state.json only ever holds garden-wide last-activity.
463
+ assert.equal(
464
+ st.lastDeliveredAt,
465
+ null,
466
+ "lastDeliveredAt left null (nobody stamps it; the .delivered suffix is the per-message receipt)",
467
+ );
460
468
  } finally {
461
469
  fx.cleanup();
462
470
  }