@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.
- package/CHANGELOG.md +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +18 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +23 -7
- package/package.json +1 -1
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
- package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
- package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
- package/pi-extensions/lib/entwurf-v2-surface.ts +18 -3
- package/pi-extensions/lib/meta-session.ts +32 -7
- package/scripts/check-entwurf-v2-mailbox.ts +33 -0
- package/scripts/check-entwurf-v2-runner.ts +18 -0
- package/scripts/check-entwurf-v2-send.ts +13 -1
- package/scripts/check-entwurf-v2-surface.ts +75 -3
- package/scripts/check-hook-launch-topology.ts +70 -1
- package/scripts/check-mailbox-receipt-state.ts +6 -0
- package/scripts/check-meta-doctor-oracle.sh +73 -0
- package/scripts/check-meta-mailbox-state-write.ts +9 -2
- package/scripts/check-meta-manifest-schema.py +19 -1
- package/scripts/check-meta-session.ts +10 -2
- package/scripts/meta-bridge-doctor.sh +18 -3
- package/scripts/meta-bridge-state.py +23 -5
- package/scripts/meta-bridge-statusline.sh +65 -2
- package/scripts/raw-async-delivery/README.md +280 -9
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
- package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
- package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
- package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
- package/scripts/smoke-meta-async-drift.sh +31 -8
- package/scripts/smoke-meta-install-state.sh +170 -11
- 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: {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2081
|
-
*
|
|
2082
|
-
*
|
|
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
|
|
2105
|
-
// Stamp lastReadAt — the one
|
|
2106
|
-
// reached the reader). lastDeliveredAt is
|
|
2107
|
-
//
|
|
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
|
@@ -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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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: {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2536
|
-
*
|
|
2537
|
-
*
|
|
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
|
|
2564
|
-
// Stamp lastReadAt — the one
|
|
2565
|
-
// reached the reader). lastDeliveredAt is
|
|
2566
|
-
//
|
|
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;
|