@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
@@ -42,6 +42,7 @@ import {
42
42
  type DispatchDecision,
43
43
  decideDispatch,
44
44
  type ExecutionPlan,
45
+ type RejectDiagnostic,
45
46
  type TargetResolution,
46
47
  } from "../pi-extensions/lib/entwurf-v2-decider.ts";
47
48
  import type { AcquireLockResult, LockClaim } from "../pi-extensions/lib/entwurf-v2-lock.ts";
@@ -156,7 +157,10 @@ type LockClass = "none" | "held" | "mailbox-null" | "released" | "acquire-fail";
156
157
 
157
158
  type Expect =
158
159
  | { decision: "execute"; transport: EntwurfV2Transport; lock: LockClass }
159
- | { decision: "reject"; reason: string; lock: LockClass; diagnostic?: boolean };
160
+ // `diagnostic` names the KIND a reject must carry, not a boolean: two reject cells now
161
+ // carry machine-readable evidence, and the axis has to say which — a reject that carried
162
+ // the wrong diagnostic would pass a yes/no cell (#101 갭 C).
163
+ | { decision: "reject"; reason: string; lock: LockClass; diagnostic?: RejectDiagnostic["kind"] };
160
164
 
161
165
  /**
162
166
  * Which IO seams the decider is ALLOWED to touch — the "어느 축을 만지면 안 되는지"
@@ -211,7 +215,7 @@ const ROWS: Row[] = [
211
215
  targetKind: "pi, lock held by another",
212
216
  intent: "fire-and-forget",
213
217
  scenario: { resolution: { identity: identity("pi"), preProbeAddressConflict: false }, lock: "conflict" },
214
- expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic: true },
218
+ expect: { decision: "reject", reason: "target-locked", lock: "acquire-fail", diagnostic: "target-locked" },
215
219
  },
216
220
  {
217
221
  name: "unsupported self-fetch active → meta-mailbox",
@@ -231,7 +235,8 @@ const ROWS: Row[] = [
231
235
  resolution: { identity: identity("claude-code"), preProbeAddressConflict: false },
232
236
  mailboxDeliverable: false,
233
237
  },
234
- expect: { decision: "reject", reason: "mailbox-undeliverable", lock: "none" },
238
+ // #101 갭 C: an undeliverable mailbox reject carries the receiver axis that failed.
239
+ expect: { decision: "reject", reason: "mailbox-undeliverable", lock: "none", diagnostic: "mailbox-undeliverable" },
235
240
  },
236
241
  {
237
242
  name: "in-domain live ff → control-socket",
@@ -338,8 +343,8 @@ async function runRow(row: Row): Promise<void> {
338
343
  }
339
344
  if (row.expect.diagnostic) {
340
345
  ok(
341
- `${row.name}: carries target-locked diagnostic`,
342
- d.kind === "reject" && d.diagnostic?.kind === "target-locked",
346
+ `${row.name}: carries the ${row.expect.diagnostic} diagnostic`,
347
+ d.kind === "reject" && d.diagnostic?.kind === row.expect.diagnostic,
343
348
  );
344
349
  } else {
345
350
  ok(`${row.name}: no diagnostic`, d.kind === "reject" && d.diagnostic === undefined);
@@ -13,6 +13,9 @@
13
13
  * E. Q3 + Q5 — a dead control send re-resolves (claude-code citizen) to the mailbox and
14
14
  * enqueues through the SAME sendViaMailbox instance (same enqueue spy) on the SAME dirs
15
15
  * the direct hand used — direct send and fallback send never drift.
16
+ * E4. #101 — the deliverability seam reads BOTH markers: a live receiver marker whose
17
+ * owner's sender marker names ANOTHER garden is undeliverable. The SAME closure serves
18
+ * the direct send and the dead-control fallback, so neither can be fooled alone.
16
19
  *
17
20
  * No real IO — every seam is a spy; the factory's COMPOSITION is what is under test.
18
21
  */
@@ -26,7 +29,7 @@ import {
26
29
  } from "../pi-extensions/lib/entwurf-v2-production.ts";
27
30
  import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
28
31
  import type { ControlSocketPlan, MetaMailboxPlan } from "../pi-extensions/lib/entwurf-v2-send.ts";
29
- import type { MetaIdentity, MetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
32
+ import type { MetaIdentity, MetaReceiverMarker, MetaSenderMarker } from "../pi-extensions/lib/meta-session.ts";
30
33
  import type { NativePushAdapter, NativePushProbeResult } from "../pi-extensions/lib/native-push/adapter.ts";
31
34
  import type { TargetSocketInspection } from "../pi-extensions/lib/socket-discovery.ts";
32
35
 
@@ -38,6 +41,8 @@ function ok(label: string, cond: boolean): void {
38
41
  }
39
42
 
40
43
  const GID = "20260613T100000-aaaaaa";
44
+ /** The garden a switched-away owner serves instead (#101). */
45
+ const OTHER_GID = "20260613T100000-bbbbbb";
41
46
  const LOCK_DIR = "/fake/locks";
42
47
  const SESSIONS_DIR = "/fake/sessions";
43
48
  const MAILBOX_DIR = "/fake/mailbox";
@@ -74,6 +79,19 @@ function receiverMarker(gid: string, backend: string, nativeSessionId = "n"): Me
74
79
  };
75
80
  }
76
81
 
82
+ /** The receiver owner's sender marker — "which garden does this pid serve NOW?" (#101). */
83
+ function senderMarker(gardenId: string, backend: string, ownerPid: number): MetaSenderMarker {
84
+ return {
85
+ gardenId,
86
+ backend: backend as MetaSenderMarker["backend"],
87
+ nativeSessionId: "n",
88
+ cwd: "/cwd",
89
+ ownerPid,
90
+ ownerStartKey: "x",
91
+ updatedAt: "t",
92
+ };
93
+ }
94
+
77
95
  function lockClaim(gardenId = GID): LockClaim {
78
96
  return {
79
97
  gardenId,
@@ -133,6 +151,11 @@ function makeSpiedFactory(over: {
133
151
  /** SE-2 2d-3 — the target's receiver presence marker: "active" (matches identity,
134
152
  * default), "absent" (terminated/never-armed), or "mismatch" (drifted native id). */
135
153
  receiverMarker?: "active" | "absent" | "mismatch";
154
+ /** #101 — the receiver owner's SENDER marker, i.e. which garden that pid serves NOW:
155
+ * "same" (still this one, default), "other-garden" (the owner switched sessions in
156
+ * place — a live marker whose watch is retired), or "absent" (no sender marker at all).
157
+ * Injected rather than defaulted so this gate never stats the operator's real roots. */
158
+ senderMarker?: "same" | "other-garden" | "absent";
136
159
  }) {
137
160
  const spies: Spies = {
138
161
  acquire: [],
@@ -161,6 +184,10 @@ function makeSpiedFactory(over: {
161
184
  const nsid = over.receiverMarker === "mismatch" ? "DRIFT" : "n";
162
185
  return receiverMarker(gid, over.backend ?? "pi", nsid);
163
186
  },
187
+ readSenderMarker: (backend, ownerPid) => {
188
+ if (over.senderMarker === "absent") return null;
189
+ return senderMarker(over.senderMarker === "other-garden" ? OTHER_GID : GID, backend, ownerPid);
190
+ },
164
191
  inspectPath: async (socketPath) => {
165
192
  spies.inspectPath.push({ socketPath });
166
193
  if (over.inspectKind === "indeterminate") {
@@ -388,6 +415,50 @@ async function main(): Promise<void> {
388
415
  ok("E3: drifted marker → enqueue NEVER called (presence ≠ identity match)", spies.enqueue.length === 0);
389
416
  }
390
417
 
418
+ // ── E4: #101 — a LIVE receiver marker whose owner switched gardens. Every axis the
419
+ // pre-#101 seam looked at says "active": the marker exists, its owner is live, and it
420
+ // matches this identity exactly. Only the owner's sender marker — which garden that pid
421
+ // serves NOW — says the watch is retired. This is the cell where a real message was
422
+ // enqueued into a mailbox nobody was draining (oracle, 2026-09-04). ─────────────────
423
+ {
424
+ const { deps, spies } = makeSpiedFactory({
425
+ backend: "claude-code",
426
+ rpc: "dead-throw",
427
+ classifyDead: true,
428
+ receiverMarker: "active",
429
+ senderMarker: "other-garden",
430
+ });
431
+ const res = await deps.executor.sendControl(CONTROL_PLAN, lockClaim());
432
+ ok(
433
+ "E4: owner switched gardens → rejected (mailbox-undeliverable), never fallback-sent",
434
+ res.outcome === "rejected" && res.rejectReason === "mailbox-undeliverable",
435
+ );
436
+ ok("E4: nothing enqueued into the retired garden's mailbox", spies.enqueue.length === 0);
437
+ // …and the same wiring still DELIVERS when that owner is serving this garden, so the
438
+ // join is a measurement and not a blanket refusal.
439
+ const served = makeSpiedFactory({
440
+ backend: "claude-code",
441
+ rpc: "dead-throw",
442
+ classifyDead: true,
443
+ receiverMarker: "active",
444
+ senderMarker: "same",
445
+ });
446
+ const ok2 = await served.deps.executor.sendControl(CONTROL_PLAN, lockClaim());
447
+ ok("E4: the served garden still falls back to a real mailbox enqueue", ok2.outcome === "fallback-sent");
448
+ ok(
449
+ "E4: a MISSING sender marker is fail-closed, not optimistic",
450
+ (
451
+ await makeSpiedFactory({
452
+ backend: "claude-code",
453
+ rpc: "dead-throw",
454
+ classifyDead: true,
455
+ receiverMarker: "active",
456
+ senderMarker: "absent",
457
+ }).deps.executor.sendControl(CONTROL_PLAN, lockClaim())
458
+ ).outcome === "rejected",
459
+ );
460
+ }
461
+
391
462
  // ── F: #50 C4 — record-LESS control socket → pre-probe record-less-socket reject ──
392
463
  // resolveTarget finds no meta-record, does ONE record-side lstat (inspectPath), sees a
393
464
  // non-symlink socket → recordLessSocket. EVERY intent then rejects pre-probe as
@@ -212,6 +212,24 @@ async function main(): Promise<void> {
212
212
  "4: executed{meta-mailbox, success}",
213
213
  res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.success === true,
214
214
  );
215
+ // #98 R: a dep that reports no path leaves the outcome's receipt undefined. The
216
+ // runner must not invent one — a guessed path is worse than no path.
217
+ ok(
218
+ "4: no enqueue receipt from the dep → messagePath stays undefined (never invented)",
219
+ res.kind === "executed" && res.outcome.transport === "meta-mailbox" && res.outcome.messagePath === undefined,
220
+ );
221
+ }
222
+
223
+ // ── 4a: #98 R — the mailbox enqueue receipt is carried through verbatim ───
224
+ {
225
+ const { deps } = makeDeps({ mailbox: { result: { success: true, messagePath: "/fake/mailbox/g/2026.msg" } } });
226
+ const res = await executeDispatch(executeDecision(MAILBOX_PLAN, null), deps);
227
+ ok(
228
+ "4a: executed{meta-mailbox} carries the dep's messagePath verbatim",
229
+ res.kind === "executed" &&
230
+ res.outcome.transport === "meta-mailbox" &&
231
+ res.outcome.messagePath === "/fake/mailbox/g/2026.msg",
232
+ );
215
233
  }
216
234
 
217
235
  // ── 4b: native-push execute → sendNativePush(plan, null), lock-free (봉인 4) ──
@@ -213,6 +213,9 @@ async function main(): Promise<void> {
213
213
  fallbackSend: { result: { success: true } },
214
214
  });
215
215
  ok("dead → re-resolve(control) success → fallback-sent", result.outcome === "fallback-sent");
216
+ // A socket retry hands the body to a live receiver and writes no file: no receipt to
217
+ // invent (#98 R is a per-FILE identifier, not a per-delivery one).
218
+ ok("dead → socket retry carries NO messagePath (no file exists)", result.messagePath === undefined);
216
219
  ok("dead → deadFallback called exactly once", trace.deadFallbackCalls === 1);
217
220
  ok("dead → re-resolve used the alt socket", trace.socketSends[1]?.socketPath === "/fake/ctl/alt.sock");
218
221
  ok("dead → release ×1", trace.releases.length === 1);
@@ -277,9 +280,16 @@ async function main(): Promise<void> {
277
280
  const sent = await run({
278
281
  firstSend: { throwCode: "ECONNREFUSED" },
279
282
  deadFallback: { kind: "execute", plan: MAILBOX_PLAN },
280
- fallbackSend: { result: { success: true } },
283
+ fallbackSend: { result: { success: true, messagePath: "/fake/mb/gid/2026-fallback.msg" } },
281
284
  });
282
285
  ok("dead → re-resolve(mailbox) enqueue → fallback-sent", sent.result.outcome === "fallback-sent");
286
+ // #98 R, fallback leg: this enqueue wrote a file, so the sender gets its name — the
287
+ // same per-message receipt the primary mailbox rail hands back. Dropping it here was
288
+ // the one mailbox delivery with no identifier.
289
+ ok(
290
+ "dead → mailbox fallback carries the #98 R messagePath",
291
+ sent.result.messagePath === "/fake/mb/gid/2026-fallback.msg",
292
+ );
283
293
  ok("dead → mailbox helper called once", sent.trace.mailboxSends.length === 1);
284
294
  ok("dead → mailbox reached only via resolver, release ×1", sent.trace.releases.length === 1);
285
295
 
@@ -289,6 +299,8 @@ async function main(): Promise<void> {
289
299
  fallbackSend: { result: { success: false } },
290
300
  });
291
301
  ok("dead → mailbox enqueue success:false → rejected", refused.result.outcome === "rejected");
302
+ // No file was written, so there is nothing to name — never echo a dep's stray path.
303
+ ok("dead → rejected enqueue carries NO messagePath", refused.result.messagePath === undefined);
292
304
  }
293
305
 
294
306
  // ── 9: single-release across every outcome (release at most once) ─────────
@@ -361,20 +361,92 @@ async function main(): Promise<void> {
361
361
  cr.isError && cr.text.includes("dormant-fire-forget-unsupported"),
362
362
  );
363
363
 
364
+ // control-socket fallback-sent that re-resolved to the MAILBOX: same file, same
365
+ // receipt. Before this the fallback leg was the one mailbox delivery whose sender
366
+ // line carried no per-message identifier.
367
+ const fallback: EntwurfV2RunResult = {
368
+ kind: "executed",
369
+ receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
370
+ transport: "control-socket",
371
+ outcome: {
372
+ transport: "control-socket",
373
+ outcome: "fallback-sent",
374
+ messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T09-58-02-114Z-ab12cd.msg",
375
+ },
376
+ };
377
+ const fb = renderEntwurfV2Result(fallback);
378
+ ok("2: fallback-sent is a delivery (not an error)", !fb.isError && fb.text.includes("fallback-sent"));
379
+ ok(
380
+ "2: fallback-sent names the enqueued FILE (#98 R, fallback leg)",
381
+ fb.text.includes("2026-09-03T09-58-02-114Z-ab12cd.msg"),
382
+ );
383
+ ok(
384
+ "2: fallback-sent prints the basename, not the whole path",
385
+ !fb.text.includes("/home/x/") && !fb.text.includes("meta-mailbox/20260903T134455-e55e87"),
386
+ );
387
+ ok(
388
+ "2: fallback-sent carries NO read stamp (same ban as the primary rail)",
389
+ !/lastReadAt|lastDeliveredAt/i.test(fb.text),
390
+ );
391
+ // A socket-to-socket retry writes no file. No receipt, and no "(enqueued undefined)".
392
+ const fallbackSocket: EntwurfV2RunResult = {
393
+ kind: "executed",
394
+ receipt: { ...SUCCESS_RECEIPT, transport: "control-socket" },
395
+ transport: "control-socket",
396
+ outcome: { transport: "control-socket", outcome: "fallback-sent" },
397
+ };
398
+ const fbs = renderEntwurfV2Result(fallbackSocket);
399
+ ok(
400
+ "2: socket-retry fallback-sent degrades to the bare outcome (no file to name)",
401
+ fbs.text === "entwurf_v2 control-socket → fallback-sent" && !fbs.isError,
402
+ );
403
+
364
404
  // The two spawn-bg render cells (lock-retained / socket-alive) were deleted with the
365
405
  // transport itself. Nothing renders them anymore, and `EntwurfV2Transport` no longer
366
406
  // admits the literal — a cell kept here would only prove the type is still wrong.
367
407
 
368
- // meta-mailbox → enqueued
408
+ // meta-mailbox → enqueued, carrying the #98 R send receipt (the enqueued FILE).
369
409
  const mailbox: EntwurfV2RunResult = {
410
+ kind: "executed",
411
+ receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
412
+ transport: "meta-mailbox",
413
+ outcome: {
414
+ transport: "meta-mailbox",
415
+ success: true,
416
+ messagePath: "/home/x/.pi/agent/meta-mailbox/20260903T134455-e55e87/2026-09-03T07-19-31-803Z-113443.msg",
417
+ },
418
+ };
419
+ const mb = renderEntwurfV2Result(mailbox);
420
+ ok("2: meta-mailbox → not error + enqueued", !mb.isError && mb.text.includes("enqueued"));
421
+ ok(
422
+ "2: meta-mailbox names the enqueued message FILE (#98 R send receipt)",
423
+ mb.text.includes("2026-09-03T07-19-31-803Z-113443.msg"),
424
+ );
425
+ // The basename alone — printing the whole path would put the operator's home and the
426
+ // target garden id (which the caller just typed) into every send line.
427
+ ok(
428
+ "2: meta-mailbox prints the basename, not the whole path",
429
+ !mb.text.includes("/home/x/") && !mb.text.includes("meta-mailbox/20260903T134455-e55e87"),
430
+ );
431
+ // The assertion #98 asks for BY NAME: an enqueue receipt must never carry a read
432
+ // stamp. At enqueue time `lastReadAt` is the PREVIOUS message's read, so surfacing it
433
+ // would read as "my message was read" — the misreading that opened the issue.
434
+ ok(
435
+ "2: meta-mailbox carries NO read stamp (lastReadAt is forbidden on a send receipt)",
436
+ !/lastReadAt|lastDeliveredAt|\bread\b/i.test(mb.text),
437
+ );
438
+ // A dep that hands back no receipt must degrade to the old literal — never
439
+ // "enqueued (undefined)". The delivery happened either way.
440
+ const mailboxNoReceipt: EntwurfV2RunResult = {
370
441
  kind: "executed",
371
442
  receipt: { ...SUCCESS_RECEIPT, transport: "meta-mailbox" },
372
443
  transport: "meta-mailbox",
373
444
  outcome: { transport: "meta-mailbox", success: true },
374
445
  };
446
+ const mbn = renderEntwurfV2Result(mailboxNoReceipt);
375
447
  ok(
376
- "2: meta-mailbox not error + enqueued",
377
- !renderEntwurfV2Result(mailbox).isError && renderEntwurfV2Result(mailbox).text.includes("enqueued"),
448
+ "2: meta-mailbox with no receipt degrades to the bare literal (never 'undefined')",
449
+ !mbn.isError && mbn.text === "entwurf_v2 meta-mailbox → enqueued",
378
450
  );
379
451
 
380
452
  // native-push → delivered (no retry)
@@ -814,6 +814,7 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
814
814
  "copilot-receive": 18,
815
815
  "fresh-cut": 1,
816
816
  "meta-facts": 4,
817
+ "meta-hook-session-switch": 17,
817
818
  "meta-identity": 4,
818
819
  "meta-retire": 3,
819
820
  "mux-boundary": 14,
@@ -58,7 +58,20 @@ const PLUGIN_SRC = path.join(REPO_DIR, "pi", "meta-bridge", "entwurf-meta-receiv
58
58
  const LAUNCHER = path.join(PLUGIN_SRC, "scripts", "hook-launch.sh");
59
59
  const PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
60
60
 
61
- type Leaf = { type?: string; command?: string; args?: string[]; asyncRewake?: boolean; timeout?: number };
61
+ type Leaf = {
62
+ type?: string;
63
+ command?: string;
64
+ args?: string[];
65
+ asyncRewake?: boolean;
66
+ timeout?: number;
67
+ rewakeSummary?: string;
68
+ rewakeMessage?: string;
69
+ };
70
+
71
+ /** #98 A/A″ length ceiling for the two rewake strings. The operator row is ONE line in a
72
+ * terminal status area; the model prefix is glued in front of the doorbell's stderr. There
73
+ * is no engine cap on the config path (see the lint block below), so this is ours. */
74
+ const REWAKE_MAX_LEN = 80;
62
75
  const manifest = JSON.parse(readFileSync(path.join(PLUGIN_SRC, "hooks", "hooks.json"), "utf8")) as {
63
76
  hooks: Record<string, Array<{ matcher?: string; hooks: Leaf[] }>>;
64
77
  };
@@ -99,6 +112,62 @@ for (const event of OWNER_EVENTS) {
99
112
  ok("FileChanged: timeout stays declared", typeof bell.timeout === "number" && bell.timeout > 0);
100
113
  }
101
114
 
115
+ // ── 1b. #98 A/A″: the two rewake strings, and the lint the engine does NOT do ─
116
+ // WHY A LINT AT ALL. Measured on Claude 2.1.236/2.1.258/2.1.259 (#98 Phase 1): the
117
+ // stdout-JSON form of `rewakeSummary` is sanitised by the engine
118
+ // (`.trim().replace(/\s+/g," ").slice(0,cap)`), but the hooks.json CONFIG form is not —
119
+ // it reaches the operator's row verbatim, guarded only by the schema's `min(1)`. Two
120
+ // concrete failures that guard cannot see:
121
+ // - a value containing a newline breaks the one-row status area;
122
+ // - a whitespace-only value passes `min(1)` and draws a BLANK row, which is worse than
123
+ // the default `Stop hook feedback` because it looks like nothing arrived.
124
+ // (A third, `<summary>` missing entirely → the whole notification is hidden, is covered
125
+ // by asserting the field is present and non-empty at all.)
126
+ {
127
+ const bell = leafOf("FileChanged");
128
+ // A″ — the model-facing prefix. It REPLACES `Stop hook blocking error from command "…":`,
129
+ // so without it every arriving letter is named an error in the transcript.
130
+ ok(
131
+ "FileChanged: rewakeMessage is declared (model wakes to mail, not to a 'blocking error')",
132
+ typeof bell.rewakeMessage === "string",
133
+ );
134
+ // A — the operator-facing row. Static by nature: the renderer draws this ONE string, so
135
+ // it cannot carry the unread count (the statusline badge does that half).
136
+ ok(
137
+ "FileChanged: rewakeSummary is declared (operator row is not 'Stop hook feedback')",
138
+ typeof bell.rewakeSummary === "string",
139
+ );
140
+ for (const field of ["rewakeSummary", "rewakeMessage"] as const) {
141
+ const value = bell[field];
142
+ ok(
143
+ `FileChanged: ${field} is a single line (a newline would break the one-row status area)`,
144
+ typeof value === "string" && !/[\r\n]/.test(value),
145
+ );
146
+ ok(
147
+ `FileChanged: ${field} is not whitespace-only (min(1) passes a blank row; we must not)`,
148
+ typeof value === "string" && value.trim().length > 0,
149
+ );
150
+ ok(
151
+ `FileChanged: ${field} is <= ${REWAKE_MAX_LEN} chars (unsanitised config path, no engine cap)`,
152
+ typeof value === "string" && value.length <= REWAKE_MAX_LEN,
153
+ );
154
+ }
155
+ // Prefix hygiene (#98 (b)): the model body is `rewakeMessage + " " + stderr`, and the
156
+ // doorbell's stderr ALREADY opens with `[entwurf inbox]`. A bracketed prefix here would
157
+ // render `[entwurf …] [entwurf inbox] …` — one tag per line, and the stderr owns it.
158
+ ok(
159
+ "FileChanged: rewakeMessage carries no bracket tag (doorbell stderr already opens with [entwurf inbox])",
160
+ typeof bell.rewakeMessage === "string" && !bell.rewakeMessage.includes("["),
161
+ );
162
+ // The doorbell's own opening tag, read from the shipped script rather than assumed —
163
+ // if someone changes the stderr tag, the no-duplicate-prefix rule above must be re-read.
164
+ const doorbellSrc = readFileSync(path.join(PLUGIN_SRC, "scripts", "doorbell.sh"), "utf8");
165
+ ok(
166
+ "doorbell stderr still opens with the [entwurf inbox] tag the prefix rule assumes",
167
+ doorbellSrc.includes('echo "[entwurf inbox]'),
168
+ );
169
+ }
170
+
102
171
  // ── 2. no shell-form regression anywhere in the shipped manifest ─────────────
103
172
  // The retired form is recognizable by its carrier; assert the whole file is free of
104
173
  // it rather than only the leaves we happen to read above.
@@ -120,6 +120,12 @@ try {
120
120
  now: new Date("2026-03-01T12:05:00.000Z"),
121
121
  });
122
122
  ok("stamp lastReadAt: state.json created on first stamp", fs.existsSync(mailboxReceiptStatePath(mailboxDir, GID)));
123
+ // The two `lastDeliveredAt === null` pins below are FIELD-ISOLATION pins, not a
124
+ // "waiting for the doorbell" state. #98 5a: no writer stamps lastDeliveredAt at all —
125
+ // the shipped doorbell.sh does not touch state.json — so this null is the field's
126
+ // permanent value. state.json holds garden-wide last-activity; the per-message receipts
127
+ // are the file suffixes (`.msg` → `.msg.delivered` → `.msg.delivered.read`). Removing
128
+ // the field is a migration over existing on-disk v1 states (#98 5b), not a comment fix.
123
129
  ok(
124
130
  "stamp lastReadAt: only lastReadAt set, others null (읽음이 남는다)",
125
131
  afterRead.lastReadAt === "2026-03-01T12:05:00.000Z" &&
@@ -631,6 +631,79 @@ else
631
631
  fi
632
632
  unset FAKE_MCP_TAIL
633
633
 
634
+ # M14 — a MANAGED SETTINGS SCALAR drifted. Added with #94, which retired the two
635
+ # compaction keys and so deleted two of this doctor's drift cells. Until now the
636
+ # mutation set planted only hook/cache/receiver/MCP/delivery defects: not one cell
637
+ # asked whether the managed-scalar sweep still detects anything at all. That made
638
+ # "retiring these two costs no detection power" an unprovable claim — the gate
639
+ # could not have noticed if the whole sweep had gone silent. `verbose` stands in
640
+ # for the ten scalars that remain owned; it is the cheapest of them and carries no
641
+ # policy weight of its own.
642
+ python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
643
+ import json, sys
644
+ p = sys.argv[1]
645
+ d = json.load(open(p, encoding="utf-8"))
646
+ d["verbose"] = True
647
+ json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
648
+ PY
649
+ expect_red "a managed settings scalar (verbose) drifted" "settings verbose missing/drifted at verbose"
650
+ python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
651
+ import json, sys
652
+ p = sys.argv[1]
653
+ d = json.load(open(p, encoding="utf-8"))
654
+ d["verbose"] = False
655
+ json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
656
+ PY
657
+
658
+ # M15 (negative) — the same sweep must NOT claim a RETIRED key. Planted here is the
659
+ # EXACT state oracle sat in on 2026-09-02, the one that opened #94: Claude Code had
660
+ # put `autoCompactEnabled` back to true while entwurf's `env.DISABLE_AUTOCOMPACT`
661
+ # stayed "1". Under the old code that drift is what turned the doctor red; under #94
662
+ # both keys are the operator's and it must be green.
663
+ #
664
+ # Two things this cell will not do. It does not plant entwurf's own former desired
665
+ # value (`false`), which is green under BOTH codes and so could not tell "retired"
666
+ # apart from "still checked, message reworded". And it does not REMOVE the env key
667
+ # to make the state tidier: leaving "1" in place is the stronger claim, because a
668
+ # retired key must be ignored while still carrying the value we used to force, not
669
+ # merely once the operator has finished cleaning up after us.
670
+ cp "$CLAUDE_CONFIG_DIR/settings.json" "$TMP/settings-m15.bak"
671
+ python3 - "$CLAUDE_CONFIG_DIR/settings.json" <<'PY'
672
+ import json, sys
673
+ p = sys.argv[1]
674
+ d = json.load(open(p, encoding="utf-8"))
675
+ d["autoCompactEnabled"] = True
676
+ d.setdefault("env", {})["DISABLE_AUTOCOMPACT"] = "1"
677
+ json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
678
+ PY
679
+ run_doctor
680
+ if [ "$DOC_RC" -eq 0 ]; then
681
+ ok "an operator who turned compaction back ON is not drift (retired keys leave the doctor green)"
682
+ else
683
+ bad "compaction re-enabled by the operator still reddens the doctor (the keys are not really retired):"$'\n'"$(printf '%s\n' "$DOC_OUT" | grep -E '^ FAIL' | sed 's/^/ /')"
684
+ fi
685
+ cp "$TMP/settings-m15.bak" "$CLAUDE_CONFIG_DIR/settings.json"
686
+
687
+ # M16 — the ONE nudge a retired key still owes. `check()` refuses while the ledger
688
+ # still carries ownership entwurf has not yet relinquished, which is the state an
689
+ # upgraded host is in between `git pull` and `install-meta-bridge`. No other
690
+ # mutation reaches that branch, so without this cell the whole retirement path
691
+ # could stop refusing and every gate would stay green.
692
+ cp "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" "$TMP/state-m16.bak"
693
+ python3 - "$CLAUDE_CONFIG_DIR/entwurf.install-state.json" <<'PY'
694
+ import json, sys
695
+ p = sys.argv[1]
696
+ d = json.load(open(p, encoding="utf-8"))
697
+ d["files"]["settings"]["keys"]["autoCompactEnabled"] = {
698
+ "kind": "scalar",
699
+ "path": ["autoCompactEnabled"],
700
+ "original": {"existed": False, "value": None},
701
+ }
702
+ json.dump(d, open(p, "w", encoding="utf-8"), indent=2)
703
+ PY
704
+ expect_red "install-state still owns a retired scalar (upgrade not yet relinquished)" "install-state still owns retired scalar autoCompactEnabled"
705
+ cp "$TMP/state-m16.bak" "$CLAUDE_CONFIG_DIR/entwurf.install-state.json"
706
+
634
707
  # --- 5. the control must still hold after every restore ----------------------
635
708
  echo "[control] re-run after all mutations were reverted"
636
709
  run_doctor