@junghanacs/entwurf 0.17.1 → 0.17.2

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 (37) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  3. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
  7. package/package.json +1 -1
  8. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  9. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  10. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  11. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  12. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  13. package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
  14. package/pi-extensions/lib/meta-session.ts +32 -7
  15. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  16. package/scripts/check-entwurf-v2-runner.ts +18 -0
  17. package/scripts/check-entwurf-v2-send.ts +13 -1
  18. package/scripts/check-entwurf-v2-surface.ts +75 -3
  19. package/scripts/check-hook-launch-topology.ts +70 -1
  20. package/scripts/check-mailbox-receipt-state.ts +6 -0
  21. package/scripts/check-meta-doctor-oracle.sh +73 -0
  22. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  23. package/scripts/check-meta-manifest-schema.py +19 -1
  24. package/scripts/check-meta-session.ts +10 -2
  25. package/scripts/meta-bridge-doctor.sh +18 -3
  26. package/scripts/meta-bridge-state.py +23 -5
  27. package/scripts/meta-bridge-statusline.sh +65 -2
  28. package/scripts/raw-async-delivery/README.md +280 -9
  29. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  30. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  31. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  32. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  33. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  34. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  35. package/scripts/smoke-meta-async-drift.sh +31 -8
  36. package/scripts/smoke-meta-install-state.sh +170 -11
  37. package/scripts/smoke-meta-keyset-guard.sh +4 -1
@@ -158,6 +158,32 @@ function recordingEnqueue(): {
158
158
  ok("6: successful enqueue → {success:true}", res.success === true && res.error === undefined);
159
159
  }
160
160
 
161
+ // ── 6b. #98 R: the enqueue result's messagePath is the SEND receipt ──────────
162
+ // Reported VERBATIM from the enqueue result, never re-derived from the plan — a
163
+ // re-derived path could disagree with the file that was actually written.
164
+ {
165
+ const enq = recordingEnqueue();
166
+ const res = executeMetaMailboxSend(mailboxPlan(), SENDER, { enqueue: enq.fn });
167
+ ok("6b: result carries the enqueue's messagePath verbatim", res.messagePath === `/fake/mailbox/${GID}/m.msg`);
168
+ }
169
+ {
170
+ // The path is taken from the RESULT, not from `plan.mailboxDir` + gardenId: an enqueue
171
+ // that lands somewhere else (env override, tilde expansion, a resolved symlink) must be
172
+ // reported where it actually landed.
173
+ const res = executeMetaMailboxSend(mailboxPlan(), SENDER, {
174
+ enqueue: (opts) => ({
175
+ gardenId: opts.gardenId,
176
+ recordPath: "/elsewhere/rec.json",
177
+ messagePath: "/elsewhere/queued-here.msg",
178
+ signalPath: "/elsewhere/inbox.signal",
179
+ }),
180
+ });
181
+ ok(
182
+ "6b: a messagePath outside the plan's mailboxDir is still reported as-is",
183
+ res.messagePath === "/elsewhere/queued-here.msg",
184
+ );
185
+ }
186
+
161
187
  // ── 7. production adapter: ignores lock entirely (poison LockClaim) ───────────
162
188
  {
163
189
  const enq = recordingEnqueue();
@@ -223,6 +249,13 @@ function recordingEnqueue(): {
223
249
  for (const forbidden of ["releaseLock", "inspectSocket", "probeSocket", "resolveDispatch", "resolveTarget"]) {
224
250
  ok(`9: lib code has no '${forbidden}' (no release / no routing seam)`, !code.includes(forbidden));
225
251
  }
252
+ // #98 R, the negative half stated as an assertion: a SEND receipt may never carry a
253
+ // read/delivery stamp. At enqueue time `lastReadAt` holds the PREVIOUS message's read,
254
+ // so carrying it here would let a sender read "my message was read" off a stamp that
255
+ // says nothing of the kind. The per-message read receipt is the `.read` suffix.
256
+ for (const forbidden of ["lastReadAt", "lastDeliveredAt", "readAt"]) {
257
+ ok(`9: lib code never carries '${forbidden}' into the send receipt`, !code.includes(forbidden));
258
+ }
226
259
  }
227
260
 
228
261
  console.log(`\ncheck-entwurf-v2-mailbox: ${passed} checks passed`);
@@ -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)
@@ -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
@@ -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
  }
@@ -468,11 +468,18 @@ if diff:
468
468
  # template equality above, not from a loose per-field test. It says the wake wiring is
469
469
  # DECLARED as shipped; it does NOT say exit-2 → wake holds at runtime (#51 B2).
470
470
  bell = leaf("FileChanged")
471
+ # The two rewake strings are REPORTED, not optional: this line's whole job is to say what
472
+ # the wake wiring DECLARES, and since #98 the declaration includes what the operator and the
473
+ # model actually see when a letter lands. Omitting them would report a contract the manifest
474
+ # no longer fully states — the exact class of half-truth #98 exists to remove. They are safe
475
+ # to index: the exact-template equality above already ran, so a manifest missing either field
476
+ # never reaches here.
471
477
  doorbell = (
472
478
  f"ok FileChanged doorbell matches the shipped static contract exactly "
473
479
  f"(command={bell['command']}, args={bell['args']}, asyncRewake={bell['asyncRewake']}, "
474
- f"timeout={bell['timeout']}); runtime exit-2 wake was observed at Claude 2.1.217 (#51 B2) but "
475
- "is not re-proven by this static read"
480
+ f"timeout={bell['timeout']}, rewakeSummary={bell['rewakeSummary']!r}, "
481
+ f"rewakeMessage={bell['rewakeMessage']!r}); runtime exit-2 wake was observed at Claude "
482
+ "2.1.217 (#51 B2) but is not re-proven by this static read"
476
483
  )
477
484
 
478
485
  # Resolve the argv EXACTLY the way Claude does for an exec form: substitute the
@@ -563,7 +570,15 @@ PY
563
570
  bad "statusline bin not on PATH: $EXPECTED_STATUSLINE"
564
571
  fi
565
572
  SAMPLE_STATUSLINE_OUT="$(printf '%s' '{"session_id":"doctor-no-record","workspace":{"current_dir":"/tmp"},"model":{"id":"claude-sonnet-5"},"context_window":{"context_window_size":200000,"used_percentage":1,"current_usage":{"input_tokens":1}}}' | "$EXPECTED_STATUSLINE" 2>/dev/null || true)"
566
- if [ "$(printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | wc -l | tr -d ' ')" = "2" ] && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '🪛' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q ' cc | s'; then ok "statusline synthetic execution emits two rows (work context + identity)"; else bad "statusline synthetic execution failed or omitted two-row work/identity marker"; fi
573
+ # The identity row may carry the #98 B unread badge between `cc` and the model letter:
574
+ # ` ✉N` when mail is waiting, nothing when the mailbox is empty, ` ✉?` when the count
575
+ # could not be taken. All three are healthy renders, so the shape test admits any of them.
576
+ if [ "$(printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | wc -l | tr -d ' ')" = "2" ] && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '1p' | grep -q 'tmp' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '🪛' && printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -qE ' cc( ✉([0-9]+|\?))? \| s'; then ok "statusline synthetic execution emits two rows (work context + identity)"; else bad "statusline synthetic execution failed or omitted two-row work/identity marker"; fi
577
+ # The synthetic feeds a session_id with no meta-record, so the garden id does not
578
+ # resolve and there is no mailbox to count. `✉?` is the required answer: a statusline
579
+ # that drew nothing there would be claiming "no mail" about a mailbox it never opened —
580
+ # the exact silence #98 was opened to remove.
581
+ if printf '%s\n' "$SAMPLE_STATUSLINE_OUT" | sed -n '2p' | grep -q '✉?'; then ok "statusline badge distinguishes 'could not count' (✉?) from zero on the no-record synthetic"; else bad "statusline badge omitted ✉? for the no-record synthetic (a false zero would be indistinguishable from an empty mailbox)"; fi
567
582
  else
568
583
  bad "cannot validate statusline without python3"
569
584
  fi
@@ -66,16 +66,24 @@ LEGACY_PERMISSION_ALLOW = ["mcp__pi-tools-bridge__*"]
66
66
 
67
67
  # Claude Code single-driver policy scalars owned by entwurf for the native
68
68
  # meta-bridge install. These are not theming/personal hooks; they close background
69
- # autonomy/suggestion/compaction surfaces so Claude Code behaves like the same
70
- # single forged screwdriver that entwurf already enforces for ACP backends.
69
+ # autonomy/suggestion surfaces so Claude Code behaves like the same single forged
70
+ # screwdriver that entwurf already enforces for ACP backends.
71
+ #
72
+ # COMPACTION IS NOT ONE OF THEM (#94). entwurf neither enables nor disables it on
73
+ # any rail, and the ACP side reached that state first: 0.5.0 really did carry a
74
+ # pi-side compaction surface in CODE (`session_before_compact` cancelling by
75
+ # default, `assertLegacyCompactionKnobUnset`, `smoke-compaction-policy`), the v2
76
+ # subtraction removed it (`378c682`, 2026-06-16), and `623a4ea` (06-23) then
77
+ # removed the documentation that had outlived the code by a week. As of #94 the
78
+ # native rail stops owning `autoCompactEnabled` / `env.DISABLE_AUTOCOMPACT` too.
79
+ # Backend-native context management is allowed; we add no guard and only measure.
80
+ # Both keys live in RETIRED_SETTINGS_SCALARS below.
71
81
  MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
72
82
  ("cleanupPeriodDays", ["cleanupPeriodDays"], 365),
73
- ("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
74
83
  ("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
75
84
  ("awaySummaryEnabled", ["awaySummaryEnabled"], False),
76
85
  ("autoMemoryEnabled", ["autoMemoryEnabled"], False),
77
86
  ("verbose", ["verbose"], False),
78
- ("autoCompactEnabled", ["autoCompactEnabled"], False),
79
87
  ("showTurnDuration", ["showTurnDuration"], False),
80
88
  ("terminalProgressBarEnabled", ["terminalProgressBarEnabled"], False),
81
89
  ("useAutoModeDuringPlan", ["useAutoModeDuringPlan"], False),
@@ -89,6 +97,14 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
89
97
  # exactly once, then uninstall can no longer restore over the operator's choice.
90
98
  RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
91
99
  ("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
100
+ # #94 — compaction returns to the operator. RETIRED, never deleted outright:
101
+ # a deleted entry would be ORPHANED in an existing install-state, and
102
+ # uninstall() walks every ledger entry through restore_entry(), so it would
103
+ # put the suppression back over an operator who had since enabled compaction.
104
+ # Only this list's relinquish path pops the entry so uninstall can no longer
105
+ # reach it.
106
+ ("env.DISABLE_AUTOCOMPACT", ["env", "DISABLE_AUTOCOMPACT"], "1"),
107
+ ("autoCompactEnabled", ["autoCompactEnabled"], False),
92
108
  ]
93
109
 
94
110
  _managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
@@ -607,7 +623,9 @@ def check(repo: Path, asm: Path) -> None:
607
623
  if existed and type(value) is type(last_managed_value) and value == last_managed_value:
608
624
  print(
609
625
  f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
610
- f"entwurf no longer suppresses or restores this warning choice"
626
+ f"entwurf no longer sets or restores this value. It still carries the value "
627
+ f"entwurf last managed, which is not drift and is not a defect — a retired "
628
+ f"key has no desired value to drift from."
611
629
  )
612
630
 
613
631
  checks = [