@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
@@ -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 ]
@@ -53,7 +53,7 @@ import { fileURLToPath } from "node:url";
53
53
  import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensions/lib/entwurf-control-rpc.ts";
54
54
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
55
55
  import { skipLive } from "./lib/live-skip.ts";
56
- import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
56
+ import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
57
57
 
58
58
  const ACP_PROVIDER = "entwurf";
59
59
  const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -64,7 +64,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
64
64
  // Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
65
65
  const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
66
66
 
67
- const BOOT_TIMEOUT_MS = 30_000;
67
+ const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
68
68
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
69
69
  const POLL_MS = 100;
70
70
 
@@ -54,7 +54,7 @@ import { fileURLToPath } from "node:url";
54
54
  import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
55
55
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
56
56
  import { skipLive } from "./lib/live-skip.ts";
57
- import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
57
+ import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
58
58
 
59
59
  const ACP_PROVIDER = "entwurf";
60
60
  const ACP_MODEL = process.env.ENTWURF_ACP_CORTEX_MODEL?.trim() || "cortex-claude-sonnet-5";
@@ -66,7 +66,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
66
66
  // Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
67
67
  const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
68
68
 
69
- const BOOT_TIMEOUT_MS = 30_000;
69
+ const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
70
70
  // The cortex CLI self-extracts on first launch and its newSession alone is
71
71
  // ~2.5 s; keep the generous default of the sibling smoke.
72
72
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_CORTEX_TIMEOUT_MS) || 300_000;
@@ -3,7 +3,7 @@
3
3
  // LIVE=1 ./run.sh smoke-acp-raw-turn-live
4
4
  //
5
5
  // What this proves (and ONLY this): the pinned Claude ACP adapter
6
- // (@agentclientprotocol/claude-agent-acp@0.70.0) spawns, speaks the ACP wire
6
+ // (@agentclientprotocol/claude-agent-acp@0.73.0) spawns, speaks the ACP wire
7
7
  // protocol over stdio NDJSON, and returns one real model turn. It is the
8
8
  // bytes-flow proof that the S2a dep surface is not just installable but
9
9
  // actually drivable — before any provider/overlay/streamSimple code (S2b+).
@@ -32,7 +32,7 @@ import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "../pi-extensi
32
32
  import { scanSocketProbes } from "../pi-extensions/lib/socket-discovery.ts";
33
33
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
34
34
  import { skipLive } from "./lib/live-skip.ts";
35
- import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
35
+ import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
36
36
 
37
37
  const ACP_PROVIDER = "entwurf";
38
38
  const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-5";
@@ -43,7 +43,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
43
43
  // Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
44
44
  const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
45
45
 
46
- const BOOT_TIMEOUT_MS = 30_000;
46
+ const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
47
47
  const POLL_MS = 100;
48
48
 
49
49
  let passed = 0;
@@ -82,7 +82,7 @@ import { fileURLToPath } from "node:url";
82
82
  import { upsertMetaSession, writeMetaReceiverMarker } from "../pi-extensions/lib/meta-session.ts";
83
83
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
84
84
  import { skipLive } from "./lib/live-skip.ts";
85
- import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
85
+ import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
86
86
 
87
87
  const ACP_PROVIDER = "entwurf";
88
88
  const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
@@ -93,7 +93,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
93
93
  // Load ONLY this checkout's extensions so the resident registers THIS acp-provider.ts.
94
94
  const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
95
95
 
96
- const BOOT_TIMEOUT_MS = 30_000;
96
+ const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // shared: pi lock-stale window + boot (see pi-record-discovery)
97
97
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
98
98
  const POLL_MS = 100;
99
99
 
@@ -65,7 +65,7 @@ import {
65
65
  } from "../pi-extensions/lib/meta-session.ts";
66
66
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
67
67
  import { skipLive } from "./lib/live-skip.ts";
68
- import { waitForPiRecord } from "./lib/pi-record-discovery.ts";
68
+ import { describePiLockResidue, PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
69
69
 
70
70
  // pi's control socket lives at the canonical dir keyed by the RECORD's garden id (#50 C4: the
71
71
  // record is the sole address authority — never a transcript/session id; :196 below proves it),
@@ -78,7 +78,14 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
78
78
  const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
79
79
 
80
80
  // Staged timeouts (automation): short and per-stage so a stall is attributable.
81
- const BOOT_TIMEOUT_MS = 30_000; // pi --entwurf-control socket appears
81
+ //
82
+ // The boot bound is SHARED and carries its own receipt (see `PI_BOOT_TIMEOUT_MS` in
83
+ // pi-record-discovery). Short version, because this smoke is where it was found: this cell used to
84
+ // wait 30_000ms, which is EXACTLY pi's `proper-lockfile` stale window, and C1's own SIGTERM can
85
+ // orphan that lock — so C1b's resident spent the whole wait queueing behind it and birthed its
86
+ // record at a measured 30_148ms, 148ms after the smoke had stopped looking. That is what blocked
87
+ // two of the three 0.17.0 `--cut` runs.
88
+ const BOOT_TIMEOUT_MS = PI_BOOT_TIMEOUT_MS; // pi --entwurf-control record + socket appear
82
89
  const POLL_MS = 100;
83
90
 
84
91
  let passed = 0;
@@ -90,6 +97,43 @@ function ok(label: string, cond: boolean): void {
90
97
  passed++;
91
98
  }
92
99
 
100
+ /** What a spawned resident DID while the smoke waited on it — the fact a bare boot timeout
101
+ * throws away. Each resident owns its own stderr here: a single shared tail cannot say which
102
+ * of the two children spoke, and the C1b post-mortem is exactly that question. */
103
+ interface ResidentWatch {
104
+ label: string;
105
+ pid: number | undefined;
106
+ startedAt: number;
107
+ exit: string | null;
108
+ stderr: string;
109
+ }
110
+
111
+ function watchResident(child: ChildProcess, label: string): ResidentWatch {
112
+ const watch: ResidentWatch = { label, pid: child.pid, startedAt: Date.now(), exit: null, stderr: "" };
113
+ child.once("exit", (code, signal) => {
114
+ watch.exit = `code=${code} signal=${signal} at +${Date.now() - watch.startedAt}ms`;
115
+ });
116
+ child.stderr?.on("data", (b: Buffer) => {
117
+ watch.stderr = (watch.stderr + b.toString()).slice(-2000);
118
+ });
119
+ return watch;
120
+ }
121
+
122
+ /** Read at FAILURE time, before the `finally` reaps anything: a resident that is still alive
123
+ * having birthed nothing is a different defect from one that exited silently, and "no pid" is a
124
+ * third (the spawn itself never took). */
125
+ function describeResident(watch: ResidentWatch): string {
126
+ const waited = `${Date.now() - watch.startedAt}ms after spawn`;
127
+ if (watch.pid === undefined) return `never acquired a pid — the spawn did not take (${waited})`;
128
+ if (watch.exit) return `pid=${watch.pid} EXITED ${watch.exit} — it was gone before the wait ended`;
129
+ try {
130
+ process.kill(watch.pid, 0);
131
+ return `pid=${watch.pid} still ALIVE ${waited} and had birthed no record`;
132
+ } catch {
133
+ return `pid=${watch.pid} is GONE with no exit event seen (reaped elsewhere), ${waited}`;
134
+ }
135
+ }
136
+
93
137
  function resolveTarget(): { provider: string; model: string } {
94
138
  const combined = process.env.ENTWURF_LIVE_TARGET?.trim();
95
139
  if (combined) {
@@ -153,7 +197,7 @@ async function main(): Promise<void> {
153
197
  let resident: ChildProcess | null = null;
154
198
  let residentGid = "";
155
199
  let c1bGid = "";
156
- let stderrTail = "";
200
+ const residents: ResidentWatch[] = [];
157
201
  let succeeded = false;
158
202
 
159
203
  const prodDeps = (sender: SenderEnvelope) =>
@@ -182,9 +226,7 @@ async function main(): Promise<void> {
182
226
  [...REPO_EXTENSION_ARGS, "--entwurf-control", "--provider", provider, "--model", model, "--mode", "rpc"],
183
227
  { cwd: tmp, stdio: ["pipe", "ignore", "pipe"], detached: false },
184
228
  );
185
- resident.stderr?.on("data", (b: Buffer) => {
186
- stderrTail = (stderrTail + b.toString()).slice(-2000);
187
- });
229
+ residents.push(watchResident(resident, "C1"));
188
230
 
189
231
  const bornGid = await waitForPiRecord(sessionsDir, BOOT_TIMEOUT_MS);
190
232
  ok("C1 the resident BIRTHED its own V3 backend:pi record (the address authority)", bornGid !== null);
@@ -241,9 +283,7 @@ async function main(): Promise<void> {
241
283
  env: { ...process.env, ENTWURF_META_SESSIONS_DIR: hiddenStore },
242
284
  },
243
285
  );
244
- resident.stderr?.on("data", (b: Buffer) => {
245
- stderrTail = (stderrTail + b.toString()).slice(-2000);
246
- });
286
+ residents.push(watchResident(resident, "C1b"));
247
287
 
248
288
  const bornGid = await waitForPiRecord(hiddenStore, BOOT_TIMEOUT_MS);
249
289
  ok("C1b the resident birthed its record into the HIDDEN store", bornGid !== null);
@@ -358,7 +398,17 @@ async function main(): Promise<void> {
358
398
  } catch (err) {
359
399
  console.error("\n[smoke-entwurf-v2-matrix-live] FAILED — diagnostic artifacts:");
360
400
  for (const [k, v] of Object.entries(artifacts)) console.error(` ${k} = ${v}`);
361
- if (stderrTail) console.error(` pi stderr (tail):\n${stderrTail.replace(/^/gm, " ")}`);
401
+ // Probed HERE, not in `finally`: the reaper runs after this block, so this is the last
402
+ // moment the residents' real state can still be read. An empty stderr is itself evidence
403
+ // and is printed as "(empty)" rather than skipped — silence that is never stated reads as
404
+ // a missing diagnostic instead of the observation it is.
405
+ console.error(` pi locks held right now: ${describePiLockResidue()}`);
406
+ for (const watch of residents) {
407
+ console.error(` resident ${watch.label}: ${describeResident(watch)}`);
408
+ console.error(
409
+ ` resident ${watch.label} stderr: ${watch.stderr ? `\n${watch.stderr.replace(/^/gm, " ")}` : "(empty)"}`,
410
+ );
411
+ }
362
412
  throw err;
363
413
  } finally {
364
414
  if (resident) {
@@ -6,13 +6,24 @@
6
6
  #
7
7
  # WHY THIS EXISTS
8
8
  # The whole Claude async-delivery path rides on UNDOCUMENTED Claude Code behavior:
9
- # `asyncRewake` force-prepends `Stop hook feedback:` and ignores `rewakeMessage`;
10
- # the payload channel is stderr-only; `watchPaths` can arm from only 3 hook events
11
- # (SessionStart / CwdChanged / FileChanged). Half the Gotchas in
12
- # scripts/raw-async-delivery/README.md are reverse-engineered. Claude ships ~weekly.
13
- # So the path can break SILENTLY on any upgrade. This gate makes it SCREAM instead —
14
- # direct lineage of the 0.8.x fail-fast tool-surface gates ("don't let a surface
15
- # break quietly, make the gate cry out").
9
+ # `asyncRewake` names the operator row and the model prefix from `rewakeSummary` /
10
+ # `rewakeMessage` (both read from a local plugin's hooks.json, both @internal, and
11
+ # the product doorbell now DEPENDS on both); the doorbell writes its notice on
12
+ # stderr; `watchPaths` can arm from only 3 hook events (SessionStart / CwdChanged /
13
+ # FileChanged). Half the Gotchas in scripts/raw-async-delivery/README.md are
14
+ # reverse-engineered. Claude ships ~weekly. So the path can break SILENTLY on any
15
+ # upgrade. This gate makes it SCREAM instead — direct lineage of the 0.8.x fail-fast
16
+ # tool-surface gates ("don't let a surface break quietly, make the gate cry out").
17
+ #
18
+ # THIS PARAGRAPH USED TO BE WRONG, AND THAT IS THE POINT (#98, corrected
19
+ # 2026-09-03). It said `asyncRewake` "force-prepends `Stop hook feedback:` and
20
+ # ignores `rewakeMessage`", and that the payload channel was "stderr-only". Both are
21
+ # FALSE — measured on 2.1.236/2.1.258/2.1.259: `rewakeMessage` REPLACES the model
22
+ # prefix, `rewakeSummary` REPLACES the operator row, and the model body is
23
+ # `stderr || stdout`. The marker strings below stayed green the whole time, because
24
+ # a marker pins a STRING and cannot see the sentence next to it rot. Nobody tried
25
+ # the two fields for months on the strength of this header. Receipts:
26
+ # scripts/raw-async-delivery/README.md "Inherited facts corrected".
16
27
  #
17
28
  # TWO TIERS (mirrors smoke-compaction-policy: deterministic default + LIVE=1 add-on)
18
29
  # DETERMINISTIC (default; free, offline, CI/pre-commit safe):
@@ -70,7 +81,19 @@ MARKERS=(
70
81
  flushPendingAsyncRewakeHooks # engine turn-boundary flush (edge-bound delivery)
71
82
  CLAUDE_CODE_STOP_HOOK_BLOCK_CAP # native re-wake cap
72
83
  FileChanged # idle-wake event
73
- rewakeMessage # the field asyncRewake IGNORES (doorbell-only proof)
84
+ # rewakeMessage/rewakeSummary CORRECTED 2026-09-03 (#98 Phase 1). The comment here used
85
+ # to read "the field asyncRewake IGNORES (doorbell-only proof)". That was FALSE, and it is
86
+ # the reason nobody tried the field for months: measured on 2.1.236/2.1.258/2.1.259, BOTH
87
+ # are read from a local plugin's hooks.json with no first-party gate. `rewakeMessage`
88
+ # REPLACES the model-facing `Stop hook blocking error from command "…":` prefix;
89
+ # `rewakeSummary` REPLACES the operator-visible row (default `Stop hook feedback`). Only
90
+ # the stdout-JSON form of rewakeSummary is first-party gated. The marker string was right
91
+ # while the reason next to it was dead — which is why these two are pinned HERE: the
92
+ # product doorbell now DEPENDS on both (pi/meta-bridge/.../hooks/hooks.json), the fields
93
+ # are @internal, and if either disappears the surface silently reverts to
94
+ # `Stop hook feedback` + "blocking error" with no other gate to notice.
95
+ rewakeMessage # model-facing prefix replacement (product depends on it)
96
+ rewakeSummary # operator-facing row replacement (product depends on it)
74
97
  hookSpecificOutput # the watchPaths emit envelope
75
98
  CwdChanged # the 2nd of the 3 watch-arming events
76
99
  )