@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
@@ -61,12 +61,17 @@ export interface DispatchExecutorDeps {
61
61
  }
62
62
 
63
63
  /** The per-transport success outcome, discriminated by transport so the surface renders
64
- * each without guessing. `control-socket` carries the optional N3 `rejectReason`;
64
+ * each without guessing. `control-socket` carries the optional N3 `rejectReason` and,
65
+ * when a dead-socket re-resolve fell back to the mailbox, that leg's #98 R `messagePath`;
65
66
  * `meta-mailbox` is always `success:true` (enqueue has no in-band refuse — a failure is
66
- * a throw, handled as `execution-failed`). */
67
+ * a throw, handled as `execution-failed`) and carries the #98 R send receipt: the path of
68
+ * the `.msg` the enqueue actually wrote. OPTIONAL because a fake/legacy `sendMailbox` dep
69
+ * may omit it — a missing receipt must degrade the rendered line, never fail the delivery,
70
+ * and it is the ONLY enqueue-side datum carried (never a read timestamp; see
71
+ * `RpcSendResult`). */
67
72
  export type ExecutedOutcome =
68
- | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string }
69
- | { transport: "meta-mailbox"; success: true }
73
+ | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string; messagePath?: string }
74
+ | { transport: "meta-mailbox"; success: true; messagePath?: string }
70
75
  // native-push carries `retried` so the surface can note the 1-shot re-probe retry fired.
71
76
  | { transport: "native-push"; success: true; retried: boolean };
72
77
 
@@ -118,7 +123,12 @@ export async function executeDispatch(
118
123
  kind: "executed",
119
124
  receipt,
120
125
  transport,
121
- outcome: { transport: "control-socket", outcome: r.outcome, rejectReason: r.rejectReason },
126
+ outcome: {
127
+ transport: "control-socket",
128
+ outcome: r.outcome,
129
+ rejectReason: r.rejectReason,
130
+ messagePath: r.messagePath,
131
+ },
122
132
  };
123
133
  } catch (err) {
124
134
  // N1: a delivered/refused send whose release then threw — lock dirty, do NOT retry.
@@ -150,7 +160,14 @@ export async function executeDispatch(
150
160
  "entwurf-v2-runner: meta-mailbox send returned success:false (contract violation; a mailbox has no in-band reject).",
151
161
  );
152
162
  }
153
- return { kind: "executed", receipt, transport, outcome: { transport: "meta-mailbox", success: true } };
163
+ return {
164
+ kind: "executed",
165
+ receipt,
166
+ transport,
167
+ // #98 R: carry the enqueue receipt verbatim. `undefined` stays `undefined` —
168
+ // the runner never substitutes a guessed path for a missing one.
169
+ outcome: { transport: "meta-mailbox", success: true, messagePath: r.messagePath },
170
+ };
154
171
  } catch (err) {
155
172
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
156
173
  }
@@ -52,10 +52,20 @@ export type MetaMailboxPlan = Extract<ExecutionPlan, { transport: "meta-mailbox"
52
52
  export type SendFinalOutcome = "sent" | "fallback-sent" | "rejected" | "failed";
53
53
 
54
54
  /** What a single RPC / mailbox enqueue reports. `success:false` is an in-band reject
55
- * (the receiver answered and refused) — distinct from a thrown connect error. */
55
+ * (the receiver answered and refused) — distinct from a thrown connect error.
56
+ *
57
+ * `messagePath` is the SEND-side receipt (#98 R) and is OPTIONAL because only the
58
+ * mailbox rail has one: an enqueue writes a file, so there is a per-message artifact
59
+ * to name. A control-socket RPC hands the body to a live receiver and produces no
60
+ * file, so it leaves this undefined rather than inventing one. It carries the ENQUEUED
61
+ * path and NOTHING about reading: `lastReadAt` at enqueue time is the PREVIOUS
62
+ * message's read stamp, so surfacing it here would read as "my message was read" —
63
+ * the exact misreading #98 opened on. The per-message read receipt is the `.read`
64
+ * suffix on this very file, never a state.json slot. */
56
65
  export interface RpcSendResult {
57
66
  success: boolean;
58
67
  error?: string;
68
+ messagePath?: string;
59
69
  }
60
70
 
61
71
  /** The same-lock one-shot re-resolve result (5c-2b implements the resolver; 5c-2a
@@ -98,6 +108,14 @@ export interface ControlSocketSendResult {
98
108
  * 5d runner carries this verbatim so the surface can tell "in-band refusal" from
99
109
  * "no live route" — the N3 carry-over the hand boundary used to drop. */
100
110
  rejectReason?: string;
111
+ /** #98 R, fallback leg: the `.msg` a dead-socket re-resolve enqueued. Present ONLY
112
+ * when the fallback actually routed to the mailbox and the enqueue succeeded — a
113
+ * control-socket retry hands the body to a live receiver and writes no file, so it
114
+ * leaves this undefined rather than inventing one. Same rule as `RpcSendResult`: the
115
+ * ENQUEUED path and nothing about reading. Without it a `fallback-sent` is the one
116
+ * mailbox delivery whose sender gets no per-message identifier — the same letter the
117
+ * primary mailbox rail names. */
118
+ messagePath?: string;
101
119
  }
102
120
 
103
121
  // A drive step's verdict: the terminal outcome, plus the original error to RETHROW on
@@ -107,6 +125,8 @@ interface SendDrive {
107
125
  outcome: SendFinalOutcome;
108
126
  error?: unknown;
109
127
  rejectReason?: string;
128
+ /** #98 R receipt from the mailbox fallback leg (see `ControlSocketSendResult`). */
129
+ messagePath?: string;
110
130
  }
111
131
 
112
132
  /**
@@ -139,7 +159,7 @@ export async function executeControlSocketSend(
139
159
  drive = { outcome: "failed", error: err };
140
160
  }
141
161
  finalizeRelease(policy, deps, held, drive);
142
- return { outcome: drive.outcome, rejectReason: drive.rejectReason };
162
+ return { outcome: drive.outcome, rejectReason: drive.rejectReason, messagePath: drive.messagePath };
143
163
  }
144
164
 
145
165
  /** Drive the 1차 send and route a connect failure through the F3 split. */
@@ -208,7 +228,10 @@ async function driveDeadFallback(
208
228
  // hand never reaches for the mailbox on its own; only the resolver routes here.
209
229
  try {
210
230
  const r = await deps.sendViaMailbox(rePlan, lock);
211
- return { outcome: r.success ? "fallback-sent" : "rejected" };
231
+ // #98 R: this leg writes a `.msg` exactly like the primary mailbox rail, so it
232
+ // owes the sender the same per-message receipt. Carried only on success — a
233
+ // `rejected` enqueue wrote no file to name.
234
+ return r.success ? { outcome: "fallback-sent", messagePath: r.messagePath } : { outcome: "rejected" };
212
235
  } catch (err) {
213
236
  return { outcome: "failed", error: err };
214
237
  }
@@ -19,6 +19,7 @@
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
21
 
22
+ import { basename } from "node:path";
22
23
  import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
23
24
  import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
24
25
  import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
@@ -144,8 +145,13 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
144
145
  if (o.transport === "control-socket") {
145
146
  const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
146
147
  const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
148
+ // #98 R, fallback leg: a dead socket that re-resolved to the mailbox wrote a
149
+ // `.msg` — name it, exactly as the primary mailbox rail does. Absent on a
150
+ // socket-to-socket retry (no file) and on every non-mailbox outcome, so the
151
+ // line degrades to the bare outcome rather than printing "undefined".
152
+ const enqueued = o.messagePath ? ` (enqueued ${basename(o.messagePath)})` : "";
147
153
  return {
148
- text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
154
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}${enqueued}`,
149
155
  isError: !delivered,
150
156
  };
151
157
  }
@@ -156,8 +162,17 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
156
162
  isError: false,
157
163
  };
158
164
  }
159
- // meta-mailbox
160
- return { text: "entwurf_v2 meta-mailbox enqueued", isError: false };
165
+ // meta-mailbox. #98 R: name the FILE that was enqueued, so the sender's transcript
166
+ // carries a per-message identifier instead of a bare literal. Only the basename —
167
+ // the directory is `<meta-mailbox>/<target garden id>/`, which the caller already
168
+ // typed. Deliberately NOT a read stamp: at enqueue time `lastReadAt` belongs to the
169
+ // PREVIOUS message, so printing it would claim a read that has not happened.
170
+ // A dep that omits the receipt falls back to the old literal rather than printing
171
+ // "undefined" — the delivery still happened.
172
+ return {
173
+ text: `entwurf_v2 meta-mailbox → enqueued${o.messagePath ? ` (${basename(o.messagePath)})` : ""}`,
174
+ isError: false,
175
+ };
161
176
  }
162
177
  case "execution-failed": {
163
178
  if (result.releaseFailed && result.finalizedOutcome) {
@@ -2532,9 +2532,17 @@ export interface ReadMetaInboxResult {
2532
2532
  * Drain a garden citizen's mailbox: read every unread message (a fresh `.msg`
2533
2533
  * read before its doorbell, or a doorbell-rung `.msg.delivered`), archive each to
2534
2534
  * `*.read` so a re-read never double-returns, and — only if at least one message
2535
- * was read — stamp `lastReadAt` (NOT `lastDeliveredAt`: the doorbell owns
2536
- * delivery-time, see the stamp-site note below). An empty inbox mutates nothing:
2537
- * reading nothing is not a receipt.
2535
+ * was read — stamp `lastReadAt`. `lastDeliveredAt` stays untouched (see the
2536
+ * stamp-site note below). An empty inbox mutates nothing: reading nothing is not
2537
+ * a receipt.
2538
+ *
2539
+ * WHERE THE PER-MESSAGE TRUTH LIVES (#98 5a, corrected 2026-09-03). The FILE SUFFIX
2540
+ * is the per-message receipt: `.msg` = enqueued, `.msg.delivered` = the doorbell
2541
+ * rang for it, `.msg.delivered.read` = this function handed it to the reader.
2542
+ * `state.json` is NOT that — its three slots are GARDEN-WIDE and overwritten, so
2543
+ * `lastReadAt` says "this citizen last read something at T", never "message X was
2544
+ * read". A sender that quotes `lastReadAt` back as the fate of the letter it just
2545
+ * queued is quoting the PREVIOUS letter's read. That misreading is what opened #98.
2538
2546
  */
2539
2547
  export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2540
2548
  const now = opts.now ?? new Date();
@@ -2560,10 +2568,18 @@ export function readMetaInbox(opts: ReadMetaInboxOptions): ReadMetaInboxResult {
2560
2568
  return { gardenId: citizen.gardenId, messages, readAt: null, recordPath: recordFile };
2561
2569
  }
2562
2570
 
2563
- // 3D-4 the cut: the read receipt lives SOLELY in the mailbox state store now.
2564
- // Stamp lastReadAt — the one receipt this layer stamps honestly (it KNOWS the body
2565
- // reached the reader). lastDeliveredAt is the doorbell's to own; stamping it here
2566
- // would report read-time as delivery-time, so it is left as the doorbell left it.
2571
+ // 3D-4 the cut: the garden-wide read receipt lives SOLELY in the mailbox state store
2572
+ // now. Stamp lastReadAt — the one slot this layer stamps honestly (it KNOWS a body
2573
+ // reached the reader at this instant). lastDeliveredAt is left alone, and #98 5a
2574
+ // corrects WHY: the old comment said "the doorbell owns it", but the shipped
2575
+ // doorbell.sh (54 lines) writes NOTHING to state.json — measured, zero state writes.
2576
+ // So `lastDeliveredAt` is a RESERVED SLOT nobody stamps, permanently null on all
2577
+ // ~180 on-disk states, while 900+ files carry a `.delivered` suffix. Stamping it
2578
+ // here would still be wrong (it would report read-time as delivery-time), so it
2579
+ // stays null — but do not read "the doorbell will fill it in" into that. The
2580
+ // per-message delivery fact is the `.delivered` SUFFIX. The field is left in place
2581
+ // deliberately: removing it is a migration (the parser rejects unknown keys AND a
2582
+ // bumped schemaVersion, and every stamp re-parses first), tracked as #98 5b.
2567
2583
  // The state stamp returns the updated state, whose lastReadAt IS the D7 read-receipt.
2568
2584
  // Inside the messages.length>0 branch by construction — an empty inbox already
2569
2585
  // early-returned (no .read archive, state untouched), so "read nothing" is no
@@ -2603,6 +2619,15 @@ export const MAILBOX_RECEIPT_SCHEMA_VERSION = 1 as const;
2603
2619
  * The per-citizen mailbox receipt state. Holds exactly the three delivery
2604
2620
  * timestamps that move out of `record.delivery` (wakeMode/deliveryLevel are
2605
2621
  * capability, deliberately absent). Body is SSOT; the on-disk path is derived.
2622
+ *
2623
+ * SCOPE (#98 5a): these are GARDEN-WIDE "last activity" slots, single-valued and
2624
+ * overwritten — NOT per-message receipts. Per message, the receipt is the file
2625
+ * suffix in the same directory (`.msg` → `.msg.delivered` → `.msg.delivered.read`).
2626
+ * `lastEnqueuedAt`/`lastReadAt` are stamped by `enqueueMetaMessage`/`readMetaInbox`.
2627
+ * `lastDeliveredAt` is stamped by NOBODY: the doorbell does not write state.json, so
2628
+ * it is a reserved slot that is null everywhere. Kept rather than removed because the
2629
+ * parser is doubly strict (exact schemaVersion + no unknown keys) and every stamp
2630
+ * re-parses, so dropping it is a migration over the existing on-disk v1 files (#98 5b).
2606
2631
  */
2607
2632
  export interface MailboxReceiptState {
2608
2633
  schemaVersion: typeof MAILBOX_RECEIPT_SCHEMA_VERSION;
package/run.sh CHANGED
@@ -2700,6 +2700,24 @@ check_acp_event_mapper() {
2700
2700
  run_ts scripts/check-acp-event-mapper.ts
2701
2701
  }
2702
2702
 
2703
+ check_acp_usage_accounting() {
2704
+ # Deterministic gate for the ACP USAGE ACCOUNTING contract (#93). A long-lived
2705
+ # Claude ACP session's dashboard read 10-18x high on three live ledgers,
2706
+ # because the per-turn token partition was dropped at the type boundary while
2707
+ # the backend's RUNNING SESSION TOTAL was assigned to a per-turn cost field pi
2708
+ # then summed. Drives streamAcpTurn against a fake ACP child + connection whose
2709
+ # turns are scripted (usage_update notification + PromptResponse.usage) for
2710
+ # five cells: the four-way token partition reaches the pi message; per-turn
2711
+ # costs are adjacent diffs of the running total and sum back to it across a
2712
+ # reused session; a turn with no cost notification attributes $0 and HOLDS the
2713
+ # baseline; a decreasing total rebaselines, attributes $0 and tells the
2714
+ # operator; totalTokens stays CONTEXT OCCUPANCY (asserted through pi's own
2715
+ # calculateContextTokens) and carries forward; and cortex — no measured
2716
+ # extractor — keeps its pre-#93 output untouched.
2717
+ section "ACP usage accounting (turn partition + adjacent-diff cost)"
2718
+ run_ts scripts/check-acp-usage-accounting.ts
2719
+ }
2720
+
2703
2721
  check_acp_stop_reason() {
2704
2722
  # Deterministic gate for the ACP stop-reason contract. Drives every member of
2705
2723
  # the closed ACP terminal set (end_turn / max_tokens / max_turn_requests /
@@ -6520,6 +6538,9 @@ case "$cmd" in
6520
6538
  check-acp-event-mapper)
6521
6539
  check_acp_event_mapper
6522
6540
  ;;
6541
+ check-acp-usage-accounting)
6542
+ check_acp_usage_accounting
6543
+ ;;
6523
6544
  check-acp-stop-reason)
6524
6545
  check_acp_stop_reason
6525
6546
  ;;
@@ -2,8 +2,14 @@
2
2
  //
3
3
  // WHAT THIS EXISTS TO STOP. `mapPromptStopReason` used to be a bare
4
4
  // `switch` returning a StopReason with `default: return "stop"`. The ACP terminal
5
- // set is closed — `@agentclientprotocol/sdk` 1.3.0 `schema/types.gen` declares
6
- // `end_turn | max_tokens | max_turn_requests | refusal | cancelled` — so that
5
+ // set is closed — `@agentclientprotocol/sdk` 1.4.0 `dist/schema/types.gen.d.ts:3001`
6
+ // declares `end_turn | max_tokens | max_turn_requests | refusal | cancelled`, with
7
+ // no `| string` arm. Re-measured at the 1.3.0 → 1.4.0 bump rather than inherited,
8
+ // because 1.4.0 also ships a SECOND, OPEN union at `dist/v2/schema/types.gen.d.ts:3607`
9
+ // (`… | "cancelled" | string`). That one is reachable only through the
10
+ // `./experimental/v2` export; entwurf imports the BARE specifier, which the package
11
+ // `exports` map sends to `./dist/acp.js` — the closed v1 surface. The claim holds
12
+ // only while that import stays bare, so a move to the v2 export retires it — so that
7
13
  // default silently collapsed THREE distinct non-success outcomes into a clean
8
14
  // successful turn:
9
15
  //