@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,127 @@ All notable changes to this project will be documented here. Format follows [Kee
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.17.2 - 2026-09-03
8
+
9
+ ### Added
10
+
11
+ - **An arriving letter is now visible to the operator, not just to the model (#98).** A sibling's
12
+ mail landed, was read, and left one line on the receiving screen — `Stop hook feedback` — with no
13
+ sender, no count, no garden id. Three surfaces changed, each measured in the
14
+ `scripts/raw-async-delivery/` lab before any product edit:
15
+ - **The doorbell says what it is.** The shipped `FileChanged` hook declares
16
+ `rewakeSummary: "entwurf inbox: sibling mail arrived"` and
17
+ `rewakeMessage: "entwurf mailbox notice:"`. The model no longer wakes to
18
+ `Stop hook blocking error from command "FileChanged"` — mail arriving was being named an error.
19
+ Both fields are `@internal` in the vendor and unsanitized, so `check-hook-launch-topology` lints
20
+ them (declared, no newline, not whitespace-only, length bound, no `[` in the prefix that would
21
+ double the doorbell's own `[entwurf inbox]`) and `smoke-meta-async-drift` carries both as
22
+ markers — a silent `@internal` removal is a future this repo cannot measure live.
23
+ - **The status line carries an unread badge.** `✉N` counts exactly what `entwurf_inbox_read`
24
+ would hand back (`*.msg` + `*.msg.delivered`, `.read` excluded — the same union as
25
+ `readMetaInbox`). No badge at zero. **`✉?` when the count could not be TAKEN** (no python3,
26
+ unusable garden id, unreadable directory): a measurement failure must not look like an empty
27
+ inbox.
28
+ - **A send names the file it enqueued.** `entwurf_v2 meta-mailbox → enqueued (2026-…-113443.msg)`.
29
+ `enqueueMetaMessage` already returned `messagePath`; the mailbox hand was flattening it to
30
+ `{success:true}`. Deliberately not a read stamp: at enqueue time `lastReadAt` belongs to the
31
+ PREVIOUS message, and surfacing it would read as "my message was read".
32
+
33
+ ### Fixed
34
+
35
+ - **A dead-socket send that falls back to the mailbox now names its file too (#98).** A
36
+ control-socket delivery whose socket was gone re-resolves to the mailbox and writes a `.msg`
37
+ exactly like the primary rail, but the sender's line said only `fallback-sent` — the one mailbox
38
+ delivery with no per-message identifier. The receipt is carried through `SendDrive` →
39
+ `ControlSocketSendResult` → `ExecutedOutcome` → surface. A socket-to-socket retry writes no file
40
+ and carries none; a `rejected` enqueue carries none; a dep that omits it degrades to the bare
41
+ outcome rather than printing `undefined`.
42
+ - **entwurf no longer owns the compaction switch (#94).** `autoCompactEnabled` and
43
+ `env.DISABLE_AUTOCOMPACT` moved from `MANAGED_SETTINGS_SCALARS` to `RETIRED_SETTINGS_SCALARS`,
44
+ the path `skipDangerousModePermissionPrompt` already walked. Retirement moves **ownership, not
45
+ state**: `relinquish_retired_scalar()` restores the install-state snapshot only when the current
46
+ value still equals the last managed one, so retirement alone turns compaction on for nobody —
47
+ turning it on is a separate operator act, and entwurf writing that value again would undo the
48
+ return. The doctor now stays green for an operator who turned compaction back on.
49
+ - **A correction that belongs in the record:** `env.DISABLE_AUTOCOMPACT` was a **no-op** at Claude
50
+ Code 2.1.259 — the only key that actually suppressed compaction was `autoCompactEnabled`. The
51
+ conclusion is unchanged; the reason narrows to one key.
52
+ - The lineage is also corrected. 0.5.0 *did* ship a pi-side compaction guard in real code;
53
+ `378c682` (v2 subtraction) deleted it and `623a4ea` later cleared the docs that outlived it by
54
+ seven days. The "zero code backing" in that commit message was a grep result at that moment, not
55
+ a claim that the guard never existed.
56
+
57
+ ### Changed
58
+
59
+ - **Three shipped comments stopped repeating two claims this release retired.** `doorbell.sh`,
60
+ `raw-async-delivery/README.md` and `smoke-meta-async-drift.sh` said that `asyncRewake` ignores
61
+ `rewakeMessage` and that stdout is dropped. Measured against three vendor binaries (2.1.236 /
62
+ 2.1.258 / 2.1.259), both are false: `rewakeMessage` *replaces* the prefix, and the body is
63
+ `stderr || stdout`. The marker strings were correct while their stated reasons were dead — a
64
+ sentinel nobody could act on. The lab README now carries the receipts under
65
+ `## Inherited facts corrected`, and the watcher documents what it observes rather than which
66
+ backends it expects.
67
+ - `lastDeliveredAt` is documented as a **reserved slot nobody stamps**, in five comments and four
68
+ test cells that had been pinning it green as `=== null` while 933 files carried a `.delivered`
69
+ suffix. The per-message facts are the suffixes (`.msg` → `.delivered` → `.read`); `state.json`
70
+ holds only the garden's last enqueue/read. Removing the field is a separate migration (its reader
71
+ is doubly strict and 182 v1 files are on disk), tracked apart from this release.
72
+
73
+ ### Upgrade note
74
+
75
+ **Run `entwurf setup` once after upgrading — every rail, not just Claude.** Two separate debts:
76
+
77
+ - The Claude plugin's hook template gained `rewakeSummary` and `rewakeMessage`, so until
78
+ `install-meta-bridge` runs, `doctor-meta-bridge` reports
79
+ `installed manifest DIFFERS … Re-run install-meta-bridge`.
80
+ - **This release changed `lib/meta-session.ts`, and four install paths deploy that file** —
81
+ `install-meta-bridge` (Claude), `install-omp-bridge` and `install-omp-receive` (OMP),
82
+ `install-copilot-bridge` (Copilot). Every one of them that is installed on the host now carries a
83
+ STALE writer until it is re-installed, and its own doctor says so by name. Re-installing only the
84
+ Claude rail leaves the others stale — measured on oracle during this cut, where it blocked
85
+ `smoke-omp-receive-live` and turned the first `--cut` run red.
86
+
87
+ `entwurf setup` is presence-driven and re-synthesizes exactly the units this host has, which is why
88
+ it is the upgrade command rather than any single `install-*`. An already-open Claude Code session
89
+ keeps the old manifest until it restarts.
90
+
91
+ 0.17.1 was tagged and released on GitHub but **not published to npm**; it is superseded by this
92
+ version.
93
+
94
+ ### Verification
95
+
96
+ All of the following ran on oracle (Linux, Claude Code 2.1.259, node 24.18.1, pi 0.84.4, omp 18.0.0).
97
+
98
+ - **`pnpm run check:full` — exit 0** (432s on the prepared tree; 437s on the pre-version HEAD).
99
+ - **`LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.17.2b.WW12BK --cut` — `cut: OK`,
100
+ exit 0.** **MUST PASS=23 FAIL=0 SKIP=0**, **BEHAVIOR PASS=1 FAIL=0 SKIP=0**. Run 20:39→21:26 KST
101
+ with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID`. Log:
102
+ `/tmp/entwurf-release-gate-0.17.2b.WW12BK/release-gate.log`. It carried `check:full` (429s) and
103
+ `check-gate-qualification` (**347/347 KILLED**) as MUST steps.
104
+ - **The first `--cut` attempt was RED, and that is the receipt for the upgrade note above.**
105
+ `smoke-omp-receive-live` failed with `STALE writer: source=634d5b96ed50 installed=229fef123589`
106
+ on `~/.omp/agent/extensions/entwurf-receive-omp/lib/meta-session.ts` — MUST PASS=22 FAIL=1,
107
+ `cut: BLOCKED`. Both OMP extensions on the host still carried the pre-release writer because only
108
+ the Claude rail had been re-installed. `install-omp-bridge` + `install-omp-receive` moved both to
109
+ `634d5b96ed50`, `doctor-omp-bridge` and `doctor-omp-receive` went PASS, and the re-run was green.
110
+ Nothing in the product changed between the two runs.
111
+ - **#94 measured on a live host.** After one `install-meta-bridge`, the install-state ledger no
112
+ longer carries `autoCompactEnabled` or `env.DISABLE_AUTOCOMPACT`, and `~/.claude/settings.json`
113
+ was **byte-identical** to its pre-install backup — the return does not rewrite the value it
114
+ returns. `doctor-meta-bridge` went FAIL→PASS, `smoke-meta-install-state` PASS,
115
+ `check-meta-doctor-oracle` PASS including the two new cells (`an operator who turned compaction
116
+ back ON is not drift`, `install-state still owns a retired scalar → FAIL naming its own cause`).
117
+ - **#98 B measured across the whole suffix lifecycle** in an isolated mailbox root: no badge at 0,
118
+ `✉1` on `.msg`, `✉2` after `.msg.delivered`, unchanged by a `.read` file, back to no badge when
119
+ all are read, and `✉?` on an unreadable directory.
120
+ - **`smoke-meta-async-drift` ends `pass=12 fail=0 drift=1`, exit 1** — the drift is
121
+ `codex 0.147.0` outside the `0.144.x` pin and **pre-dates this release**. Both markers this
122
+ release added (`rewakeMessage`, `rewakeSummary`) are present. The pin bump is deliberately not in
123
+ this lane.
124
+ - **Exact-SHA CI on the pre-version HEAD `4124e42`** — `check`, `install-surface`,
125
+ `artifact-consumer` all `success`.
126
+ Run: https://github.com/junghan0611/entwurf/actions/runs/33742448634
127
+
7
128
  ## 0.17.1 - 2026-09-03
8
129
 
9
130
  ### Fixed
@@ -36,16 +36,23 @@ import { enqueueMetaMessage } from "./meta-session.js";
36
36
  * legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
37
37
  * caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
38
38
  * throw PROPAGATES; it is never converted to `{success:false}`.
39
+ *
40
+ * #98 R — the SEND receipt: `enqueueMetaMessage` already returns the exact `.msg` path it
41
+ * wrote ("Returns the paths so a sender can show exactly what was queued"), and this body
42
+ * used to flatten it to `{success:true}`. It now carries that ONE field through. Nothing
43
+ * else from the enqueue result crosses: no timestamps, and specifically no `lastReadAt` —
44
+ * see the `RpcSendResult` note. The path is reported verbatim from the enqueue result, not
45
+ * re-derived from the plan, so it can never disagree with the file that was actually written.
39
46
  */
40
47
  export function executeMetaMailboxSend(plan, sender, deps) {
41
48
  const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
42
- deps.enqueue({
49
+ const enqueued = deps.enqueue({
43
50
  gardenId: plan.targetGardenId,
44
51
  body,
45
52
  sessionsDir: plan.sessionsDir,
46
53
  mailboxDir: plan.mailboxDir,
47
54
  });
48
- return { success: true };
55
+ return { success: true, messagePath: enqueued.messagePath };
49
56
  }
50
57
  /**
51
58
  * Build the production `sendViaMailbox(plan, lock)` adapter the 5c-2a send hand consumes.
@@ -49,7 +49,12 @@ export async function executeDispatch(decision, deps) {
49
49
  kind: "executed",
50
50
  receipt,
51
51
  transport,
52
- outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
52
+ outcome: {
53
+ transport: "control-socket",
54
+ outcome: r.outcome,
55
+ rejectReason: r.rejectReason,
56
+ messagePath: r.messagePath,
57
+ },
53
58
  };
54
59
  }
55
60
  catch (err) {
@@ -80,7 +85,14 @@ export async function executeDispatch(decision, deps) {
80
85
  if (r.success !== true) {
81
86
  throw new Error("entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).");
82
87
  }
83
- return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
88
+ return {
89
+ kind: "executed",
90
+ receipt,
91
+ transport,
92
+ // #98 R: carry the enqueue receipt verbatim. `undefined` stays `undefined` —
93
+ // the runner never substitutes a guessed path for a missing one.
94
+ outcome: { transport: "meta-mailbox", success: true, messagePath: r.messagePath },
95
+ };
84
96
  }
85
97
  catch (err) {
86
98
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
@@ -58,7 +58,7 @@ export async function executeControlSocketSend(plan, lock, deps) {
58
58
  drive = { outcome: "failed", error: err };
59
59
  }
60
60
  finalizeRelease(policy, deps, held, drive);
61
- return { outcome: drive.outcome, rejectReason: drive.rejectReason };
61
+ return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
62
62
  }
63
63
  /** Drive the 1차 send and route a connect failure through the F3 split. */
64
64
  async function driveSend(plan, lock, deps) {
@@ -122,7 +122,10 @@ async function driveDeadFallback(plan, lock, deps) {
122
122
  // hand never reaches for the mailbox on its own; only the resolver routes here.
123
123
  try {
124
124
  const r = await deps.sendViaMailbox(rePlan, lock);
125
- return { outcome: r.success ? "fallback-sent" : "rejected" };
125
+ // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
126
+ // owes the sender the same per-message receipt. Carried only on success — a
127
+ // `rejected` enqueue wrote no file to name.
128
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
126
129
  }
127
130
  catch (err) {
128
131
  return { outcome: "failed", error: err };
@@ -18,6 +18,7 @@
18
18
  * - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
+ import { basename } from "node:path";
21
22
  import { makeProductionEntwurfV2Deps } from "./entwurf-v2-production.js";
22
23
  import { runEntwurfV2 } from "./entwurf-v2-runner.js";
23
24
  import { FRESH_CUT_PRESCRIPTION } from "./meta-session.js";
@@ -109,8 +110,13 @@ export function renderEntwurfV2Result(result) {
109
110
  if (o.transport === "control-socket") {
110
111
  const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
111
112
  const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
113
+ // #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
114
+ // `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
115
+ // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
116
+ // line degrades to the bare outcome rather than printing "undefined".
117
+ const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
112
118
  return {
113
- text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
119
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
114
120
  isError: !delivered,
115
121
  };
116
122
  }
@@ -121,8 +127,17 @@ export function renderEntwurfV2Result(result) {
121
127
  isError: false,
122
128
  };
123
129
  }
124
- // meta-mailbox
125
- return { text: "entwurf_v2 meta-mailbox enqueued", isError: false };
130
+ // meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
131
+ // carries a per-message identifier instead of a bare literal. Only the basename —
132
+ // the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
133
+ // typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
134
+ // PREVIOUS message, so printing it would claim a read that has not happened.
135
+ // A dep that omits the receipt falls back to the old literal rather than printing
136
+ // "undefined" — the delivery still happened.
137
+ return {
138
+ text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
139
+ isError: false,
140
+ };
126
141
  }
127
142
  case "execution-failed": {
128
143
  if (result.releaseFailed && result.finalizedOutcome) {
@@ -2077,9 +2077,17 @@ export function enqueueMetaMessage(opts) {
2077
2077
  * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
2078
2078
  * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
2079
2079
  * `*.read` so a re-read never double-returns, and — only if at least one message
2080
- * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
2081
- * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
2082
- * reading nothing is not a receipt.
2080
+ * was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
2081
+ * stamp-site note below). An empty inbox mutates nothing: reading nothing is not
2082
+ * a receipt.
2083
+ *
2084
+ * WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
2085
+ * is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
2086
+ * rang for it, `.msg.delivered.read` = this function handed it to the reader.
2087
+ * `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
2088
+ * `lastReadAt` says "this citizen last read something at T", never "message X was
2089
+ * read". A sender that quotes `lastReadAt` back as the fate of the letter it just
2090
+ * queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
2083
2091
  */
2084
2092
  export function readMetaInbox(opts) {
2085
2093
  const now = opts.now ?? new Date();
@@ -2101,10 +2109,18 @@ export function readMetaInbox(opts) {
2101
2109
  if (messages.length === 0) {
2102
2110
  return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
2103
2111
  }
2104
- // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
2105
- // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
2106
- // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
2107
- // would report read-time as delivery-time, so it is left as the doorbell left it.
2112
+ // 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
2113
+ // now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
2114
+ // reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
2115
+ // corrects WHY: the old comment said "the doorbell owns it", but the shipped
2116
+ // doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
2117
+ // So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
2118
+ // ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
2119
+ // here would still be wrong (it would report read-time as delivery-time), so it
2120
+ // stays null — but do not read "the doorbell will fill it in" into that. The
2121
+ // per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
2122
+ // deliberately: removing it is a migration (the parser rejects unknown keys AND a
2123
+ // bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
2108
2124
  // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
2109
2125
  // Inside the messages.length>0 branch by construction — an empty inbox already
2110
2126
  // early-returned (no .read archive, state untouched), so "read nothing" is no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -45,7 +45,9 @@
45
45
  "command": "${CLAUDE_PLUGIN_ROOT}/scripts/hook-launch.sh",
46
46
  "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/doorbell.sh"],
47
47
  "asyncRewake": true,
48
- "timeout": 20
48
+ "timeout": 20,
49
+ "rewakeSummary": "entwurf inbox: sibling mail arrived",
50
+ "rewakeMessage": "entwurf mailbox notice:"
49
51
  }
50
52
  ]
51
53
  }
@@ -6,15 +6,20 @@
6
6
  # with a doorbell notice. Free: a file write + continuation of an already-running
7
7
  # subscription session (no `claude -p` spawn).
8
8
  #
9
- # ADDRESSED by GARDEN ID: the changed path arrives on stdin as `file_path`; its
10
- # directory IS this session's garden mailbox (<meta-mailbox>/<garden-id>/). So
11
- # this hook touches ONLY its own mailbox — a sender that pokes one garden id's
12
- # signal wakes only that session. No node needed here; the dirname is the mailbox.
9
+ # ADDRESSED by GARDEN ID: the changed path arrives on stdin as `file_path` and this
10
+ # hook takes its DIRECTORY as the mailbox to process. No node needed here; the
11
+ # dirname is the mailbox.
13
12
  #
14
- # DOORBELL ONLY: announce "you have mail" + the body path on stderr (the sole
15
- # asyncRewake payload channel stdout is dropped). NEVER push imperatives; strong
16
- # models flag hook-injected commands as prompt injection. The agent self-fetches
17
- # the body with its own trusted tool, and that inbox-read is the real D7 receipt.
13
+ # Two statements, deliberately kept apart (#98, 2026-09-03 they used to be one
14
+ # sentence claiming this hook "touches ONLY its own mailbox"):
15
+ # - PREMISE: under a normal install the only watch this session armed is its own
16
+ # <garden-id>/inbox.signal, so that is the only file_path that arrives.
17
+ # - LIMIT: this script does not verify it. It trusts `file_path` and never compares
18
+ # the dirname against its garden id. Measured — a second FileChanged hook in the
19
+ # same session made this doorbell process the OTHER mailbox and race it to exit 2,
20
+ # which is why a second such hook cannot coexist with this one.
21
+ # Receipt: scripts/raw-async-delivery/README.md, "What the probe session actually
22
+ # touches". Not a live defect: nothing pokes a signal outside the garden mailbox.
18
23
  #
19
24
  # RUNTIME DEPS: bash + python3 (the FileChanged stdin JSON is parsed with python3
20
25
  # below — robust against escaping, unlike sed/grep). The meta-bridge doctor must
@@ -50,6 +50,13 @@ export interface MetaMailboxSendDeps {
50
50
  * legacy mailbox path used). `plan.wantsReply` is threaded into the body — v2 carries the
51
51
  * caller's intent, a DELIBERATE divergence from the legacy hard-coded `false`. An enqueue
52
52
  * throw PROPAGATES; it is never converted to `{success:false}`.
53
+ *
54
+ * #98 R — the SEND receipt: `enqueueMetaMessage` already returns the exact `.msg` path it
55
+ * wrote ("Returns the paths so a sender can show exactly what was queued"), and this body
56
+ * used to flatten it to `{success:true}`. It now carries that ONE field through. Nothing
57
+ * else from the enqueue result crosses: no timestamps, and specifically no `lastReadAt` —
58
+ * see the `RpcSendResult` note. The path is reported verbatim from the enqueue result, not
59
+ * re-derived from the plan, so it can never disagree with the file that was actually written.
53
60
  */
54
61
  export function executeMetaMailboxSend(
55
62
  plan: MetaMailboxPlan,
@@ -57,13 +64,13 @@ export function executeMetaMailboxSend(
57
64
  deps: MetaMailboxSendDeps,
58
65
  ): RpcSendResult {
59
66
  const body = sender ? formatMetaMailboxBody(sender, plan.message, plan.wantsReply) : plan.message;
60
- deps.enqueue({
67
+ const enqueued = deps.enqueue({
61
68
  gardenId: plan.targetGardenId,
62
69
  body,
63
70
  sessionsDir: plan.sessionsDir,
64
71
  mailboxDir: plan.mailboxDir,
65
72
  });
66
- return { success: true };
73
+ return { success: true, messagePath: enqueued.messagePath };
67
74
  }
68
75
 
69
76
  /**
@@ -61,12 +61,17 @@ export interface DispatchExecutorDeps {
61
61
  }
62
62
 
63
63
  /** The per-transport success outcome, discriminated by transport so the surface renders
64
- * each without guessing. `control-socket` carries the optional N3 `rejectReason`;
64
+ * each without guessing. `control-socket` carries the optional N3 `rejectReason` and,
65
+ * when a dead-socket re-resolve fell back to the mailbox, that leg's #98 R `messagePath`;
65
66
  * `meta-mailbox` is always `success:true` (enqueue has no in-band refuse — a failure is
66
- * a throw, handled as `execution-failed`). */
67
+ * a throw, handled as `execution-failed`) and carries the #98 R send receipt: the path of
68
+ * the `.msg` the enqueue actually wrote. OPTIONAL because a fake/legacy `sendMailbox` dep
69
+ * may omit it — a missing receipt must degrade the rendered line, never fail the delivery,
70
+ * and it is the ONLY enqueue-side datum carried (never a read timestamp; see
71
+ * `RpcSendResult`). */
67
72
  export type ExecutedOutcome =
68
- | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string }
69
- | { transport: "meta-mailbox"; success: true }
73
+ | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string; messagePath?: string }
74
+ | { transport: "meta-mailbox"; success: true; messagePath?: string }
70
75
  // native-push carries `retried` so the surface can note the 1-shot re-probe retry fired.
71
76
  | { transport: "native-push"; success: true; retried: boolean };
72
77
 
@@ -118,7 +123,12 @@ export async function executeDispatch(
118
123
  kind: "executed",
119
124
  receipt,
120
125
  transport,
121
- outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
126
+ outcome: {
127
+ transport: "control-socket",
128
+ outcome: r.outcome,
129
+ rejectReason: r.rejectReason,
130
+ messagePath: r.messagePath,
131
+ },
122
132
  };
123
133
  } catch (err) {
124
134
  // N1: a delivered/refused send whose release then threw — lock dirty, do NOT retry.
@@ -150,7 +160,14 @@ export async function executeDispatch(
150
160
  "entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).",
151
161
  );
152
162
  }
153
- return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
163
+ return {
164
+ kind: "executed",
165
+ receipt,
166
+ transport,
167
+ // #98 R: carry the enqueue receipt verbatim. `undefined` stays `undefined` —
168
+ // the runner never substitutes a guessed path for a missing one.
169
+ outcome: { transport: "meta-mailbox", success: true, messagePath: r.messagePath },
170
+ };
154
171
  } catch (err) {
155
172
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
156
173
  }
@@ -52,10 +52,20 @@ export type MetaMailboxPlan = Extract<ExecutionPlan, { transport: "meta-mailbox"
52
52
  export type SendFinalOutcome = "sent" | "fallback-sent" | "rejected" | "failed";
53
53
 
54
54
  /** What a single RPC / mailbox enqueue reports. `success:false` is an in-band reject
55
- * (the receiver answered and refused) — distinct from a thrown connect error. */
55
+ * (the receiver answered and refused) — distinct from a thrown connect error.
56
+ *
57
+ * `messagePath` is the SEND-side receipt (#98 R) and is OPTIONAL because only the
58
+ * mailbox rail has one: an enqueue writes a file, so there is a per-message artifact
59
+ * to name. A control-socket RPC hands the body to a live receiver and produces no
60
+ * file, so it leaves this undefined rather than inventing one. It carries the ENQUEUED
61
+ * path and NOTHING about reading: `lastReadAt` at enqueue time is the PREVIOUS
62
+ * message's read stamp, so surfacing it here would read as "my message was read" —
63
+ * the exact misreading #98 opened on. The per-message read receipt is the `.read`
64
+ * suffix on this very file, never a state.json slot. */
56
65
  export interface RpcSendResult {
57
66
  success: boolean;
58
67
  error?: string;
68
+ messagePath?: string;
59
69
  }
60
70
 
61
71
  /** The same-lock one-shot re-resolve result (5c-2b implements the resolver; 5c-2a
@@ -98,6 +108,14 @@ export interface ControlSocketSendResult {
98
108
  * 5d runner carries this verbatim so the surface can tell "in-band refusal" from
99
109
  * "no live route" — the N3 carry-over the hand boundary used to drop. */
100
110
  rejectReason?: string;
111
+ /** #98 R, fallback leg: the `.msg` a dead-socket re-resolve enqueued. Present ONLY
112
+ * when the fallback actually routed to the mailbox and the enqueue succeeded — a
113
+ * control-socket retry hands the body to a live receiver and writes no file, so it
114
+ * leaves this undefined rather than inventing one. Same rule as `RpcSendResult`: the
115
+ * ENQUEUED path and nothing about reading. Without it a `fallback-sent` is the one
116
+ * mailbox delivery whose sender gets no per-message identifier — the same letter the
117
+ * primary mailbox rail names. */
118
+ messagePath?: string;
101
119
  }
102
120
 
103
121
  // A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
@@ -107,6 +125,8 @@ interface SendDrive {
107
125
  outcome: SendFinalOutcome;
108
126
  error?: unknown;
109
127
  rejectReason?: string;
128
+ /** #98 R receipt from the mailbox fallback leg (see `ControlSocketSendResult`). */
129
+ messagePath?: string;
110
130
  }
111
131
 
112
132
  /**
@@ -139,7 +159,7 @@ export async function executeControlSocketSend(
139
159
  drive = { outcome: "failed", error: err };
140
160
  }
141
161
  finalizeRelease(policy, deps, held, drive);
142
- return { outcome: drive.outcome, rejectReason: drive.rejectReason };
162
+ return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
143
163
  }
144
164
 
145
165
  /** Drive the 1차 send and route a connect failure through the F3 split. */
@@ -208,7 +228,10 @@ async function driveDeadFallback(
208
228
  // hand never reaches for the mailbox on its own; only the resolver routes here.
209
229
  try {
210
230
  const r = await deps.sendViaMailbox(rePlan, lock);
211
- return { outcome: r.success ? "fallback-sent" : "rejected" };
231
+ // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
232
+ // owes the sender the same per-message receipt. Carried only on success — a
233
+ // `rejected` enqueue wrote no file to name.
234
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
212
235
  } catch (err) {
213
236
  return { outcome: "failed", error: err };
214
237
  }
@@ -19,6 +19,7 @@
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
21
 
22
+ import { basename } from "node:path";
22
23
  import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
23
24
  import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
24
25
  import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
@@ -144,8 +145,13 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
144
145
  if (o.transport === "control-socket") {
145
146
  const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
146
147
  const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
148
+ // #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
149
+ // `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
150
+ // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
151
+ // line degrades to the bare outcome rather than printing "undefined".
152
+ const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
147
153
  return {
148
- text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
154
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
149
155
  isError: !delivered,
150
156
  };
151
157
  }
@@ -156,8 +162,17 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
156
162
  isError: false,
157
163
  };
158
164
  }
159
- // meta-mailbox
160
- return { text: "entwurf_v2 meta-mailbox enqueued", isError: false };
165
+ // meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
166
+ // carries a per-message identifier instead of a bare literal. Only the basename —
167
+ // the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
168
+ // typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
169
+ // PREVIOUS message, so printing it would claim a read that has not happened.
170
+ // A dep that omits the receipt falls back to the old literal rather than printing
171
+ // "undefined" — the delivery still happened.
172
+ return {
173
+ text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
174
+ isError: false,
175
+ };
161
176
  }
162
177
  case "execution-failed": {
163
178
  if (result.releaseFailed && result.finalizedOutcome) {
@@ -2532,9 +2532,17 @@ export interface ReadMetaInboxResult {
2532
2532
  * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
2533
2533
  * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
2534
2534
  * `*.read` so a re-read never double-returns, and — only if at least one message
2535
- * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
2536
- * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
2537
- * reading nothing is not a receipt.
2535
+ * was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
2536
+ * stamp-site note below). An empty inbox mutates nothing: reading nothing is not
2537
+ * a receipt.
2538
+ *
2539
+ * WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
2540
+ * is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
2541
+ * rang for it, `.msg.delivered.read` = this function handed it to the reader.
2542
+ * `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
2543
+ * `lastReadAt` says "this citizen last read something at T", never "message X was
2544
+ * read". A sender that quotes `lastReadAt` back as the fate of the letter it just
2545
+ * queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
2538
2546
  */
2539
2547
  export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2540
2548
  const now = opts.now ?? new Date();
@@ -2560,10 +2568,18 @@ export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2560
2568
  return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
2561
2569
  }
2562
2570
 
2563
- // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
2564
- // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
2565
- // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
2566
- // would report read-time as delivery-time, so it is left as the doorbell left it.
2571
+ // 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
2572
+ // now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
2573
+ // reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
2574
+ // corrects WHY: the old comment said "the doorbell owns it", but the shipped
2575
+ // doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
2576
+ // So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
2577
+ // ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
2578
+ // here would still be wrong (it would report read-time as delivery-time), so it
2579
+ // stays null — but do not read "the doorbell will fill it in" into that. The
2580
+ // per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
2581
+ // deliberately: removing it is a migration (the parser rejects unknown keys AND a
2582
+ // bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
2567
2583
  // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
2568
2584
  // Inside the messages.length>0 branch by construction — an empty inbox already
2569
2585
  // early-returned (no .read archive, state untouched), so "read nothing" is no
@@ -2603,6 +2619,15 @@ export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
2603
2619
  * The per-citizen mailbox receipt state. Holds exactly the three delivery
2604
2620
  * timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
2605
2621
  * capability, deliberately absent). Body is SSOT; the on-disk path is derived.
2622
+ *
2623
+ * SCOPE (#98 5a): these are GARDEN-WIDE "last activity" slots, single-valued and
2624
+ * overwritten — NOT per-message receipts. Per message, the receipt is the file
2625
+ * suffix in the same directory (`.msg` → `.msg.delivered` → `.msg.delivered.read`).
2626
+ * `lastEnqueuedAt`/`lastReadAt` are stamped by `enqueueMetaMessage`/`readMetaInbox`.
2627
+ * `lastDeliveredAt` is stamped by NOBODY: the doorbell does not write state.json, so
2628
+ * it is a reserved slot that is null everywhere. Kept rather than removed because the
2629
+ * parser is doubly strict (exact schemaVersion + no unknown keys) and every stamp
2630
+ * re-parses, so dropping it is a migration over the existing on-disk v1 files (#98 5b).
2606
2631
  */
2607
2632
  export interface MailboxReceiptState {
2608
2633
  schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;