@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,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