@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
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """mailbox-watch.py — P4 prototype (issue #98 option E): the out-of-harness
3
+ observation window.
4
+
5
+ WHY THIS EXISTS
6
+ Mail delivered through the META-MAILBOX rail drops the SAME artifact into
7
+ ~/.pi/agent/meta-mailbox/<garden-id>/: a `<stamp>.msg` whose body carries a
8
+ human-shaped envelope (from / session / at / wants reply). The doorbell then
9
+ renames it `.msg.delivered`, and `entwurf_inbox_read` archives it
10
+ `.msg.delivered.read`. Those three suffixes are the per-message truth --
11
+ `state.json` only ever holds a garden-wide "last activity" slot, which is why
12
+ it cannot serve as a per-message receipt.
13
+
14
+ So a single watcher on that directory renders every mailbox-rail message in
15
+ one place, with zero changes to any delivery contract. That is what this
16
+ prints.
17
+
18
+ SCOPE -- read this before believing the output is complete
19
+ This watches ONE directory tree (the given root, `~/.pi/agent/meta-mailbox`
20
+ by default) and prints a line only when a message file's suffix actually
21
+ changes there. That is the whole of what it observes.
22
+
23
+ So it sees a delivery only when the dispatcher chose the META-MAILBOX plan
24
+ and that plan wrote a file. A send that went over a control socket, a send
25
+ that was injected straight into a live conversation, and a send that was
26
+ REJECTED as undeliverable all leave nothing here -- there is no file to
27
+ change. Silence in this window therefore means "no mailbox-rail file
28
+ activity under this root", never "no traffic on the garden".
29
+
30
+ An earlier version of this docstring claimed the opposite ("every rail
31
+ (pi / codex / agy / Claude Code)", "EVERY sibling's traffic"), corrected
32
+ 2026-09-03. It is deliberately NOT replaced with a per-backend table: which
33
+ backend takes which plan is the dispatcher's to say, it moves when a backend
34
+ is admitted, and a copy of it here would be the next sentence to rot. The
35
+ authoritative routing lives in pi-extensions/lib/entwurf-v2-decider.ts and
36
+ the domain sets it reads from entwurf-v2-contract.ts. Read those, not this.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import ctypes
42
+ import ctypes.util
43
+ import datetime
44
+ import errno
45
+ import os
46
+ import struct
47
+ import sys
48
+ from pathlib import Path
49
+
50
+ IN_CREATE = 0x00000100
51
+ IN_CLOSE_WRITE = 0x00000008
52
+ IN_MOVED_FROM = 0x00000040
53
+ IN_MOVED_TO = 0x00000080
54
+ IN_Q_OVERFLOW = 0x00004000
55
+ IN_ISDIR = 0x40000000
56
+
57
+ # The doorbell's `mv m m.delivered` is a rename WITHIN one directory, so it emits a
58
+ # MOVED_FROM/MOVED_TO pair, not a CREATE. Watching create+close_write alone would
59
+ # miss every delivery transition -- the reason the issue's event list was widened.
60
+ WATCH_MASK = IN_CREATE | IN_CLOSE_WRITE | IN_MOVED_FROM | IN_MOVED_TO
61
+
62
+ EVENT_HDR = struct.Struct("iIII") # wd, mask, cookie, len
63
+
64
+ DEFAULT_ROOT = Path.home() / ".pi" / "agent" / "meta-mailbox"
65
+
66
+
67
+ def _libc() -> ctypes.CDLL:
68
+ name = ctypes.util.find_library("c") or "libc.so.6"
69
+ libc = ctypes.CDLL(name, use_errno=True)
70
+ libc.inotify_init1.argtypes = [ctypes.c_int]
71
+ libc.inotify_init1.restype = ctypes.c_int
72
+ libc.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
73
+ libc.inotify_add_watch.restype = ctypes.c_int
74
+ return libc
75
+
76
+
77
+ def envelope(path: Path) -> tuple[str, str]:
78
+ """(sender, first body line) from a mailbox message.
79
+
80
+ The envelope is the human-shaped header the mailbox writer emits; the body
81
+ follows a horizontal rule. Unreadable/renamed-away files yield placeholders --
82
+ a watcher must never crash on a file that moved under it.
83
+ """
84
+ try:
85
+ text = path.read_text(encoding="utf-8", errors="replace")
86
+ except OSError:
87
+ return ("?", "(unreadable)")
88
+ sender, body_started, first = "?", False, ""
89
+ for line in text.splitlines():
90
+ if not body_started:
91
+ stripped = line.strip()
92
+ if stripped.startswith("from:"):
93
+ sender = stripped[len("from:") :].strip()
94
+ elif stripped.startswith("session:"):
95
+ # The garden id is the reply address; prefer it over the backend label.
96
+ sender = stripped[len("session:") :].strip().split()[0] or sender
97
+ elif set(stripped) == {"─"}:
98
+ body_started = True
99
+ continue
100
+ if line.strip():
101
+ first = line.strip()
102
+ break
103
+ return (sender, first or "(empty body)")
104
+
105
+
106
+ def state_of(name: str) -> str | None:
107
+ """Map a filename to the per-message state its suffix encodes."""
108
+ if name.endswith(".msg.delivered.read"):
109
+ return "READ"
110
+ if name.endswith(".msg.delivered"):
111
+ return "RUNG"
112
+ if name.endswith(".msg"):
113
+ return "ARRIVED"
114
+ return None
115
+
116
+
117
+ def main(argv: list[str]) -> int:
118
+ root = Path(argv[1]).expanduser() if len(argv) > 1 else DEFAULT_ROOT
119
+ if not root.is_dir():
120
+ print(f"mailbox root not found: {root}", file=sys.stderr)
121
+ return 2
122
+
123
+ libc = _libc()
124
+ fd = libc.inotify_init1(0)
125
+ if fd < 0:
126
+ print(f"inotify_init1 failed: {os.strerror(ctypes.get_errno())}", file=sys.stderr)
127
+ return 1
128
+
129
+ wd_dir: dict[int, Path] = {}
130
+ # Names already reported by a directory sweep, so the sweep and the live event
131
+ # for the same file do not print it twice.
132
+ seen: set[str] = set()
133
+
134
+ def report(gid: str, state: str, path: Path) -> None:
135
+ sender, first = envelope(path)
136
+ stamp = datetime.datetime.now().strftime("%H:%M:%S")
137
+ print(f"{stamp} {gid:24} {state:9} {sender} -> {first[:80]}", flush=True)
138
+
139
+ def watch(d: Path, *, sweep: bool = False) -> None:
140
+ wd = libc.inotify_add_watch(fd, str(d).encode(), WATCH_MASK)
141
+ if wd < 0:
142
+ err = ctypes.get_errno()
143
+ # ENOSPC is the watch-limit ceiling; say so plainly instead of dying quiet.
144
+ hint = " (raise fs.inotify.max_user_watches)" if err == errno.ENOSPC else ""
145
+ print(f" ! cannot watch {d.name}: {os.strerror(err)}{hint}", file=sys.stderr)
146
+ return
147
+ wd_dir[wd] = d
148
+ if not sweep:
149
+ return
150
+ # RACE: a citizen's FIRST message can already be on disk before this watch
151
+ # exists. `enqueueMetaMessage` (meta-session.ts:2484-2489) does
152
+ # mkdirSync(dir) and then writeFileSync(messagePath) with nothing in between
153
+ # -- no tmp+rename -- so the .msg can be fully written between our receiving
154
+ # IN_CREATE for the directory and our adding a watch to it. Its CLOSE_WRITE
155
+ # is then gone forever. MEASURED: without this sweep, a
156
+ # `os.mkdir(d); open(d/'x.msg','w').write(...)` pair produced ZERO output.
157
+ # Sweeping right after the watch is added closes the window: anything the
158
+ # watch missed is still on disk, and anything it caught is deduped below.
159
+ try:
160
+ existing = sorted(p for p in d.iterdir() if state_of(p.name))
161
+ except OSError:
162
+ return
163
+ for p in existing:
164
+ if p.name in seen:
165
+ continue
166
+ seen.add(p.name)
167
+ report(d.name, state_of(p.name) or "?", p)
168
+
169
+ watch(root)
170
+ gids = sorted(p for p in root.iterdir() if p.is_dir())
171
+ for d in gids:
172
+ watch(d)
173
+ print(f"# watching {len(gids)} garden mailboxes under {root}", file=sys.stderr)
174
+ print("# TIME GID STATE SENDER -> first line", file=sys.stderr)
175
+
176
+ try:
177
+ while True:
178
+ buf = os.read(fd, 8192)
179
+ off = 0
180
+ while off < len(buf):
181
+ wd, mask, _cookie, ln = EVENT_HDR.unpack_from(buf, off)
182
+ off += EVENT_HDR.size
183
+ raw = buf[off : off + ln].split(b"\0", 1)[0]
184
+ off += ln
185
+ name = raw.decode("utf-8", "replace")
186
+ # The kernel drops events when the queue fills and reports it as a
187
+ # single wd=-1 event with no name. Saying nothing here would be the
188
+ # exact failure this whole issue is about: traffic that happened and
189
+ # was never shown. Announce the loss instead of swallowing it.
190
+ if mask & IN_Q_OVERFLOW:
191
+ print(
192
+ " ! inotify queue overflow — events were LOST; this window is "
193
+ "incomplete (raise fs.inotify.max_queued_events)",
194
+ file=sys.stderr,
195
+ flush=True,
196
+ )
197
+ continue
198
+ parent = wd_dir.get(wd)
199
+ if parent is None or not name:
200
+ continue
201
+ # A citizen created while we run: watch it AND sweep it, because its
202
+ # first message may already be written (see the race note in watch()).
203
+ if mask & IN_ISDIR and parent == root:
204
+ watch(parent / name, sweep=True)
205
+ continue
206
+ state = state_of(name)
207
+ if state is None:
208
+ continue
209
+ # Print on exactly one event per transition:
210
+ # CLOSE_WRITE — a new .msg whose body is fully flushed. CREATE fires
211
+ # first but the file may still be empty, so printing on CREATE both
212
+ # double-reports (CREATE then CLOSE_WRITE) and can read a half-written
213
+ # envelope. The issue's `-e create` would have hit exactly that.
214
+ # MOVED_TO — the arriving half of the doorbell's in-place rename.
215
+ # MOVED_FROM is the vacating half of the same rename; reporting it too
216
+ # would print every delivery twice under its OLD name.
217
+ if not mask & (IN_CLOSE_WRITE | IN_MOVED_TO):
218
+ continue
219
+ if name in seen:
220
+ seen.discard(name) # the sweep already printed it; let it pass next time
221
+ continue
222
+ report(parent.name, state, parent / name)
223
+ except KeyboardInterrupt:
224
+ return 0
225
+ finally:
226
+ os.close(fd)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main(sys.argv))
@@ -19,7 +19,9 @@
19
19
  "type": "command",
20
20
  "command": "${CLAUDE_PLUGIN_ROOT}/scripts/watch-filechanged.sh",
21
21
  "asyncRewake": true,
22
- "timeout": 20
22
+ "timeout": 20,
23
+ "rewakeSummary": "LAB-P1 entwurf inbox: sibling mail arrived",
24
+ "rewakeMessage": "LAB-P1 entwurf mailbox notice:"
23
25
  }
24
26
  ]
25
27
  }
@@ -7,12 +7,21 @@
7
7
  # subscription session continuation (no `claude -p` spawn).
8
8
  #
9
9
  # ADDRESSED: the changed path arrives on stdin as `file_path`. The mailbox is
10
- # simply its directory (<root>/<session_id>/). So this hook reads ONLY its own
11
- # session's mailbox — no cross-session leakage. Self-contained: it does not even
10
+ # simply its directory (<root>/<session_id>/). Self-contained: it does not even
12
11
  # need session_id, the changed-path dirname IS the per-session mailbox.
13
12
  #
14
- # DOORBELL ONLY: announce "you have mail" + the body path on stderr (the sole
15
- # asyncRewake payload channel; stdout is ignored). Do NOT push imperatives
13
+ # CORRECTED 2026-09-03 (#98 Phase 1): this used to add "so this hook reads ONLY its
14
+ # own session's mailbox no cross-session leakage". The dirname provides no such
15
+ # isolation: this script trusts `file_path` and never checks it against the session.
16
+ # Measured — with the product plugin loaded from user settings, the PRODUCT doorbell
17
+ # processed THIS lab mailbox and raced this hook to `exit 2`. The probe now avoids
18
+ # that ONE coexistence by dropping user settings (`--setting-sources project,local`);
19
+ # any other FileChanged hook in project or local scope would cross-fire the same way.
20
+ # Receipt: README.md "What the probe session actually touches".
21
+ #
22
+ # DOORBELL ONLY: announce "you have mail" + the body path on stderr. Not because
23
+ # stdout is ignored — it is not; the body is `stderr || stdout` (same receipt).
24
+ # stderr is used unconditionally and is never parsed as JSON. Do NOT push imperatives —
16
25
  # strong models flag hook-injected commands as prompt injection. The agent
17
26
  # self-fetches the body with its own trusted tool.
18
27
  set -euo pipefail
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env bash
2
+ # probe-delivery-transparency.sh — issue #98 Phase 1, probes P1 + P2a + P2b.
3
+ #
4
+ # MEASUREMENT ONLY -- but read the next paragraph before believing that phrase.
5
+ #
6
+ # It edits no product FILE: nothing under pi/meta-bridge/**, pi-extensions/**,
7
+ # mcp/** or scripts/meta-bridge-*.sh, and it installs nothing.
8
+ #
9
+ # Isolation from operator STATE took two tries to get right, and both failures are
10
+ # worth carrying:
11
+ #
12
+ # 1. Without `--setting-sources project,local` the session inherits
13
+ # ~/.claude/settings.json, so the PRODUCT meta-bridge plugin loads next to the
14
+ # lab one and its SessionStart hook mints a REAL garden citizen (a record in
15
+ # ~/.pi/agent/meta-sessions/, a mailbox in ~/.pi/agent/meta-mailbox/).
16
+ # MEASURED: three runs left three citizens behind.
17
+ # 2. Worse, the two doorbells DO cross-fire, and an earlier version of this
18
+ # header claimed they did not. `doorbell.sh` takes `dirname(file_path)` as
19
+ # "its" mailbox unconditionally, so the product doorbell happily processed the
20
+ # LAB mailbox and raced the lab hook to exit 2. When the product hook won, the
21
+ # operator got the DEFAULT "Stop hook feedback" -- from a hook that carries no
22
+ # rewakeSummary -- while hooks.json under test was perfectly correct.
23
+ # MEASURED: two consecutive runs failed P1 that way. The transcript proved it:
24
+ # product wording ("[entwurf inbox] ... entwurf_inbox_read ... lastReadAt")
25
+ # pointing at the lab path /tmp/cc-p98-probe/mailbox/<sid>/.
26
+ #
27
+ # So P1 was non-deterministic until the setting sources were cut. Dropping user
28
+ # settings fixes both: no product plugin, no minted citizen, no doorbell race.
29
+ # `cleanup_citizens` stays as a belt-and-braces sweep for anything a run under the
30
+ # old flags left behind; it deletes only records whose cwd matches this probe's
31
+ # throwaway /tmp path, and reports rather than removes a non-empty mailbox.
32
+ #
33
+ # Claude still writes ~/.claude/projects/<cwd>/<sid>.jsonl and
34
+ # ~/.claude/sessions/<pid>.json of its own accord -- receipt (ii) below IS one of
35
+ # those files. That is Claude's own bookkeeping and is left alone.
36
+ #
37
+ # WHAT EACH PROBE DECIDES
38
+ # P1 hooks.json `rewakeSummary` / `rewakeMessage` on an asyncRewake hook:
39
+ # does the operator's row stop saying "Stop hook feedback", and does the
40
+ # model stop being told "Stop hook blocking error"? Binary reads say both
41
+ # fields are ungated for a local plugin (only the stdout-JSON rewakeSummary
42
+ # is first-party gated). This is the live confirmation, and the receipt
43
+ # that retires README lesson #4.
44
+ # P2a Does Claude RE-EXECUTE the statusline command on the turn an asyncRewake
45
+ # doorbell creates? If not, a statusline unread badge cannot be the primary
46
+ # surface.
47
+ # P2b Does it re-execute again after a MID-TURN tool call drains the mailbox?
48
+ # If not, the badge would keep showing unread after the model already read
49
+ # -- the badge would lie in the direction that matters, so option B would
50
+ # WEAKEN the doorbell-rang-vs-model-read distinction instead of showing it.
51
+ #
52
+ # P2a/P2b need no badge on screen: lab-statusline.sh logs one line per
53
+ # invocation with the count it observed, so the log alone answers both.
54
+ #
55
+ # COST: one interactive subscription session; the wake itself is a continuation,
56
+ # not a `claude -p` spawn.
57
+ # USAGE: ./probe-delivery-transparency.sh [keep] (`keep` leaves tmux alive)
58
+ set -euo pipefail
59
+
60
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
61
+ PLUGIN="$HERE/plugin-entwurf-receive"
62
+ T="${CC_PROBE_DIR:-/tmp/cc-p98-probe}"
63
+ ROOT="$T/mailbox"
64
+ CWD="$T/cwd"
65
+ SLOG="$T/statusline.log"
66
+ SESSION="ccp98"
67
+ KEEP="${1:-}"
68
+
69
+ pass=0 fail=0
70
+ ok() {
71
+ echo " PASS: $*"
72
+ pass=$((pass + 1))
73
+ }
74
+ bad() {
75
+ echo " FAIL: $*"
76
+ fail=$((fail + 1))
77
+ }
78
+
79
+ rm -rf "$T"
80
+ mkdir -p "$ROOT" "$CWD/.claude"
81
+ : >"$SLOG"
82
+
83
+ # The lab statusline is wired through the PROJECT settings of a throwaway /tmp
84
+ # cwd, so it overrides the operator's statusline for THIS session only -- no file
85
+ # in ~/.claude is edited. (Claude still WRITES session/transcript files there of
86
+ # its own accord; see the header.)
87
+ cat >"$CWD/.claude/settings.json" <<JSON
88
+ {
89
+ "statusLine": {
90
+ "type": "command",
91
+ "command": "$HERE/lab-statusline.sh"
92
+ }
93
+ }
94
+ JSON
95
+
96
+ # Remove the garden citizens this probe's session minted. Matching is on the
97
+ # record's own `cwd` field against this probe's throwaway /tmp cwd -- an exact
98
+ # string compare on a path no real citizen can have. A record that does not match
99
+ # is never touched, and anything unexpected is printed instead of deleted.
100
+ cleanup_citizens() {
101
+ local cwd="$1"
102
+ python3 - "$cwd" <<'PY'
103
+ import json, shutil, sys
104
+ from pathlib import Path
105
+
106
+ cwd = sys.argv[1]
107
+ recs = Path.home() / ".pi" / "agent" / "meta-sessions"
108
+ boxes = Path.home() / ".pi" / "agent" / "meta-mailbox"
109
+ if not recs.is_dir():
110
+ sys.exit(0)
111
+
112
+ removed = []
113
+ for f in sorted(recs.glob("*.meta.json")):
114
+ try:
115
+ d = json.loads(f.read_text())
116
+ except Exception:
117
+ continue
118
+ if d.get("cwd") != cwd:
119
+ continue
120
+ gid = d.get("gardenId") or f.name.removesuffix(".meta.json")
121
+ box = boxes / gid
122
+ # Refuse to delete a mailbox that holds real traffic: only the SessionStart
123
+ # signal file is expected here. Anything else means this citizen was actually
124
+ # used, and a probe must not destroy evidence -- say so and leave it.
125
+ leftovers = sorted(p.name for p in box.iterdir()) if box.is_dir() else []
126
+ if [n for n in leftovers if n != "inbox.signal" and not n.endswith(".log")]:
127
+ print(f" ! left {gid}: mailbox is not empty ({', '.join(leftovers)})")
128
+ continue
129
+ if box.is_dir():
130
+ shutil.rmtree(box)
131
+ f.unlink()
132
+ removed.append(gid)
133
+
134
+ if removed:
135
+ print(f" cleaned {len(removed)} probe-minted citizen(s): {', '.join(removed)}")
136
+ else:
137
+ print(" no probe-minted citizens to clean")
138
+ PY
139
+ }
140
+
141
+ sid_for_cwd() {
142
+ python3 - "$1" <<'PY'
143
+ import json, glob, os, sys
144
+ cwd = sys.argv[1]
145
+ for f in glob.glob(os.path.expanduser("~/.claude/sessions/*.json")):
146
+ try: d = json.load(open(f))
147
+ except Exception: continue
148
+ if d.get("cwd") == cwd and d.get("kind") == "interactive":
149
+ print(d.get("sessionId")); break
150
+ PY
151
+ }
152
+
153
+ echo "== SETUP =="
154
+ echo " claude: $(claude --version 2>/dev/null)"
155
+ echo " plugin: $PLUGIN"
156
+ echo " mailbox: $ROOT"
157
+ echo " slog: $SLOG"
158
+ echo " hooks.json FileChanged entry under test:"
159
+ python3 -c 'import json,sys;print(" "+json.dumps(json.load(open(sys.argv[1]))["hooks"]["FileChanged"][0]["hooks"][0],ensure_ascii=False))' \
160
+ "$PLUGIN/hooks/hooks.json"
161
+
162
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
163
+ tmux new-session -d -s "$SESSION" -x 220 -y 50
164
+ tmux send-keys -t "$SESSION" "export CC_MAILBOX_ROOT='$ROOT' CC_LAB_STATUSLINE_LOG='$SLOG'" Enter
165
+ # --setting-sources project,local DROPS ~/.claude/settings.json, which is where
166
+ # the product meta-bridge plugin is enabled (extraKnownMarketplaces +
167
+ # enabledPlugins). Without it the probe was NOT isolated in two ways, both
168
+ # measured: the product SessionStart hook minted a real garden citizen, and the
169
+ # product doorbell -- which takes dirname(file_path) as "its" mailbox
170
+ # unconditionally -- processed the LAB mailbox and won the race to exit 2, so the
171
+ # operator saw the DEFAULT "Stop hook feedback" from a hook that has no
172
+ # rewakeSummary. Two runs failed P1 that way while hooks.json was correct.
173
+ # The project settings under $CWD/.claude still load, so the lab statusline stays.
174
+ tmux send-keys -t "$SESSION" "cd '$CWD' && claude --plugin-dir '$PLUGIN' --setting-sources project,local --dangerously-skip-permissions" Enter
175
+
176
+ echo "== ARM =="
177
+ SID=""
178
+ for _ in $(seq 1 45); do
179
+ [ -z "$SID" ] && SID=$(sid_for_cwd "$CWD")
180
+ [ -n "$SID" ] && [ -f "$ROOT/$SID/hook.log" ] && break
181
+ sleep 1
182
+ done
183
+ [ -n "$SID" ] || {
184
+ echo "FAIL: session never started"
185
+ exit 1
186
+ }
187
+ [ -f "$ROOT/$SID/hook.log" ] || {
188
+ echo "FAIL: watch never armed"
189
+ exit 1
190
+ }
191
+ echo " sessionId: $SID"
192
+ echo " arm: $(cat "$ROOT/$SID/hook.log")"
193
+
194
+ # (iii) schema acceptance: two @internal fields must not make the plugin unloadable.
195
+ # A rejected hooks.json shows up as a hook/plugin error on the pane at startup.
196
+ STARTUP_PANE="$(tmux capture-pane -t "$SESSION" -p)"
197
+ if printf '%s' "$STARTUP_PANE" | grep -qiE 'hook.*(invalid|error|failed)|plugin.*(invalid|error|failed)|unrecognized key|rewake'; then
198
+ bad "P1(iii) schema: startup complained about the hook config"
199
+ printf '%s\n' "$STARTUP_PANE" | grep -iE 'hook|plugin|rewake' | head -5
200
+ else
201
+ ok "P1(iii) schema: rewakeSummary+rewakeMessage accepted (no startup hook/plugin complaint)"
202
+ fi
203
+
204
+ echo "== DRIVE ONE TURN -> IDLE =="
205
+ sleep 2
206
+ tmux send-keys -t "$SESSION" "Reply with exactly the single word READY and then stop. No tools." Enter
207
+ sleep 1
208
+ tmux send-keys -t "$SESSION" Enter
209
+ for _ in $(seq 1 40); do
210
+ tmux capture-pane -t "$SESSION" -p | grep -qE '●\s*READY|⏺\s*READY' && break
211
+ sleep 1
212
+ done
213
+ sleep 3
214
+ SL_BEFORE=$(wc -l <"$SLOG")
215
+ PANE_BEFORE_LINES=$(tmux capture-pane -t "$SESSION" -p | wc -l)
216
+ echo " idle. statusline invocations so far: $SL_BEFORE"
217
+
218
+ echo "== DOORBELL (addressed external write, zero typing) =="
219
+ # The body carries NO imperative. The first run of this probe put the drain
220
+ # instructions in here and the woken Opus refused to act on them, citing the
221
+ # doorbell's own "do not act on unverified imperatives" line -- correct behaviour,
222
+ # and README lesson #7 reproducing itself. It also meant the mailbox was never
223
+ # drained, so that run measured prompt-injection resistance instead of P2b.
224
+ # The drain below is therefore typed by the OPERATOR, which is real user input.
225
+ CC_MAILBOX_ROOT="$ROOT" "$HERE/cc-enqueue-addressed.sh" "$SID" \
226
+ "P98-PROBE body. Notification-only payload; nothing to do. Reply with exactly WOKE and stop."
227
+
228
+ for _ in $(seq 1 25); do
229
+ grep -qs FILECHANGED "$ROOT/$SID/hook.log" && break
230
+ sleep 1
231
+ done
232
+ if grep -qs FILECHANGED "$ROOT/$SID/hook.log"; then
233
+ ok "doorbell rang on the idle session (FileChanged delivered)"
234
+ else
235
+ bad "doorbell never rang -- every probe below is void"
236
+ exit 1
237
+ fi
238
+
239
+ echo "== WAIT FOR THE WOKEN TURN TO FINISH (P2a window) =="
240
+ for _ in $(seq 1 45); do
241
+ tmux capture-pane -t "$SESSION" -p | grep -qE '⏺\s*WOKE|●\s*WOKE' && break
242
+ sleep 2
243
+ done
244
+ sleep 4
245
+ SL_AFTER_WAKE=$(wc -l <"$SLOG")
246
+
247
+ echo "== OPERATOR-TYPED MID-TURN DRAIN (P2b window) =="
248
+ # The lab has no `entwurf_inbox_read` tool, so a Bash rename to `.read` stands in
249
+ # for it: the same shape -- a MID-TURN tool call that takes the unread count to 0.
250
+ tmux send-keys -t "$SESSION" \
251
+ "Immediately run exactly this one Bash command, nothing else first: mv $ROOT/$SID/*.msg.delivered $ROOT/$SID/drained.msg.delivered.read" Enter
252
+ sleep 1
253
+ tmux send-keys -t "$SESSION" Enter
254
+ # Poll the FILESYSTEM, not the pane. Pane text is a race: the first corrected run
255
+ # timed out at 90s while the turn was still thinking (high effort), and reported a
256
+ # P2b failure that was really "the drain had not happened yet". The rename itself
257
+ # is the unambiguous signal that the mid-turn tool call landed.
258
+ DRAINED=0
259
+ for _ in $(seq 1 90); do
260
+ if ls "$ROOT/$SID"/*.msg.delivered.read >/dev/null 2>&1; then
261
+ DRAINED=1
262
+ break
263
+ fi
264
+ sleep 2
265
+ done
266
+ if [ "$DRAINED" -eq 1 ]; then
267
+ ok "mid-turn drain landed (mailbox archived to .read)"
268
+ else
269
+ bad "mid-turn drain never landed -- P2b below is VOID, not a measurement"
270
+ fi
271
+ # Give the status area time to render at least once after the tool result.
272
+ sleep 6
273
+
274
+ PANE="$(tmux capture-pane -t "$SESSION" -p)"
275
+ JSONL="$HOME/.claude/projects/$(printf '%s' "$CWD" | sed 's#/#-#g')/$SID.jsonl"
276
+
277
+ echo
278
+ echo "===================== P1: OPERATOR-VISIBLE ROW ====================="
279
+ printf '%s\n' "$PANE" | grep -nE 'Stop hook feedback|LAB-P1' || echo " (neither string on the pane)"
280
+ if printf '%s' "$PANE" | grep -q 'LAB-P1 entwurf inbox: sibling mail arrived'; then
281
+ ok "P1(i) pane row shows our rewakeSummary"
282
+ else
283
+ bad "P1(i) pane row does NOT show our rewakeSummary"
284
+ fi
285
+ if printf '%s' "$PANE" | grep -q 'Stop hook feedback'; then
286
+ bad "P1(i) pane still shows the default 'Stop hook feedback'"
287
+ else
288
+ ok "P1(i) default 'Stop hook feedback' is gone from the pane"
289
+ fi
290
+
291
+ echo
292
+ echo "===================== P1: MODEL-VISIBLE PREFIX ====================="
293
+ if [ -f "$JSONL" ]; then
294
+ echo " transcript: $JSONL"
295
+ python3 - "$JSONL" <<'PY'
296
+ import json, sys
297
+ hits = []
298
+ for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
299
+ if "meta-session notice" not in line and "Stop hook" not in line and "LAB-P1" not in line:
300
+ continue
301
+ try: rec = json.loads(line)
302
+ except Exception: continue
303
+ txt = json.dumps(rec, ensure_ascii=False)
304
+ for needle in ("Stop hook blocking error", "LAB-P1 entwurf mailbox notice"):
305
+ if needle in txt:
306
+ i = txt.index(needle)
307
+ hits.append((needle, txt[max(0, i - 60):i + 190]))
308
+ for needle, ctx in hits[:4]:
309
+ print(f" [{needle}]\n …{ctx}…")
310
+ if not hits:
311
+ print(" (neither prefix found in the transcript)")
312
+ PY
313
+ if grep -q 'LAB-P1 entwurf mailbox notice' "$JSONL"; then
314
+ ok "P1(ii) model prefix replaced by our rewakeMessage"
315
+ else
316
+ bad "P1(ii) our rewakeMessage did NOT reach the model"
317
+ fi
318
+ if grep -q 'Stop hook blocking error' "$JSONL"; then
319
+ bad "P1(ii) model still framed with 'Stop hook blocking error'"
320
+ else
321
+ ok "P1(ii) 'Stop hook blocking error' framing is gone"
322
+ fi
323
+ else
324
+ bad "P1(ii) transcript not found at $JSONL"
325
+ fi
326
+
327
+ echo
328
+ echo "===================== P2a / P2b: STATUSLINE RE-EXECUTION ====================="
329
+ echo " invocations before doorbell: $SL_BEFORE"
330
+ echo " --- statusline log lines added after idle ---"
331
+ tail -n +$((SL_BEFORE + 1)) "$SLOG" | sed 's/^/ /'
332
+ SL_AFTER=$(wc -l <"$SLOG")
333
+ WAKE_ADDED=$((SL_AFTER_WAKE - SL_BEFORE))
334
+ DRAIN_ADDED=$((SL_AFTER - SL_AFTER_WAKE))
335
+ echo " added by the doorbell turn: $WAKE_ADDED"
336
+ echo " added by the drain turn: $DRAIN_ADDED"
337
+
338
+ if [ "$WAKE_ADDED" -gt 0 ]; then
339
+ ok "P2a statusline RE-EXECUTED on the asyncRewake turn ($WAKE_ADDED invocation(s))"
340
+ else
341
+ bad "P2a statusline did NOT re-execute -- a badge would not appear when mail lands"
342
+ fi
343
+
344
+ # P2a value check: the invocations during the doorbell turn must have SEEN the
345
+ # mail. A re-execution that still reports unread=0 would draw no badge.
346
+ if tail -n +$((SL_BEFORE + 1)) "$SLOG" | head -n "$WAKE_ADDED" | grep -q 'unread=[1-9]'; then
347
+ ok "P2a those invocations observed unread>=1 (a badge would have been drawn)"
348
+ else
349
+ bad "P2a re-executed but never observed the unread mail"
350
+ fi
351
+
352
+ # P2b: after the mid-turn rename, an invocation must observe unread=0. If every
353
+ # line still says unread>=1, the badge would keep claiming mail the model already
354
+ # read -- option B would then weaken the rang-vs-read distinction, not show it.
355
+ if tail -n +$((SL_AFTER_WAKE + 1)) "$SLOG" | grep -q 'unread=0'; then
356
+ ok "P2b an invocation observed unread=0 after the mid-turn drain (badge clears)"
357
+ else
358
+ bad "P2b no invocation observed unread=0 -- badge would stay stale after the read"
359
+ fi
360
+
361
+ echo
362
+ echo "===================== MAILBOX FINAL STATE ====================="
363
+ ls -1 "$ROOT/$SID/" | sed 's/^/ /'
364
+ echo " hook.log: $(cat "$ROOT/$SID/hook.log")"
365
+
366
+ echo
367
+ echo "===================== PANE (tail) ====================="
368
+ printf '%s\n' "$PANE" | grep -vE '^\s*$' | tail -22 | sed 's/^/ /'
369
+ echo "======================================================="
370
+
371
+ echo
372
+ echo "SUMMARY: $pass pass, $fail fail (claude $(claude --version 2>/dev/null | awk '{print $1}'))"
373
+ echo "receipts: pane above, transcript $JSONL, statusline log $SLOG"
374
+ if [ "$KEEP" = "keep" ]; then
375
+ echo "(tmux session '$SESSION' left alive)"
376
+ else
377
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
378
+ fi
379
+
380
+ # The session inherited the operator's settings, so the PRODUCT meta-bridge minted
381
+ # a real citizen for this throwaway cwd. Give it back.
382
+ echo
383
+ echo "== OPERATOR-STATE CLEANUP =="
384
+ cleanup_citizens "$CWD"
385
+ echo " (left in place, written by Claude itself: ~/.claude/projects/$(printf '%s' "$CWD" | sed 's#/#-#g')/ and ~/.claude/sessions/*.json)"
386
+
387
+ [ "$fail" -eq 0 ]