@junghanacs/entwurf 0.16.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 (60) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +333 -0
  3. package/VERIFY.md +8 -1
  4. package/docs/acp-backend-rail.md +25 -14
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +15 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
  12. package/package.json +4 -4
  13. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  14. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  15. package/pi-extensions/lib/acp/acp-client.ts +57 -4
  16. package/pi-extensions/lib/acp/backend-adapter.ts +59 -0
  17. package/pi-extensions/lib/acp/backend.ts +453 -11
  18. package/pi-extensions/lib/acp/event-mapper.ts +43 -6
  19. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  20. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  21. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  22. package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
  23. package/pi-extensions/lib/meta-session.ts +32 -7
  24. package/run.sh +21 -0
  25. package/scripts/check-acp-stop-reason.ts +8 -2
  26. package/scripts/check-acp-usage-accounting.ts +1074 -0
  27. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  28. package/scripts/check-entwurf-v2-runner.ts +18 -0
  29. package/scripts/check-entwurf-v2-send.ts +13 -1
  30. package/scripts/check-entwurf-v2-surface.ts +75 -3
  31. package/scripts/check-gate-qualification.ts +1 -0
  32. package/scripts/check-hook-launch-topology.ts +70 -1
  33. package/scripts/check-mailbox-receipt-state.ts +6 -0
  34. package/scripts/check-meta-doctor-oracle.sh +73 -0
  35. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  36. package/scripts/check-meta-manifest-schema.py +19 -1
  37. package/scripts/check-meta-session.ts +10 -2
  38. package/scripts/lib/pi-record-discovery.ts +47 -0
  39. package/scripts/meta-bridge-doctor.sh +18 -3
  40. package/scripts/meta-bridge-state.py +23 -5
  41. package/scripts/meta-bridge-statusline.sh +65 -2
  42. package/scripts/mutants/acp-stream-hooks.json +4 -2
  43. package/scripts/mutants/acp-usage-accounting.json +181 -0
  44. package/scripts/raw-async-delivery/README.md +280 -9
  45. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  46. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  47. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  48. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  49. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  50. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  51. package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
  52. package/scripts/smoke-acp-cortex-live.ts +2 -2
  53. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  54. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  55. package/scripts/smoke-acp-v2-send-live.ts +2 -2
  56. package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
  57. package/scripts/smoke-meta-async-drift.sh +31 -8
  58. package/scripts/smoke-meta-install-state.sh +170 -11
  59. package/scripts/smoke-meta-keyset-guard.sh +4 -1
  60. package/scripts/smoke-mux-lifecycle-live.ts +46 -2
package/AGENTS.md CHANGED
@@ -104,7 +104,9 @@ A `--entwurf-control` pi session is a citizen for the same reason a native bridg
104
104
 
105
105
  ## Issue queue — a manual sweeper, not a backlog
106
106
 
107
- - OPEN issues are live contracts only, capped at five. A sixth means something closes first; the cap does not bend.
107
+ - **Two caps, and the inner one is what disciplines the work.** OPEN issues are capped at **ten** in total; of those, **implementation issues — the ones that will go out on a branch — are capped at five**. A sixth implementation issue means one closes first; that inner cap does not bend.
108
+ - **What sits outside the implementation five:** research issues, and issues GLG keeps open to look at. These are not slots to be earned or swept — a research lane may stay open across releases, and a north-star issue other repos read may never close. They still count toward the ten, so the queue cannot grow without limit.
109
+ - Classify by destination, not by size: if closing it produces a diff, it is an implementation issue. If closing it produces a decision, a document, or nothing, it is not.
108
110
  - A slot is earned by a current defect or executable contract, not by importance, age, or possible future value.
109
111
  - Direction, philosophy, frozen invariants and observations without a current action live in `ROADMAP.md`, durable docs, or closed history.
110
112
  - Nothing stays open "in case." If a closed problem recurs, it returns as a new issue carrying the new evidence.
@@ -140,7 +142,7 @@ LIVE=1 AGY_CONVERSATION_ID=<id> ./run.sh smoke-agy-native-push-live
140
142
  - The deterministic floor is tiered (#70). `pnpm check` is the everyday core — toolchain (lint + typecheck), the vitest lanes, and the pure-unit / behavioral-contract / source-topology gates plus the cheap static coherence checks. It prints its own total wall time; acceptance is ≤60s on the reference host `oracle` (an operator measurement, never a hard wall-clock gate on arbitrary hosts). `pnpm run check:full` is the full deterministic floor — the core plus the hermetic-integration and package/install tiers — and is what the candidate protocol, push CI, release-gate, and `prepublishOnly` run. Exact membership is the named `check:*` group scripts in `package.json` (the executable SSOT); a gate changes tier by semantic-class decision, never because it happened to get faster or slower. Neither tier includes `check-gate-qualification`, which is scheduled separately (below).
141
143
  - **Kill-proof discipline (gate qualification).** A gate is a test only if re-planting a closed defect turns it red for the claimed reason. `check-gate-qualification` proves that automatically: committed mutants in `scripts/mutants/` must be KILLED at their `[QK:<claim>]` signature inside an isolated snapshot repo (control→mutant→restore→control; the real checkout is never written). Gates a release touches carry such manifests; assertion counts are never evidence — claim IDs + killed mutant IDs are. `check-agy-permission-matrix` holds the enumerated permission contract space; matrix cells change by axis/rule edits, never by appending cases.
142
144
  - **When changing a contract/gate:** name the production subject and an oracle independent of it; give the failing assertion a stable `[QK:<claim>]` label and add/update the exact-once mutant in `scripts/mutants/*.json`; if the contract is combinatorial, update the literal matrix axes/cells/exclusions together with their declared counts; then verify the focused gate, and let qualification and the full floor follow the scheduling contract below — once on the frozen candidate, not once per amendment. `MUTANT-STALE`/`SURVIVED`/`WRONG-REASON`/`CONTROL-RED`/`HANG`/`IMPURE` are red — never substitute an assertion count for a kill.
143
- - Run LIVE gates with `PWD` in scratch so session artifacts do not land in the repo.
145
+ - Run LIVE gates with `PWD` in scratch so session artifacts do not land in the repo. Strip `CLAUDE_CONFIG_DIR`, `PI_SESSION_ID`, and `PI_AGENT_ID` from the gate process — a live pi/ACP session exports them into children, where they strip Claude hooks or capture a fresh-call callback.
144
146
  - Release acceptance and evidence levels are defined in [VERIFY.md](./VERIFY.md); recorded host evidence is in [BASELINE.md](./BASELINE.md).
145
147
  - A failed gate or evidence downgrade blocks commit/release. Pipes can be connected and the water can still taste wrong.
146
148
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,339 @@ 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
+
128
+ ## 0.17.1 - 2026-09-03
129
+
130
+ ### Fixed
131
+
132
+ - **The C1b red that blocked two of the three 0.17.0 `--cut` runs: a LIVE smoke was waiting exactly
133
+ as long as pi's lock-stale window, and losing by 148ms.** The cause is measured end to end, and
134
+ none of it is a product regression:
135
+ 1. pi guards `auth.json` AND `models-store.json` with `proper-lockfile` and reads through that
136
+ lock on every boot (`dist/core/auth-storage.js`), retrying a held lock for `staleMs = 30_000`
137
+ before taking it over.
138
+ 2. `terminateChild`'s SIGTERM ends a resident before `proper-lockfile`'s release ever runs, so a
139
+ kill that lands inside the lock window orphans the lock directory. Sweeping 24 kill offsets
140
+ across a boot reproduced it once, at +375ms.
141
+ 3. With `~/.pi/agent/models-store.json.lock` orphaned, the next boot → V3 record measured
142
+ **30_148ms** — and **1_114ms** immediately afterwards, once the stale takeover had cleared it.
143
+ 4. `smoke-entwurf-v2-matrix-live`'s `BOOT_TIMEOUT_MS` was `30_000`: it stopped looking 148ms
144
+ before C1b's record landed. Hence the exact signature the blocked cuts left — empty stderr, no
145
+ record in any store, and (confirmed in both runs from the host's own process-audit trail) a
146
+ child that was alive for the entire 30s and was killed by the smoke at +30s.
147
+ The control was in the same three runs: `smoke-entwurf-chain-live` does the same two-resident
148
+ dance with `BOOT_TIMEOUT_MS = 45_000` and passed 3/3 while matrix-live failed 2/3.
149
+ - **The bound is now shared and carries its receipt.** `PI_BOOT_TIMEOUT_MS = 45_000` lives in
150
+ `scripts/lib/pi-record-discovery.ts` next to the measurements above, and the five smokes that
151
+ sat on the 30s cliff (`smoke-entwurf-v2-matrix-live`, `smoke-acp-socket-citizen-live`,
152
+ `smoke-acp-bundled-mcp-live`, `smoke-acp-v2-send-live`, `smoke-acp-cortex-live`) now derive
153
+ from it. 30s was the single worst value available: it expires *inside* the takeover.
154
+ `smoke-resident-garden-guard` already used 90s and needed no change.
155
+ - **A boot overrun now names the lock.** `describePiLockResidue()` reports which pi locks are held
156
+ at failure time — read-only, because a live holder and an orphan look identical from outside and
157
+ only pi's own stale protocol may arbitrate them.
158
+ - **Both blocked smokes now say WHICH failure they hit**, which is what made the cause findable.
159
+ `smoke-entwurf-v2-matrix-live` watches each resident (pid, exit code/signal and how many ms in, a
160
+ signal-0 liveness probe taken in the catch before the reaper runs, and a **per-child** stderr tail
161
+ — the old single shared buffer could not say whether C1 or C1b spoke; an empty tail now prints as
162
+ `(empty)` rather than being skipped). `smoke-mux-lifecycle-live` attaches window forensics when a
163
+ nonce callback never comes — pane-pid liveness, `list-panes`, and the last 40 lines of
164
+ `capture-pane`, for both cells. Its launch receipt already tells a human "the window is visible
165
+ and can be read directly"; on a headless gate nobody is there and the window is torn down seconds
166
+ later. Every diagnostic step is best-effort so it can never become the failure.
167
+
168
+ The mux pi-native nonce timeout (the second blocked cut) is NOT this bug and stays open: 300s is
169
+ ten times the stale window, and the codex rail was healthy in that same run (`chain-live` and
170
+ `smoke-omp-fresh-live` both passed on it). The forensics above are what the next occurrence will
171
+ answer with.
172
+
173
+ ### Verification
174
+
175
+ - `pnpm run check:toolchain` (biome + `tsc` ×3) — green.
176
+ - **The repair measured against the exact failing condition.** With `models-store.json.lock`
177
+ planted as an orphan, `LIVE=1 ./run.sh smoke-entwurf-v2-matrix-live` is **17/17 PASS in 34s** —
178
+ the stale wait absorbed. The same condition measured 30_148ms to birth, i.e. red under the old
179
+ 30_000 bound.
180
+ - **Boot cost, undisturbed**: 1008–1212ms across 80 consecutive boots in the smokes' spawn shape
181
+ (5.1–5.4s under 4× CPU oversubscription). The bound is not sized for boot cost; it is sized to
182
+ clear pi's stale window.
183
+ - **The new matrix-live diagnostic exercised on a real failure path**, at 0 model tokens, by
184
+ pointing the smoke at a bogus provider: `resident C1: pid=… EXITED code=1 signal=null at +1057ms
185
+ — it was gone before the wait ended`, with that child's own stderr beneath it.
186
+ - `LIVE=1 ./run.sh smoke-entwurf-v2-matrix-live` — **17/17 PASS** on the real
187
+ `openai-codex/gpt-5.6-luna` target (undisturbed run).
188
+ - `LIVE=1 ./run.sh smoke-mux-lifecycle-live` — **81 checks passed, exit 0** (real model turns on
189
+ both pi cells and the claude-code cell).
190
+ - 15 iterations of the gate's own C1b neighbourhood (`smoke-resident-garden-guard` → `check-bridge`
191
+ → `doctor-pi-provider` → `smoke-entwurf-v2-matrix-live`) reproduced nothing — recorded because it
192
+ is what ruled out host load and prior-smoke residue and sent the search to pi's lock.
193
+ - **`LIVE=1 ./run.sh release-gate /tmp/entwurf-release-gate-0.17.1.hb5Q5j --cut`** — **MUST
194
+ PASS=23 FAIL=0 SKIP=0**, **BEHAVIOR PASS=1 FAIL=0 SKIP=0**, exit 0, `cut: OK`. Run on `665191d`,
195
+ 2026-09-03 09:46:37 → 10:35:42 KST, with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u
196
+ PI_AGENT_ID`. Log: `/tmp/entwurf-release-gate-0.17.1.hb5Q5j/release-gate.log`. This carried
197
+ `pnpm run check:full` (451s) and `./run.sh check-gate-qualification` (**347/347 KILLED**) as MUST
198
+ steps. **This is the green `--cut` 0.17.0 never got**: both cells that blocked it — C1b in
199
+ `smoke-entwurf-v2-matrix-live` and the pi-native nonce in `smoke-mux-lifecycle-live` — passed
200
+ here, on the same host, in one run.
201
+ - **Exact-SHA CI on `665191d`** — `check` (34m20s, carrying `check:full` and
202
+ `check-gate-qualification`), `install-surface`, `artifact-consumer`, all `success`.
203
+ Run: https://github.com/junghan0611/entwurf/actions/runs/33697821117
204
+
205
+ ## 0.17.0 - 2026-09-02
206
+
207
+ ### Verification
208
+
209
+ Each receipt carries its own scope; none of them is transferable to another commit or host.
210
+
211
+ - **`LIVE=1 ./run.sh release-gate <scratch> --cut`** — **MUST PASS=23 FAIL=0 SKIP=0**,
212
+ BEHAVIOR PASS=1 FAIL=0 SKIP=0, exit 0, `cut: OK`. Run on commit `0379764`, tracked tree
213
+ `cb1a3dc69b521909f0fe6f956e5c0fa36e885d5955f91271dd8e63d30cd918fb`, 2026-09-02
214
+ 20:14:51 → 21:04:41 KST. Frozen log: `/tmp/entwurf-0.17.0-evidence/ACCEPTED-cut-release-gate.log`.
215
+ This run carried `pnpm run check:full` and `./run.sh check-gate-qualification`
216
+ (**346/346 KILLED**) as MUST steps. **It is not the 0.17.0 acceptance SHA:** `9479750`
217
+ changed source after it.
218
+ `CHANGELOG.md` is in `package.json` `files` and `check:full → check:package → check-pack`
219
+ enumerates it (`npm pack --dry-run`, required/forbidden names — not a cardinality lock).
220
+ `NEXT.md` is not in `files`. Working-tree cleanliness at cut start was an operator
221
+ observation, not a line in the gate log.
222
+ - **Four reds along the way, all real, none smoothed.** A formatter rejection of the new mutant
223
+ manifest; a doc-floor gate catching the ROADMAP ledger entry quoting the ADAPTER's
224
+ `engines.node ">=22"` as though it were entwurf's own; a qualification run that aborted RED on its
225
+ own self-fence because the worktree was edited mid-run — kept in the record because a
226
+ qualification predating the final bytes is not a receipt, and the fence is what says so; and
227
+ `check-bridge-delivery` failing "artifact is not stale" after the review amendment edited three
228
+ `.ts` files without rebuilding the MCP bridge. That last one is why the tree hash above is quoted
229
+ twice: `mcp/entwurf-bridge/dist/` is gitignored, so the rebuild moved no candidate byte, and the
230
+ two receipts really do cover the same tree. None of the four would have been caught by typecheck
231
+ plus the focused gate.
232
+ - **The first `--cut` attempt was RED — MUST PASS=18 FAIL=5 SKIP=0 — and every one of the five was
233
+ the operator's own environment, not this release.** Recorded because the failure wore a
234
+ convincing disguise: it read as a broken host. `static` was the stale bridge artifact above,
235
+ re-created when a `git checkout`/`merge` bumped every tracked `.ts` mtime past the built `dist`.
236
+ The other four came from running the gate **inside a live entwurf ACP Claude session**, which
237
+ exports two carriers into every child it spawns. `CLAUDE_CONFIG_DIR` points at the ACP overlay,
238
+ whose `settings.json` is `hooks: {}` by design — so the Claude Code children the live smokes
239
+ launch are born with no entwurf SessionStart hook and mint no meta-record, failing
240
+ `smoke-claude-native-resume-live`, `smoke-entwurf-chain-live` and `smoke-mux-lifecycle-live` on
241
+ citizen birth. The same variable made `doctor-meta-bridge` report `installed: absent` and
242
+ `claude mcp list` deny `entwurf-bridge`; with it unset the doctor reads
243
+ `installed: v3 (229fef123589)` matching source and assembled exactly. `PI_SESSION_ID` did the
244
+ analogous damage on the omp axis: `smoke-omp-fresh-live` reported a 240s nonce-callback timeout,
245
+ but the sibling was alive — it was born at 19:58:21 KST and called back at 19:58:28 to the
246
+ AMBIENT caller id rather than the caller the smoke had minted, so the smoke watched the right
247
+ mailbox and saw nothing. That smoke's own source anticipates this exact disguise
248
+ (`scripts/smoke-omp-fresh-live.ts:186`, "the failure would masquerade as a silent sibling").
249
+ Re-run with `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID`, all five turned green and
250
+ nothing else changed. That gap — VERIFY.md named `PWD` in scratch but not the carriers — is what
251
+ cost the first cut its 52 minutes; VERIFY.md and AGENTS.md now name the strip.
252
+ - **Qualification lane size.** On the `0379764` cut the total was **335 → 346**, eleven new claims
253
+ in `scripts/mutants/acp-usage-accounting.json` (absent on the 0.16.1 base). `9479750` adds a
254
+ twelfth, `ACP-REBILL-MAIN-LOOP-SCOPE`. Standalone `./run.sh check-gate-qualification` on
255
+ `fee89d3` is **347/347 KILLED** (36m19s, log `/home/junghan/.pi/background/1788355846036-bg03.log`).
256
+ That is a qualification receipt, not a `--cut` receipt.
257
+ - **Three recuts on the D1 tree, all `cut: BLOCKED`.** Each ran
258
+ `env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID LIVE=1 ./run.sh release-gate <scratch> --cut`.
259
+ All three: MUST PASS=22 FAIL=1 SKIP=0, BEHAVIOR PASS=1. None of the FAILs is D1.
260
+ 1. `/tmp/entwurf-cut-0.17.0.WdTkH7` — `smoke-entwurf-v2-matrix-live` C1b: second
261
+ `pi --entwurf-control` wrote no hidden-store record in 30s, stderr empty. Isolated re-run:
262
+ 17/17 PASS.
263
+ 2. `/tmp/entwurf-cut2-0.17.0.XQcOuR` — `smoke-mux-lifecycle-live` pi-native nonce
264
+ `mux-fresh-call-4bd6391e9783fb24fb1ee74a` never arrived in 300s (claude-code cell passed;
265
+ v2-matrix passed this run). Same shape as the 0.16.1 mux-lifecycle retry.
266
+ 3. `/tmp/entwurf-cut3-0.17.0.sfZ8WC` — C1b 30s again. Isolated re-run: 17/17 PASS.
267
+ mux-lifecycle PASS this run.
268
+ GLG 2026-09-03: no fourth cut. Make proceeds with this gap named here, not rounded up to
269
+ `cut: OK`.
270
+ - **`smoke-acp-raw-turn-live`** — PASS on the moved pin, quoted from the accepted `--cut` run's own
271
+ output rather than from a session message: launch source
272
+ `package:@agentclientprotocol/claude-agent-acp` (not PATH fallback), `protocolVersion=1`, model
273
+ `claude-sonnet-5`, `stopReason=end_turn`, reply `"OK"`, 62,998 bytes NDJSON. This is the dep-bump
274
+ track's own named lock, not a substitute for it. An earlier standalone run of the same smoke
275
+ reported 58,189 bytes and another 58,178; the byte count is per-run and is not a fixed
276
+ fingerprint, which is precisely why the number cited here is the one the gate printed.
277
+ - **Recurrence corpus measurement** — cited from the in-source comment at
278
+ `pi-extensions/lib/acp/backend.ts` (`priorTurnInputOutputSum`): 2,398 of 2,410 adjacent pairs
279
+ (99.50%), 2026-05 → 2026-09. The raw pair inventory is not in the frozen receipts; treat it as
280
+ an operator observation recorded in source, not as a cut receipt.
281
+
282
+ ### Changed
283
+
284
+ - **ACTION REQUIRED — ACP accounting consumers must read `usage.acp`.** Claude ACP
285
+ `PromptResponse.usage` is the sum across a turn's API round trips, not one request's
286
+ prompt shape. The turn's accounting totals now travel at
287
+ `usage.acp.{input,output,cacheRead,cacheWrite}`; pi's four request-shaped fields remain
288
+ zero, while `usage.totalTokens` remains the vendor's context-occupancy reading. Consumers
289
+ that display ACP token/cache accounting must read the new key — `agent-config`'s
290
+ `pi-extensions/glg-footer.ts` does so at `47b9b95`. The numerator is taken from the
291
+ vendor's ACCOUNTING-GRADE `_meta.quota.model_usage` rows (summed) in preference to the
292
+ main-loop-only `PromptResponse.usage`, because the cost denominator is an adjacent diff of
293
+ the backend's running total and already has that wider scope. Deterministic receipts:
294
+ `scripts/check-acp-usage-accounting.ts` CELLs 1/1b drive pi's real overflow and context
295
+ readers with the incident-scale 4,185,084 cache-read aggregate on a 223,516-token context,
296
+ and CELL 1e drives both token carriers at once so a silent fallback to the narrow one
297
+ cannot pass for a preference.
298
+ - **ACTION REQUIRED — the certified Claude ACP dependency coordinates move together, and
299
+ this release REQUIRES them.** The bundled Claude adapter is
300
+ `@agentclientprotocol/claude-agent-acp` **0.70.0 → 0.73.0**, its wire SDK is
301
+ `@agentclientprotocol/sdk` **1.3.0 → 1.4.0**, and the resolved transitive
302
+ `@anthropic-ai/claude-agent-sdk` is **0.3.232 → 0.3.257**. This is not a refresh riding
303
+ along with the fix: `_meta.quota.model_usage` does not exist before adapter 0.71.0 (added
304
+ by upstream `fad4d10`, "report per-model token usage on prompt responses"), so the
305
+ accounting above has no accounting-grade numerator without this bump. Three adapter minors
306
+ are folded into one certification; the per-bump measurement — declared-dependency deltas
307
+ per tag, the zod floor narrowing at 0.71.0, lock peer-resolution, the reachable/unreachable
308
+ split of the new surface, and the re-measured MCP readiness boundary — is recorded in the
309
+ ROADMAP "Dep bump(별도 트랙)" ledger. Re-run the ACP support gates on any locally
310
+ overridden adapter command before treating that command as covered by these coordinates.
311
+
312
+ ### Fixed
313
+
314
+ - **Claude ACP no longer presents a turn aggregate as one request to pi.** That projection
315
+ caused pi's raw overflow reader to compact a live 223,516-token session under a
316
+ 1,000,000-token window, invented two phantom cache misses, and silenced the one real miss
317
+ after a 401-minute idle gap. The aggregate is retained as accounting evidence, context
318
+ occupancy remains separate and is carried forward across a turn that reports none, and a
319
+ material re-billed prefix is reported as a PROVEN LOWER BOUND rather than silently hidden.
320
+ Turn cost remains the adjacent difference of the SDK's cumulative estimate, never a local
321
+ reprice. A backend with no measured semantics (cortex) is still sealed not at all.
322
+ - **The re-billed-prefix bound no longer mixes main-context occupancy with wide accounting IO.**
323
+ `readTurnAccounting` prefers `_meta.quota.model_usage`, whose rows also count Task subagents,
324
+ sidechains, and internal compaction. Feeding that `cacheWrite` into a bound whose occupancy is
325
+ main-context inflates the lower bound through both remaining terms, so a warm main prefix can
326
+ announce a miss and attach this turn's dollar figure to it. The bound and its stored prior now
327
+ read `PromptResponse.usage` (main loop) only; `usage.acp` stays the wide totals. Deterministic
328
+ oracle: `scripts/check-acp-usage-accounting.ts` CELL 1f.
329
+ - **A backwards session total no longer asserts a cause it has not measured.** The operator
330
+ diagnostic for a decreasing cumulative cost previously named a conversation reset as "the
331
+ known cause". The adapter's `conversation_reset` handler only switches to a fresh
332
+ conversation and touches no cost (0.73.0 `dist/acp-agent.js:3675-3682`); the documented
333
+ mechanism lives in claude-agent-sdk instead, which states that a mid-session `/clear`
334
+ resets the running total (`sdk.d.ts:4884`). The notice now reports the observation, names
335
+ `/clear` in the vendor's own word rather than paraphrasing it into a different noun, states
336
+ explicitly that the adapter's `conversation_reset` event is NOT that mechanism, and says plainly
337
+ that the cause is not measured here. Naming the disproven event as the documented cause was the
338
+ release's own thesis being violated inside its own diagnostic; it was caught in review.
339
+
7
340
  ## 0.16.1 - 2026-09-01
8
341
 
9
342
  ### Verification
package/VERIFY.md CHANGED
@@ -165,8 +165,15 @@ cd "$REPO_DIR" && ./run.sh setup "$PROJECT_DIR"
165
165
 
166
166
  ### 1.2 Live acceptance (optional)
167
167
 
168
+ Put `PWD` in scratch so session artifacts do not land in the repo. LIVE gates inherit
169
+ the caller's environment: a pi or ACP session exports `PI_SESSION_ID` / `PI_AGENT_ID` /
170
+ `CLAUDE_CONFIG_DIR` into every child, which masquerades as the smoke's identity or
171
+ strips Claude hooks (`hooks: {}` overlay). Strip those carriers:
172
+
168
173
  ```bash
169
- LIVE=1 ./run.sh release-gate /path/to/consumer-project --cut
174
+ cd /path/to/scratch
175
+ env -u CLAUDE_CONFIG_DIR -u PI_SESSION_ID -u PI_AGENT_ID \
176
+ LIVE=1 /path/to/entwurf/run.sh release-gate /path/to/scratch --cut
170
177
  pi --provider entwurf --model claude-sonnet-5 -p "reply with ok only" # one-turn smoke
171
178
  ```
172
179
 
@@ -92,11 +92,11 @@ undifferentiated "supported" column is what let a Claude PASS read as if it also
92
92
 
93
93
  | Surface | Declaration | Class | What a green actually says |
94
94
  |---|---|---|---|
95
- | Entwurf package | `0.15.1` | shipped baseline | the package contract these rows belong to |
96
- | pi runtime | devDep exact `0.84.3`, peer `>=0.84.3 <0.85` | **exact** oracle + **closed range** | built and certified against 0.84.3; hosts inside the range are accepted, and the ceiling moves only on measurement |
97
- | ACP wire SDK | `@agentclientprotocol/sdk 1.3.0` | **exact** | the shared wire oracle both adapters speak |
98
- | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.70.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
99
- | Claude Agent SDK | `0.3.232` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
95
+ | Entwurf package | `0.17.0` | shipped baseline | the package contract these rows belong to |
96
+ | pi runtime | devDep exact `0.84.4`, peer `>=0.84.4 <0.85` | **exact** oracle + **closed range** | built and certified against 0.84.4; hosts inside the range are accepted, and the ceiling moves only on measurement |
97
+ | ACP wire SDK | `@agentclientprotocol/sdk 1.4.0` | **exact** | the shared wire oracle both adapters speak |
98
+ | Claude ACP adapter | `@agentclientprotocol/claude-agent-acp 0.73.0` | **exact**, bundled | the adapter we ship and certify; resolved before any PATH fallback |
99
+ | Claude Agent SDK | `0.3.257` (transitive) | **exact** oracle | the runtime risk surface behind the adapter |
100
100
  | Anthropic SDK | `0.100.1` | **exact**, peer-resolution only | satisfies the Agent SDK peer floor (0.93.0+); never an API client here (gate L4) |
101
101
  | Claude Code runtime | `>=2.1.217` (`entwurf.claudeCodeFloor`) | **floor** | below it, hook args are silently dropped; entwurf enforces this itself |
102
102
  | Node | `>=24` (`engines.node`) | **floor** | single axis, derived everywhere else |
@@ -141,8 +141,10 @@ different reasons, and collapsing them would hide a real risk**:
141
141
  - **Capability-gated.** AIR typed session failures, the 0.69.0 AIR file-change report, terminal
142
142
  output widgets and nested subagent transcripts each test a client capability entwurf does not
143
143
  send, so the adapter itself keeps the legacy path.
144
- - **Advertised but never called.** Some surfaces carry no capability prerequisite at all — 0.70.0's
145
- `providers/list` / `providers/set` / `providers/disable` are advertised unconditionally. They are
144
+ - **Advertised but never called.** Some surfaces carry no capability prerequisite at all — the
145
+ `providers/list` / `providers/set` / `providers/disable` trio added in 0.70.0 is advertised
146
+ unconditionally, and 0.71.0–0.73.0 add native subagents, async tasks, message-specific session
147
+ forks, AI-generated session titles and permission-mode kinds on the same footing. They are
146
148
  unreachable only because the common loop never invokes them (nor `logout`). Nothing upstream
147
149
  enforces that; it is our own call-site discipline, and it stops holding the moment we use one.
148
150
 
@@ -255,13 +257,22 @@ caller-session `_meta`, and cross-machine certification.
255
257
 
256
258
  A backend can return `newSession` before its declared MCP server is callable. This was
257
259
  observed intermittently on the Claude rail and directly on Cortex's private `mcp.json`
258
- path. Neither `claude-agent-acp` 0.70.0 nor the Cortex landing adds a client-side
259
- readiness fence, and `mcpServerStatus()` is not called by the common loop.
260
- (Re-measured at the 0.68.0 → 0.70.0 bump: AIR typed failures and the 0.69.0 AIR
261
- file-change report are both capability-gated and unadvertised by entwurf; 0.70.0's
262
- `providers/set` / `providers/disable` are advertised unconditionally by the adapter but
263
- entwurf calls neither, so no session's provider state is transitioned; goal extension is
264
- still not a fence. This bump changes no readiness behavior and closes no part of #72.)
260
+ path. Neither `claude-agent-acp` 0.73.0 nor the Cortex landing adds a client-side
261
+ readiness fence over a session's declared MCP servers, and entwurf's common loop
262
+ calls `mcpServerStatus()` nowhere.
263
+ (Re-measured at the 0.70.0 0.73.0 bump, not inherited from the previous one.
264
+ `mcpServerStatus` call sites in `src/acp-agent.ts` went 0 2, new in 0.71.0 via
265
+ `0cbbaf3` (MCP OAuth, LLM-25012) — so the ADAPTER now calls it where it previously
266
+ did not. Both sites were read at `v0.73.0 src/acp-agent.ts:1618` and `:1711`: each is
267
+ gated behind `supportsMcpOAuth(query)`, and the polling one waits only on a server
268
+ that already reported `needs-auth`, never on every declared server before
269
+ `newSession` returns. That is an auth handshake, not a readiness fence, so the
270
+ boundary below is unchanged. The other reachable-surface findings also re-measured:
271
+ AIR typed failures and the AIR file-change report stay capability-gated and
272
+ unadvertised by entwurf; `providers/set` / `providers/disable` stay advertised
273
+ unconditionally and uncalled; native subagents, async tasks, session forks, session
274
+ titles, permission-mode kinds and clear-context planning are all new-but-uncalled.
275
+ This bump changes no readiness behavior and closes no part of #72.)
265
276
 
266
277
  ### 11-7-a/b. Instrument and first measurement
267
278
 
@@ -16,7 +16,7 @@
16
16
  // This module owns the `AcpConnectionLike` seam so the backend and the live
17
17
  // smokes both drive ONE adapter — the SDK method-name mapping lives here only,
18
18
  // and the backend's orchestration + the gate fakes stay untouched.
19
- import { AGENT_METHODS, CLIENT_METHODS, client } from "@agentclientprotocol/sdk";
19
+ import { AGENT_METHODS, CLIENT_METHODS, client, } from "@agentclientprotocol/sdk";
20
20
  /**
21
21
  * Production factory — wrap the SDK 1.1 fluent `client()` into the
22
22
  * `AcpConnectionLike` seam the backend (and the live smokes) drive.
@@ -121,6 +121,13 @@ export const claudeAdapter = {
121
121
  // `_adapterSettings` here.
122
122
  return {};
123
123
  },
124
+ // MEASURED (2026-09-02, #93): claude-agent-acp's `usage_update` carries a
125
+ // session-cumulative ESTIMATED cost (`cost.amount = message.total_cost_usd`)
126
+ // and a context-occupancy scalar (`used`; both read at 0.73.0
127
+ // `dist/acp-agent.js:2918-2924`). Both are measured, so backend.ts seals them.
128
+ // The token partition is NOT declared here: ACP's only token carrier is a
129
+ // per-turn round-trip aggregate, which is not what pi's four fields mean.
130
+ sealsTurnAccounting: true,
124
131
  };
125
132
  // ---------------------------------------------------------------------------
126
133
  // Registry — modelId → adapter
@@ -259,6 +266,14 @@ export const cortexAdapter = {
259
266
  const settings = adapterSettings;
260
267
  return { cortexConnection: settings?.cortexConnection ?? null };
261
268
  },
269
+ // NO sealsTurnAccounting — a DELIBERATE, permanent absence, not an omission to be
270
+ // filled in later by symmetry with claude. Nobody has measured what cortex's
271
+ // ACP `usage` (and its usage_update cost, if it sends one) MEAN: whether the
272
+ // token counts are a turn delta or a session total, and against which price
273
+ // table. Until that measurement exists, the honest report is no report:
274
+ // backend.ts seals nothing for a backend without this flag, so cortex's
275
+ // emitted usage is byte-identical to what it was before #93. Declaring this
276
+ // with a guess would mint exactly the silent misaccounting #93 exists to end.
262
277
  };
263
278
  const ADAPTERS = [claudeAdapter, cortexAdapter];
264
279
  /**
@@ -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) {