@junghanacs/entwurf 0.17.1 → 0.18.0

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 (74) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +248 -0
  3. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -9
  4. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +9 -6
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +11 -3
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +66 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +10 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +8 -2
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +9 -2
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +25 -13
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +14 -2
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +24 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +61 -7
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +64 -4
  17. package/mcp/entwurf-bridge/src/index.ts +16 -8
  18. package/package.json +3 -2
  19. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +3 -1
  20. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +13 -8
  21. package/pi-extensions/lib/entwurf-deliverability.ts +104 -0
  22. package/pi-extensions/lib/entwurf-fact-provider.ts +20 -6
  23. package/pi-extensions/lib/entwurf-facts.ts +52 -3
  24. package/pi-extensions/lib/entwurf-peer-observe.ts +77 -0
  25. package/pi-extensions/lib/entwurf-peers-render.ts +12 -1
  26. package/pi-extensions/lib/entwurf-v2-decider.ts +26 -8
  27. package/pi-extensions/lib/entwurf-v2-mailbox.ts +9 -2
  28. package/pi-extensions/lib/entwurf-v2-production.ts +32 -12
  29. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -6
  30. package/pi-extensions/lib/entwurf-v2-send.ts +26 -3
  31. package/pi-extensions/lib/entwurf-v2-surface.ts +24 -3
  32. package/pi-extensions/lib/meta-session.ts +76 -7
  33. package/pi-extensions/meta-bridge-hook.ts +72 -3
  34. package/run.sh +21 -0
  35. package/scripts/check-bridge-delivery.ts +42 -1
  36. package/scripts/check-entwurf-fact-provider.ts +22 -1
  37. package/scripts/check-entwurf-facts.ts +17 -2
  38. package/scripts/check-entwurf-peers-surface.ts +20 -2
  39. package/scripts/check-entwurf-self-address.ts +20 -2
  40. package/scripts/check-entwurf-v2-decider.ts +6 -3
  41. package/scripts/check-entwurf-v2-mailbox.ts +33 -0
  42. package/scripts/check-entwurf-v2-matrix.ts +10 -5
  43. package/scripts/check-entwurf-v2-production.ts +72 -1
  44. package/scripts/check-entwurf-v2-runner.ts +18 -0
  45. package/scripts/check-entwurf-v2-send.ts +13 -1
  46. package/scripts/check-entwurf-v2-surface.ts +75 -3
  47. package/scripts/check-gate-qualification.ts +1 -0
  48. package/scripts/check-hook-launch-topology.ts +70 -1
  49. package/scripts/check-mailbox-receipt-state.ts +6 -0
  50. package/scripts/check-meta-doctor-oracle.sh +73 -0
  51. package/scripts/check-meta-hook-session-switch.ts +560 -0
  52. package/scripts/check-meta-mailbox-state-write.ts +9 -2
  53. package/scripts/check-meta-manifest-schema.py +19 -1
  54. package/scripts/check-meta-session.ts +10 -2
  55. package/scripts/meta-bridge-doctor.sh +47 -7
  56. package/scripts/meta-bridge-state.py +23 -5
  57. package/scripts/meta-bridge-statusline.sh +65 -2
  58. package/scripts/mutants/meta-hook-session-switch.json +223 -0
  59. package/scripts/raw-async-delivery/README.md +280 -9
  60. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +6 -2
  61. package/scripts/raw-async-delivery/lab-statusline.sh +63 -0
  62. package/scripts/raw-async-delivery/mailbox-watch.py +230 -0
  63. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +3 -1
  64. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +13 -4
  65. package/scripts/raw-async-delivery/probe-delivery-transparency.sh +387 -0
  66. package/scripts/raw-claude-session-switch/README.md +170 -0
  67. package/scripts/smoke-acp-cortex-live.ts +22 -2
  68. package/scripts/smoke-acp-v2-send-live.ts +23 -2
  69. package/scripts/smoke-entwurf-chain-live.ts +85 -13
  70. package/scripts/smoke-entwurf-v2-matrix-live.ts +23 -1
  71. package/scripts/smoke-meta-async-drift.sh +31 -8
  72. package/scripts/smoke-meta-install-state.sh +170 -11
  73. package/scripts/smoke-meta-keyset-guard.sh +4 -1
  74. package/scripts/smoke-mux-fresh-call-live.ts +13 -3
@@ -112,6 +112,28 @@ do not ship that. Proven: deliver to A's sessionId → A wakes (FileChanged), B'
112
112
  - `live` — one session idle → addressed external write wakes it, zero typing.
113
113
  - `repro-addressed-routing.sh` — **two sessions** A,B → deliver to A only → assert A wakes, B undisturbed.
114
114
 
115
+ ### Delivery-transparency probes (issue #98 Phase 1)
116
+ None of these is a product surface, and none edits a product **file** —
117
+ `pi/meta-bridge/**`, `pi-extensions/**`, `mcp/**`, `scripts/meta-bridge-*.sh`.
118
+ They are **not** isolated from operator **state**, though: see
119
+ [What the probe session actually touches](#what-the-probe-session-actually-touches).
120
+ Receipts in [Inherited facts corrected](#inherited-facts-corrected).
121
+ - `probe-delivery-transparency.sh [keep]` — P1 + P2a + P2b in one live session:
122
+ does a configured `rewakeSummary`/`rewakeMessage` reach the operator's row and
123
+ the model's prefix, and does the statusline re-execute both when mail lands and
124
+ after a mid-turn drain. Builds its own `/tmp` mailbox, cwd and settings.
125
+ - `lab-statusline.sh` — the P2a/P2b instrument. Logs one line per invocation with
126
+ the unread count it observed, so the question is answerable without shipping a
127
+ badge first. **Never install this as the product statusline**
128
+ (`scripts/meta-bridge-statusline.sh`).
129
+ - `mailbox-watch.py [root]` — P4 prototype of the out-of-harness observation
130
+ window: one line per message transition (`ARRIVED`/`RUNG`/`READ`), read from the
131
+ mailbox files alone. Uses `inotify(7)` via ctypes because `inotifywait` is not on
132
+ `PATH` here. **Mailbox-rail only** — self-fetch backends (claude-code / copilot /
133
+ omp). pi goes over its control socket and antigravity over native-push, so
134
+ neither writes here; silence is not "no sibling traffic". See the script's SCOPE
135
+ block.
136
+
115
137
  ## Quick start (plugin reception)
116
138
 
117
139
  ```bash
@@ -131,9 +153,11 @@ CC_MAILBOX_ROOT=/tmp/cc-mbx ./cc-enqueue-addressed.sh <session_id> "your async m
131
153
 
132
154
  ## Design notes / invariants
133
155
 
134
- - **Doorbell only.** `asyncRewake` payload rides **stderr** (stdout is ignored).
135
- Announce "you have mail" + the body path; never push imperatives strong
136
- models flag hook-injected commands as prompt injection. The agent self-fetches.
156
+ - **Doorbell only.** Write the payload to **stderr** — not because stdout is
157
+ ignored (it is not; see the correction to gotcha #3), but because stderr is the
158
+ channel that is used unconditionally and is never parsed as JSON. Announce "you
159
+ have mail" + the body path; never push imperatives — strong models flag
160
+ hook-injected commands as prompt injection. The agent self-fetches.
137
161
  - **Body path in the doorbell.** The hook `mv`s `*.msg` → `*.msg.delivered`
138
162
  before announcing and reports the `.delivered` path, so the agent reads it in
139
163
  one step (measured: removes a filesystem-hunt round-trip).
@@ -161,12 +185,23 @@ that a second pass reversed.
161
185
  at skill *invocation* (mid-session, after `SessionStart`); plugin
162
186
  `hooks/hooks.json` loads at *startup*. (Wrong conclusion #2 was "scoped is
163
187
  impossible → global settings only". See top of this file.)
164
- 3. **`asyncRewake` payload channel is `stderr` ONLY.** Anything on `stdout` is
165
- dropped and the model sees "No stderr output". The body must go to stderr.
166
- 4. **`asyncRewake` force-prepends `Stop hook feedback:\n[<script>]:`** and
167
- **ignores any configured `rewakeMessage`**. You cannot control the exact
168
- injected string so use it as a *doorbell* (notify only) and let the agent
169
- self-fetch the body. Do not depend on injecting the literal message.
188
+ 3. ~~**`asyncRewake` payload channel is `stderr` ONLY.** Anything on `stdout` is
189
+ dropped and the model sees "No stderr output". The body must go to stderr.~~
190
+ **FALSE retired 2026-09-03, receipt in [Inherited facts corrected](#inherited-facts-corrected).**
191
+ The model-facing body is `${prefix} ${stderr || stdout}`: stdout is used
192
+ whenever stderr is empty, and is additionally scanned line-by-line for a JSON
193
+ hook-output object. Keep writing to stderr that is still the right choice,
194
+ because a doorbell that also emits JSON on stdout invites the parser — but do
195
+ not repeat the reason. It is not "stdout is dropped".
196
+ 4. ~~**`asyncRewake` force-prepends `Stop hook feedback:\n[<script>]:`** and
197
+ **ignores any configured `rewakeMessage`**.~~
198
+ **FALSE — retired 2026-09-03, receipt in [Inherited facts corrected](#inherited-facts-corrected).**
199
+ Both strings are configurable from `hooks.json`, ungated for a local plugin:
200
+ `rewakeSummary` replaces the operator-visible row (default `Stop hook
201
+ feedback`), `rewakeMessage` replaces the model-visible prefix (default `Stop
202
+ hook blocking error from command "…":`). Only the *stdout-JSON* form of
203
+ `rewakeSummary` is first-party gated. Doorbell framing is still right — never
204
+ push imperatives (lesson #7) — but that is a *policy* choice, not a limit.
170
205
  5. **Infinite-loop guard is mandatory.** Honor `stop_hook_active` (if `true`,
171
206
  `exit 0` — already continuing, let it stop) or you get a wake loop. The engine
172
207
  also caps re-wakes via `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`.
@@ -186,6 +221,242 @@ that a second pass reversed.
186
221
  submit a `tmux send-keys` prompt (the first keystroke only fills the input
187
222
  box). The repro drivers send `Enter` twice on purpose — not a bug.
188
223
 
224
+ ## What the probe session actually touches
225
+
226
+ A correction to a claim this lab made about *itself* — read it before the
227
+ receipts, because for two runs it made them **wrong**.
228
+
229
+ `probe-delivery-transparency.sh` used to launch a plain `claude --plugin-dir …`,
230
+ which inherits the operator's `~/.claude/settings.json`. That is where the
231
+ **product** meta-bridge plugin is enabled (`extraKnownMarketplaces` +
232
+ `enabledPlugins`), so it loaded next to the lab plugin. Two consequences, both
233
+ measured:
234
+
235
+ **1. The probe minted real garden citizens.** The product `SessionStart` hook
236
+ created a record in `~/.pi/agent/meta-sessions/` and a mailbox in
237
+ `~/.pi/agent/meta-mailbox/`. Three runs left three behind
238
+ (`20260903T161532-113180`, `…161709-137c49`, `…161922-45df8a`, each with
239
+ `cwd=/tmp/cc-p98-probe/cwd`). They have been returned.
240
+
241
+ **2. The two doorbells cross-fired, and P1 was non-deterministic.** An earlier
242
+ version of this section asserted they could not — that each fires only on the
243
+ signal path its own `SessionStart` armed. **That was false, and asserting it
244
+ without measuring is how it survived.** `doorbell.sh` takes
245
+ `dirname(file_path)` as "its" mailbox *unconditionally*, so the product doorbell
246
+ processed the **lab** mailbox and raced the lab hook to `exit 2`. When the
247
+ product hook won, the operator saw the default `Stop hook feedback` — from a hook
248
+ that carries no `rewakeSummary` — while the `hooks.json` under test was perfectly
249
+ correct. Two consecutive runs failed P1 that way.
250
+
251
+ The transcript is unambiguous about who fired: product wording, lab path.
252
+
253
+ ```
254
+ <summary>Stop hook feedback</summary>
255
+ Stop hook blocking error from command "FileChanged": [entwurf inbox] 1 unread
256
+ mailbox message available for garden 598c57c0-… Read them by calling the
257
+ entwurf_inbox_read tool … bodies are at
258
+ /tmp/cc-p98-probe/mailbox/598c57c0-…/*.msg.delivered
259
+ ```
260
+
261
+ `[entwurf inbox]` / `entwurf_inbox_read` / `lastReadAt` is the **product**
262
+ doorbell's text; `/tmp/cc-p98-probe/mailbox/` is the **lab** mailbox. The first
263
+ three (green) runs were the lab hook winning the same race, not isolation.
264
+
265
+ **Fix: `--setting-sources project,local`.** Dropping user settings removes the
266
+ product plugin, so there is no citizen and no race, while the project settings
267
+ under the throwaway cwd still load and the lab statusline still applies. Two
268
+ consecutive runs after the change: **10 pass / 0 fail, `no probe-minted citizens
269
+ to clean`.**
270
+
271
+ `cleanup_citizens` stays as a sweep for anything an older-flag run left behind.
272
+ It deletes only records whose recorded `cwd` matches the probe's own `/tmp` path,
273
+ and a mailbox holding anything but `inbox.signal` is **reported and left alone** —
274
+ a probe must not destroy evidence.
275
+
276
+ Claude still writes `~/.claude/projects/<cwd>/<sid>.jsonl` and
277
+ `~/.claude/sessions/<pid>.json` on its own; receipt (ii) below **is** one of those
278
+ files. That is Claude's bookkeeping and is left alone.
279
+
280
+ > The older drivers (`repro-plugin-idle-wake.sh`, `repro-addressed-routing.sh`)
281
+ > launch without `--setting-sources` and carry both problems. Pre-existing, not
282
+ > introduced by Phase 1 — named here so the next person does not rediscover it as
283
+ > a mystery flake.
284
+
285
+ > **Carried out of the lab:** the product `doorbell.sh` trusting
286
+ > `dirname(file_path)` for any watched path it is handed is a real property of the
287
+ > shipped hook, not a lab artifact. Nothing today pokes a signal outside the
288
+ > garden mailbox, so it is not a live defect — but it is the reason a second
289
+ > FileChanged hook cannot coexist with it, and it belongs in the issue.
290
+
291
+ ## Inherited facts corrected
292
+
293
+ Receipts for claims this file used to assert without one. Issue #98 Phase 1,
294
+ measured on thinkpad 2026-09-03 against **Claude Code 2.1.259**. Driver:
295
+ `./probe-delivery-transparency.sh` (**10 pass, 0 fail**), which sets up its own
296
+ `/tmp` mailbox, cwd and statusline. Read the section above for what it does *not*
297
+ isolate.
298
+
299
+ The claims were not sloppy — they were true of what was *observed* at the time
300
+ and were never re-measured. What made them expensive is that gotcha #4's reason
301
+ rotted while its drift-sentinel marker stayed green: `smoke-meta-async-drift.sh`
302
+ pins the string `rewakeMessage` with the comment *"the field asyncRewake
303
+ IGNORES"*. The string is present, so the sentinel passes, while the sentence next
304
+ to it says the opposite of the truth. A live string with a dead reason is a class
305
+ of debt no sentinel catches.
306
+
307
+ ### P1 — `rewakeSummary` / `rewakeMessage` are configurable and ungated
308
+
309
+ Added to the lab plugin's `hooks/hooks.json` FileChanged entry:
310
+
311
+ ```json
312
+ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/watch-filechanged.sh",
313
+ "asyncRewake": true, "timeout": 20,
314
+ "rewakeSummary": "LAB-P1 entwurf inbox: sibling mail arrived",
315
+ "rewakeMessage": "LAB-P1 entwurf mailbox notice:" }
316
+ ```
317
+
318
+ **(i) operator-visible row** — `tmux capture-pane`, verbatim:
319
+
320
+ ```
321
+ ● READY
322
+ ● LAB-P1 entwurf inbox: sibling mail arrived
323
+ Read 1 file (ctrl+o to expand)
324
+ ```
325
+
326
+ `Stop hook feedback` does not appear anywhere on the pane.
327
+
328
+ **(ii) model-visible prefix** — session JSONL
329
+ (`~/.claude/projects/-tmp-cc-p98-probe-cwd/<sid>.jsonl`), verbatim:
330
+
331
+ ```
332
+ …arrived</summary>\n</task-notification>\n<system-reminder>\nLAB-P1 entwurf mailbox notice: [meta-session notice] 1 unread entwurf mailbox message arrived (20260903T071931803113443). Body is at: /tmp/cc-p98-probe/mailbox/…
333
+ ```
334
+
335
+ `Stop hook blocking error` does not appear in the transcript. Note the shape the
336
+ binary predicted and the transcript confirms: the notification is
337
+ `<task-notification><summary>…</summary></task-notification>` — the ONLY part
338
+ rendered to the operator — followed by a `<system-reminder>` carrying the body,
339
+ which the operator never sees. That asymmetry, not a regression, is why sibling
340
+ traffic was invisible.
341
+
342
+ **(iii) schema** — the two `@internal` fields load with no plugin/hook complaint
343
+ at startup.
344
+
345
+ **(iv) sanitisation** — the config path is *unsanitised*. The stdout-JSON path
346
+ runs `.trim().replace(/\s+/g," ").slice(0,cap)`; a `hooks.json` value goes to the
347
+ row as written, guarded only by the schema's `min(1)`. Keeping the summary short
348
+ and single-line is the caller's job. A value with no `<summary>` content at all
349
+ hides the whole notification (the renderer drops a task-notification with no
350
+ summary tag); a whitespace value passes `min(1)` and draws a blank row.
351
+
352
+ ### P2a / P2b — the statusline DOES re-execute, in both windows
353
+
354
+ The open question was whether an unread badge could work at all. Both halves are
355
+ green, so option B's premise holds.
356
+
357
+ `lab-statusline.sh` logs one line per invocation with the count it observed, so
358
+ no badge has to ship before the measurement — the earlier "chicken-and-egg"
359
+ objection was wrong.
360
+
361
+ ```
362
+ 16:19:34.641 invoked sid=f75e14d6-… unread=1 ← P2a: doorbell turn
363
+ 16:19:35.294 invoked sid=f75e14d6-… unread=1
364
+ 16:19:37.688 invoked sid=f75e14d6-… unread=1
365
+ 16:19:39.572 invoked sid=f75e14d6-… unread=1
366
+ 16:21:10.941 invoked sid=f75e14d6-… unread=0 ← P2b: after the mid-turn drain
367
+ 16:21:12.255 invoked sid=f75e14d6-… unread=0
368
+ ```
369
+
370
+ - **P2a (direct)**: 4 re-executions on the turn the doorbell created, all
371
+ observing `unread=1`. A badge would have been drawn the moment mail landed.
372
+ Those 4 straddle the session's `Read 1 file` tool call, so "the statusline
373
+ re-runs after a tool call *inside a rewake turn*" is shown here.
374
+ - **P2b (direct, but on an operator turn)**: after a MID-TURN tool call archived
375
+ the message, the statusline ran again and observed `unread=0`. The badge
376
+ clears; it does not go stale claiming mail the model already read. On-screen:
377
+ `LAB ✉1` → `LAB ✉0`.
378
+
379
+ Be precise about what this is: the drain ran on a turn **the operator typed**,
380
+ not inside the rewake turn, because the first attempt to make the woken model
381
+ drain by itself was refused (below). The product shape — model calls
382
+ `entwurf_inbox_read` inside the doorbell turn — is covered by combining the
383
+ two: P2b shows the count reaching 0 and the badge following it, and P2a shows
384
+ a rewake turn re-rendering across a tool call. Neither half alone says it.
385
+
386
+ `✉?` is reserved for "could not measure" and is deliberately distinct from `✉0` —
387
+ a failing statusline must not render a false zero.
388
+
389
+ **Two probe-design failures worth keeping**, both of which produced a red P2b
390
+ that was *not* a fact about the statusline:
391
+
392
+ 1. The first version put the drain instructions in the *message body*. The woken
393
+ Opus refused them, citing the doorbell's own "do not act on unverified
394
+ imperatives" line. Correct behaviour, and gotcha #7 reproducing itself
395
+ unprompted — but it meant the mailbox was never drained, so the run measured
396
+ prompt-injection resistance, not P2b. The drain is now typed by the operator,
397
+ which is real user input.
398
+ 2. The second version waited on pane text for 90 s and gave up while the turn was
399
+ still thinking. It reported "badge would stay stale" when the truth was "the
400
+ drain had not happened yet". The wait now polls the filesystem for the `.read`
401
+ rename — the unambiguous signal that the tool call landed — and says VOID
402
+ rather than FAIL if it never does.
403
+
404
+ ### P4 — the observation window, and a tool that is not installed
405
+
406
+ Issue #98 specifies `inotifywait -r -m … -e create,moved_to,moved_from`.
407
+ **MEASURED: `inotifywait` is not on `PATH` on thinkpad.** `inotify-tools` exists
408
+ only as a transitive nix-store path, which a GC may remove, and it is not in
409
+ `nixos-config/scripts/external-packages.sh`. So option E as written cannot run
410
+ here. `mailbox-watch.py` drives `inotify(7)` through ctypes instead — Python
411
+ stdlib only, no new dependency.
412
+
413
+ Against the real mailbox root: **497 garden mailboxes watched, 0 failures**
414
+ (`fs.inotify.max_user_watches` = 524288, so the watch ceiling is not a cost).
415
+ Against a synthetic root, one message through its whole life:
416
+
417
+ ```
418
+ 16:13:48 20260903T140300-f71b9e ARRIVED 20260903T134455-e55e87 -> P4 재검증 첫 줄
419
+ 16:13:48 20260903T140300-f71b9e RUNG 20260903T134455-e55e87 -> P4 재검증 첫 줄
420
+ 16:13:49 20260903T140300-f71b9e READ 20260903T134455-e55e87 -> P4 재검증 첫 줄
421
+ 16:13:49 newcit2 ARRIVED 20260903T999999-zzzzzz -> 새 시민 편지
422
+ ```
423
+
424
+ Three corrections to the specified event set, each found by running it:
425
+
426
+ - **`-r` is mandatory.** A non-recursive watch on the parent sees only the
427
+ garden-id *directories*, never a `.msg` inside one — the specified command
428
+ would have printed nothing at all.
429
+ - **`create` is the wrong arrival event.** `CREATE` fires before the body is
430
+ written, so it both double-reports (with `CLOSE_WRITE`) and can read a
431
+ half-written envelope. Report on `CLOSE_WRITE`.
432
+ - **`moved_from` must be watched but not printed.** The doorbell's `mv m
433
+ m.delivered` is an in-place rename, so it emits a `MOVED_FROM`/`MOVED_TO` pair;
434
+ printing both reports every delivery twice, once under its old name.
435
+
436
+ The last row shows a citizen created *while the watcher ran* being picked up —
437
+ the parent watch exists for exactly that.
438
+
439
+ Two further defects, both found in review after the first green run, both of the
440
+ same family the issue is about — **traffic that happened and was never shown**:
441
+
442
+ - **A new citizen's FIRST message was lost.** `enqueueMetaMessage`
443
+ (`meta-session.ts:2484-2489`) does `mkdirSync(dir)` and then `writeFileSync`
444
+ with nothing between — no tmp+rename — so the `.msg` can be complete before the
445
+ watcher, which only learns of the directory from `IN_CREATE`, has attached a
446
+ watch to it. MEASURED: `os.mkdir(d); open(d/'x.msg','w').write(…)` produced
447
+ **zero output**. The synthetic lifecycle test above never caught it because its
448
+ `mkdir` and its write were seconds apart. Fixed by sweeping a directory
449
+ immediately after watching it and de-duplicating against the live event; the
450
+ same repro now prints **1** line.
451
+ - **`IN_Q_OVERFLOW` was swallowed.** A kernel queue overflow arrives as `wd=-1`
452
+ with no name, which fell through the `parent is None` guard and vanished. A
453
+ watcher that silently drops "I lost events" is the very failure this window
454
+ exists to end. It now says so on stderr.
455
+
456
+ Still true and documented rather than fixed: a hard link produces `CREATE` with
457
+ no `CLOSE_WRITE` and is not reported (nothing writes mailbox messages that way),
458
+ and `IN_IGNORED` for a deleted garden directory is not handled (harmless).
459
+
189
460
  ## Codex raw delivery status (0.136.0)
190
461
 
191
462
  Measured against `@openai/codex` 0.136.0 (source: `~/repos/3rd/codex`). **Codex IS
@@ -5,8 +5,12 @@
5
5
  # on exit 2, continues THAT SAME session/model (same session_id, same
6
6
  # transcript) — proven: the wake is an in-process continuation, NOT a new
7
7
  # `claude -p` spawn. No credit draw beyond the running subscription turn.
8
- # PATTERN: DOORBELL — announce "you have mail" on stderr (the ONLY payload
9
- # channel for asyncRewake; stdout is ignored). Do NOT push imperatives;
8
+ # PATTERN: DOORBELL — announce "you have mail" on stderr. Not because stdout is
9
+ # ignored (it is NOT corrected 2026-09-03, #98 Phase 1: the model body is
10
+ # `${prefix} ${stderr || stdout}` and stdout is additionally scanned for a
11
+ # JSON hook-output object; receipts in README.md "Inherited facts
12
+ # corrected"). stderr is the right channel because it is used
13
+ # unconditionally and is never parsed as JSON. Do NOT push imperatives;
10
14
  # strong models (opus) refuse hook-injected commands as prompt injection.
11
15
  # The agent self-fetches the body with its own trusted mailbox tool.
12
16
  # GUARD: respect stop_hook_active (+ engine cap CLAUDE_CODE_STOP_HOOK_BLOCK_CAP).
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env bash
2
+ # lab-statusline.sh — MEASUREMENT ONLY. Not the product statusline.
3
+ #
4
+ # P2a/P2b instrument (issue #98). The product surface is
5
+ # `scripts/meta-bridge-statusline.sh`; this file must never be installed as it.
6
+ # It exists to answer ONE question the binary read could not settle:
7
+ #
8
+ # Does Claude Code RE-EXECUTE the statusline command
9
+ # (P2a) on the turn an asyncRewake doorbell creates, and
10
+ # (P2b) after a mid-turn tool call drains the mailbox?
11
+ #
12
+ # Method: every invocation appends one line to $CC_LAB_STATUSLINE_LOG carrying
13
+ # the wall clock and the unread count it observed. A new line = the command ran.
14
+ # The count on that line = what a real badge WOULD have drawn at that moment.
15
+ # So the log alone decides both P2a (a line appears when mail lands) and P2b
16
+ # (a line with count 0 appears after the read) — no badge needed on screen.
17
+ # That is why the "you must ship the badge before you can measure it"
18
+ # chicken-and-egg does not hold.
19
+ #
20
+ # CONTRACT: never exit non-zero, never block. Claude renders this every update;
21
+ # a broken exit would put a broken line in front of the operator. Failures
22
+ # degrade to a visible marker, never to a silent zero — the same honesty rule
23
+ # the product badge will need (`✉?` != `✉0`).
24
+ set -uo pipefail
25
+
26
+ LOG="${CC_LAB_STATUSLINE_LOG:-/tmp/cc-lab-statusline.log}"
27
+ ROOT="${CC_MAILBOX_ROOT:-}"
28
+
29
+ input=$(cat 2>/dev/null || true)
30
+
31
+ # session_id lets us find THIS session's lab mailbox. The lab addresses mailboxes
32
+ # by Claude's own session_id (cc-enqueue-addressed.sh), not by garden id.
33
+ sid=$(printf '%s' "$input" | python3 -c \
34
+ 'import json,sys;print(json.load(sys.stdin).get("session_id",""))' 2>/dev/null || true)
35
+
36
+ # Unread = what `entwurf_inbox_read` would still return: every *.msg (not yet rung)
37
+ # plus every *.msg.delivered (rung, but not yet read). A file already archived to
38
+ # *.msg.delivered.read is NOT unread. This is the same set the product counts at
39
+ # meta-session.ts:2550. `?` means "could not measure" and is kept distinct from
40
+ # `0` on purpose: a statusline that fails must not draw a false zero.
41
+ #
42
+ # ADDRESSING DIFFERS FROM THE PRODUCT. The lab addresses a mailbox by Claude's own
43
+ # `session_id` (that is what cc-enqueue-addressed.sh writes); the product
44
+ # addresses it by GARDEN ID. Only the directory lookup changes -- the counted set,
45
+ # and therefore what P2a/P2b prove about re-execution, is the same.
46
+ count="?"
47
+ if [ -n "$ROOT" ] && [ -n "$sid" ] && [ -d "$ROOT/$sid" ]; then
48
+ n=0
49
+ for f in "$ROOT/$sid"/*.msg "$ROOT/$sid"/*.msg.delivered; do
50
+ [ -e "$f" ] && n=$((n + 1))
51
+ done
52
+ count="$n"
53
+ fi
54
+
55
+ printf '%s invoked sid=%s unread=%s\n' \
56
+ "$(date '+%H:%M:%S.%3N')" "${sid:-NONE}" "$count" >>"$LOG" 2>/dev/null || true
57
+
58
+ if [ "$count" = "?" ]; then
59
+ printf 'LAB ✉? (unmeasurable)'
60
+ else
61
+ printf 'LAB ✉%s' "$count"
62
+ fi
63
+ exit 0
@@ -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