@cohortapp/agent-sdk 2.4.0 → 2.5.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 (81) hide show
  1. package/bin/maestro.mjs +9 -0
  2. package/lib/backlog.mjs +35 -0
  3. package/lib/backlog.test.mjs +36 -0
  4. package/lib/channels/contract.mjs +1 -0
  5. package/lib/channels/contract.test.mjs +2 -1
  6. package/lib/channels/inbox-item.mjs +54 -0
  7. package/lib/comms/send-gate.mjs +56 -1
  8. package/lib/comms/send-gate.test.mjs +56 -0
  9. package/lib/execution/disposition.mjs +62 -2
  10. package/lib/execution/disposition.test.mjs +54 -0
  11. package/lib/execution/drive.mjs +1 -1
  12. package/lib/execution/effects.mjs +282 -24
  13. package/lib/execution/effects.test.mjs +112 -0
  14. package/lib/execution/index.mjs +1 -0
  15. package/lib/execution/intake.mjs +43 -9
  16. package/lib/execution/intake.test.mjs +46 -0
  17. package/lib/execution/pipeline.mjs +5 -0
  18. package/lib/execution/surface-policy.mjs +80 -30
  19. package/lib/goals/classify.mjs +49 -5
  20. package/lib/goals/classify.test.mjs +58 -0
  21. package/lib/goals/collaborate.mjs +131 -17
  22. package/lib/goals/collaborate.test.mjs +16 -4
  23. package/lib/goals/loop.mjs +160 -9
  24. package/lib/goals/loop.test.mjs +129 -3
  25. package/lib/kpi-sensors.mjs +666 -0
  26. package/lib/kpi-sensors.test.mjs +275 -0
  27. package/lib/kpi.mjs +23 -0
  28. package/lib/mandate/audit.mjs +3 -0
  29. package/lib/mandate/contract.mjs +277 -0
  30. package/lib/mandate/contract.test.mjs +185 -0
  31. package/lib/mandate/derive.mjs +49 -5
  32. package/lib/mandate/derive.test.mjs +7 -1
  33. package/lib/mandate/model.mjs +10 -1
  34. package/lib/mandate/model.test.mjs +22 -3
  35. package/lib/mandate/refresh.mjs +53 -5
  36. package/lib/mandate/refresh.test.mjs +83 -1
  37. package/lib/org/doctor.mjs +66 -0
  38. package/lib/org/doctor.test.mjs +73 -1
  39. package/lib/org/inbound/directedness.mjs +119 -1
  40. package/lib/org/inbound/directedness.test.mjs +67 -0
  41. package/lib/org/inbound/facts.mjs +132 -9
  42. package/lib/org/inbound/facts.test.mjs +96 -0
  43. package/lib/org/inbound/hydrate.mjs +40 -0
  44. package/lib/org/inbound/index.test.mjs +83 -0
  45. package/lib/org/inbound/project.mjs +8 -0
  46. package/lib/org/inbound/surfaces.mjs +20 -0
  47. package/lib/org/param-contract.mjs +16 -2
  48. package/lib/org/protocol.checksum +1 -1
  49. package/lib/org/protocol.mjs +214 -2
  50. package/lib/org/protocol.test.mjs +11 -2
  51. package/lib/org/push.mjs +213 -49
  52. package/lib/org/push.test.mjs +112 -10
  53. package/lib/plan/compile.mjs +85 -8
  54. package/lib/plan/compile.test.mjs +82 -0
  55. package/lib/plan/emit.test.mjs +6 -1
  56. package/lib/setup/enroll-from-cohort.mjs +22 -2
  57. package/lib/setup/enroll-from-cohort.test.mjs +25 -0
  58. package/lib/setup/sections/mandate.mjs +43 -1
  59. package/lib/subagents/schema.mjs +14 -2
  60. package/lib/subagents/schema.test.mjs +22 -0
  61. package/package.json +1 -1
  62. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  63. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  64. package/scripts/ci/check.mjs +3 -0
  65. package/scripts/ci/conformance-org-api.mjs +16 -0
  66. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  67. package/scripts/daemon/agent-daemon.mjs +582 -28
  68. package/scripts/daemon/cadence-handlers.mjs +273 -17
  69. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  70. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  71. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  72. package/scripts/daemon/maestro-daemon.mjs +53 -0
  73. package/scripts/daemon/prompt-builder.mjs +47 -0
  74. package/scripts/daemon/responder.mjs +70 -3
  75. package/scripts/poller/imap-client.mjs +20 -1
  76. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  77. package/scripts/poller/utils.mjs +51 -0
  78. package/scripts/setup/generate-capability.mjs +120 -11
  79. package/scripts/setup/generate-capability.test.mjs +134 -0
  80. package/scripts/setup/generate-plan.mjs +6 -1
  81. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
package/lib/org/push.mjs CHANGED
@@ -64,23 +64,35 @@
64
64
  * THE WIRE CONTRACT (what this expects of hq)
65
65
  * ============================================================================
66
66
  *
67
- * Rung 1 — GET `{base}/api/v1/agent.stream?cursor=<seq>`
68
- * Accept: `text/event-stream, application/x-ndjson`. Held open; each frame is
69
- * one line. BOTH framings are accepted by the parser below:
70
- * - SSE: `data: {...}\n\n`, with `:` comment lines as keepalives
71
- * - NDJSON: `{...}\n` (hq's house streaming format — see its AI gateway,
72
- * which returns `application/x-ndjson`; there is no SSE precedent
73
- * in that repo, so committing to only one framing would be a bet)
74
- * Frame bodies:
75
- * `{seq, family, kind, entityId, actor, at, payload}` — a committed event
76
- * `{type:"heartbeat"}` — liveness only
77
- * `{type:"ready", cursor}` — optional preamble
67
+ * VERIFIED AGAINST THE DEPLOYED APP (os.cohortapp.com), not inferred.
68
+ *
69
+ * Rung 1 — GET `{base}/api/v1/stream?cursor=<seq>`
70
+ * `stream`, not `agent.stream`: it is a static Next route
71
+ * (`hq src/app/api/v1/stream/route.ts`) that wins over the `[...method]`
72
+ * catch-all. Accept: `text/event-stream`. Held open (15-min lifetime cap),
73
+ * ~2KB comment preamble, 20s heartbeat comments, `X-Accel-Buffering: no`.
74
+ * Both framings are accepted by the parser below (SSE `data:` lines and bare
75
+ * NDJSON), because a future hq lane may use either.
76
+ * Frame bodies, all UNTYPED (no `type` field) — recognised structurally:
77
+ * `{seq, topic, reason, family, kind, entityId, actor, at, ids}` — an item
78
+ * ALREADY FILTERED TO THIS SEAT by hq (relevance.ts#selectForMember)
79
+ * `{orgId, memberId, cursor, heartbeatMs, lifetimeMs}` — `event: ready`
80
+ * `{cursor}` — `event: cursor`,
81
+ * the watermark past events that were NOT this seat's
82
+ * `{reason, cursor, message}` — `event: bye`
83
+ * `: …` comment lines — heartbeats
78
84
  * Anything unrecognised is ignored (forward-compatible).
79
85
  *
80
- * Rung 2 — GET `{base}/api/v1/agent.wait?cursor=<seq>&timeoutMs=55000`
81
- * Returns the BARE payload (hq's GET lane never wraps reads in an ok-frame):
82
- * `{ events: [ {seq, family, kind, ...}, ... ], cursor: <newHead> }`
83
- * Empty `events` after the timeout is a normal, successful response.
86
+ * Rung 2 — POST `{base}/api/v1/agent.wait` `{since?, timeoutMs, limit?}`
87
+ * A METHOD on the dispatcher, NOT a GET read (the read lane's HANDLED_READS
88
+ * list does not contain it — see waitOnce for the full autopsy). Returns the
89
+ * standard ok-frame wrapping:
90
+ * `{ cursor, items: [{seq, family, kind, entityId, actor, at, reason, ref}],
91
+ * timedOut, bootstrap, memberId, waitedMs }`
92
+ * `reason` is hq's RELATIONAL verdict (mention/channel/assignee/reviewer/
93
+ * call_participant/mailbox_owner/mailbox_member), resolved server-side against
94
+ * the projection tables — the chain payload is redacted and cannot name a
95
+ * recipient. Empty `items` after the timeout is a normal, successful response.
84
96
  *
85
97
  * Both lanes key off `CohortEvent.seq` — the per-org monotonic, DB-unique,
86
98
  * gap-free ledger cursor hq's own poll route uses. Not the 30s ephemeral ring,
@@ -117,17 +129,32 @@ export const PUSH_RUNG = Object.freeze({
117
129
  CADENCE: "cadence",
118
130
  });
119
131
 
120
- /** Read path for the held stream (rung 1). */
121
- export const DEFAULT_STREAM_PATH = "agent.stream";
122
- /** Read path for the bounded long-poll (rung 2). */
132
+ /**
133
+ * Path for the held stream (rung 1).
134
+ *
135
+ * `stream`, NOT `agent.stream`. hq serves this as an ORDINARY Next route at
136
+ * `src/app/api/v1/stream/route.ts` — a static segment that wins over the
137
+ * `[...method]` catch-all. `agent.stream` was a guess made before the server
138
+ * existed, and it resolved to the catch-all, which answered `NOT_FOUND` for an
139
+ * unknown read: the ladder read that as "this hq has no stream endpoint",
140
+ * demoted instantly and never probed rung 1 again. Verified against the
141
+ * deployed app: `GET /api/v1/stream` → 200 `text/event-stream`.
142
+ */
143
+ export const DEFAULT_STREAM_PATH = "stream";
144
+ /** Method name for the bounded long-poll (rung 2). A POST RPC — see waitOnce. */
123
145
  export const DEFAULT_WAIT_PATH = "agent.wait";
124
146
 
125
147
  /**
126
- * Long-poll hold, ms. 55s is not arbitrary: it is the bound hq's `approval.wait`
127
- * clamps to and the only hold length PROVEN to survive Railway's proxy in front
128
- * of `next start`. Do not raise it without measuring against the deployed app.
148
+ * Long-poll hold, ms.
149
+ *
150
+ * 25s, not 55s. `approval.wait` holds 55s and proves Railway tolerates it, but
151
+ * `agent.wait` deliberately caps itself at 25s (hq `methods/agent/wait.ts`
152
+ * MAX_WAIT_MS) because 30s is the tightest idle-read timeout in the common proxy
153
+ * layer between a daemon and the app. Asking for 55s does not get 55s — hq
154
+ * clamps it — it only makes OUR abort budget wrong, so we would tear down a
155
+ * healthy hold that was about to answer. Ask for what the server actually gives.
129
156
  */
130
- export const DEFAULT_WAIT_MS = 55_000;
157
+ export const DEFAULT_WAIT_MS = 25_000;
131
158
 
132
159
  /**
133
160
  * Recycle a held stream after this long even when it looks healthy (ms). A
@@ -175,11 +202,47 @@ export const STATUS_RELATIVE = "state/org/push-status.json";
175
202
  * replace, or disable any of them without touching this module.
176
203
  */
177
204
  export const FAMILY_WAKE_TARGET = Object.freeze({
205
+ // ── The INBOUND families. Every one of these can carry something addressed to
206
+ // this seat, and every one is resolved authoritatively by the wide inbound
207
+ // reader behind the `messaging-inbound` cadence
208
+ // (lib/org/inbound.pullWideInbound). They therefore all wake the SAME
209
+ // target, which is the whole point: one nudge, one authoritative pull, one
210
+ // cursor, one dedup-guarded write.
211
+ //
212
+ // This map used to send `board`/`work` to a "board" target whose handler
213
+ // escalates (spawns a session), and to name no target at all for files,
214
+ // decision, escalation, approval, handoff or calendar — so a task assigned
215
+ // to this agent produced NO wake, and the only thing that would ever find
216
+ // it was a cadence tick against a reader that filtered the family out. That
217
+ // is the shape of "board, task, calendar, escalation, approval and mandate
218
+ // events never reach a daemon at all".
178
219
  messaging: "org-messaging",
179
220
  calling: "org-messaging",
221
+ board: "org-messaging",
222
+ work: "org-messaging",
223
+ file: "org-messaging",
224
+ files: "org-messaging",
225
+ decision: "org-messaging",
226
+ escalation: "org-messaging",
227
+ approval: "org-messaging",
228
+ handoff: "org-messaging",
229
+ calendar: "org-messaging",
230
+ meetings: "org-messaging",
231
+ // `mandate.*` is a governance lane: adopting/retiring a mandate changes what
232
+ // this seat is FOR. It gets its OWN target rather than being folded into
233
+ // `org-messaging`, because the wide inbound reader has no `mandate` branch —
234
+ // waking it would spend a pull that structurally cannot see the event. With no
235
+ // default handler this target logs once and degrades to the plan refresh's own
236
+ // cadence, which is honest; an agent repo that wants instant mandate reload
237
+ // passes `wake: { mandate: () => … }`.
238
+ mandate: "mandate",
239
+
240
+ // ── Lanes with their OWN fetcher. Email is polled by the orgmail channel
241
+ // adapter (which writes its own inbox items) — waking the wide reader too
242
+ // would double-deliver every mail into a thread-lease race, which is
243
+ // exactly why the `email` inbound surface defaults OFF. Branding has its
244
+ // own steward cadence.
180
245
  email: "orgmail",
181
- board: "board",
182
- work: "board",
183
246
  branding: "brand",
184
247
  });
185
248
 
@@ -335,13 +398,32 @@ export function decodeFrameLine(line) {
335
398
  if (type === "error") return { kind: "malformed" }; // a server-side error frame ends the read
336
399
 
337
400
  // An event either says so, or is recognised structurally: hq's own fan-out
338
- // frame is {family, kind, entityId, actor, at, seq, payload} with NO `type`.
401
+ // frame is {seq, topic, reason, family, kind, entityId, actor, at, ids} with
402
+ // NO `type` (src/server/agent-stream/relevance.ts#selectForMember).
339
403
  const seq = Number(obj.seq);
340
404
  const hasShape = Number.isFinite(seq) && typeof obj.family === "string" && obj.family !== "";
341
405
  if (type === "event" || hasShape) {
342
406
  if (!hasShape) return { kind: "malformed" }; // claimed to be an event, isn't one
343
407
  return { kind: "event", event: obj };
344
408
  }
409
+
410
+ // ── WATERMARK FRAMES. hq emits three untyped frames that carry only a cursor:
411
+ // event: ready {orgId, memberId, cursor, heartbeatMs, lifetimeMs}
412
+ // event: cursor {cursor}
413
+ // event: bye {reason, cursor, message}
414
+ // The `cursor` one is NOT optional bookkeeping — it is how the server moves
415
+ // our resume watermark past events that were NOT for this seat. Verified on
416
+ // the deployed app: a 20s idle hold emitted `data: {"cursor":12766}` after a
417
+ // `ready` at 12600, i.e. 166 events of other members' business. Treating it
418
+ // as `ignore` (which this parser did) means every reconnect re-asks hq to
419
+ // re-scan that whole span, and it grows without bound in a busy org.
420
+ //
421
+ // We key off the SHAPE — a numeric `cursor` and nothing event-like — rather
422
+ // than the SSE `event:` field, because the field arrives on a different line
423
+ // and this decoder is deliberately line-at-a-time and stateless.
424
+ const c = Number(obj.cursor);
425
+ if (Number.isFinite(c) && c >= 0) return { kind: "cursor", cursor: c };
426
+
345
427
  return { kind: "ignore" }; // forward-compatible: unknown frame types are fine
346
428
  }
347
429
 
@@ -366,7 +448,23 @@ export function decodeFrameLine(line) {
366
448
  function wakeTargetFor(ctx, ev) {
367
449
  const family = typeof ev.family === "string" ? ev.family : "";
368
450
  const target = FAMILY_WAKE_TARGET[family];
369
- if (!target) return null;
451
+ if (!target) {
452
+ // NEVER SILENT. hq's SSE lane classifies eleven families
453
+ // (`agent-stream/relevance.ts`) and every frame it sends has ALREADY been
454
+ // proven to concern this seat. A family with no entry here is therefore a
455
+ // frame hq went to the trouble of addressing to us, that we consume and
456
+ // throw away. That is a delivery gap, not a filter — say so, once per
457
+ // family, so it shows up as a line rather than as an agent that "seems
458
+ // slow on artifacts".
459
+ if (family && !ctx.warnedTargets.has(`family:${family}`)) {
460
+ ctx.warnedTargets.add(`family:${family}`);
461
+ logWarn(
462
+ `hq pushed a "${family}" frame it had already resolved as THIS SEAT'S, and nothing here consumes that family — ` +
463
+ `it is being dropped. Add it to FAMILY_WAKE_TARGET (and give the wide inbound reader a branch) to close the gap.`,
464
+ );
465
+ }
466
+ return null;
467
+ }
370
468
 
371
469
  // Our own committed action echoing back is not news. Only applies when the
372
470
  // caller told us which ids are "us" — otherwise we cannot know, so we wake.
@@ -507,6 +605,31 @@ function dispatchEvent(ctx, ev) {
507
605
  return outcome;
508
606
  }
509
607
 
608
+ /**
609
+ * Move the persisted watermark to a server-reported head. FORWARD ONLY.
610
+ *
611
+ * Both lanes report a head that can be AHEAD of the last frame they sent us:
612
+ * the SSE `cursor` checkpoint and `agent.wait`'s `cursor` both mean "I have
613
+ * EVALUATED up to here and nothing else in that span was yours." Honouring it is
614
+ * what stops every reconnect re-scanning other members' business; refusing to
615
+ * rewind is what stops a stale or mis-ordered frame replaying work.
616
+ *
617
+ * @param {object} ctx
618
+ * @param {number|undefined} head
619
+ * @param {string} why frame kind, for the log
620
+ * @returns {boolean} whether the cursor moved
621
+ */
622
+ function advanceCursor(ctx, head, why) {
623
+ const n = Number(head);
624
+ if (!Number.isFinite(n) || n < 0) return false;
625
+ if (ctx.cursor != null && n <= ctx.cursor) return false;
626
+ ctx.cursor = n;
627
+ writeCursor(ctx.agentRoot, n, ctx.now);
628
+ ctx.stats.checkpoints = (ctx.stats.checkpoints || 0) + 1;
629
+ if (why === "ready") logInfo(`resumed at ledger cursor ${n}`);
630
+ return true;
631
+ }
632
+
510
633
  // ---------------------------------------------------------------------------
511
634
  // URLs + headers
512
635
  // ---------------------------------------------------------------------------
@@ -648,6 +771,11 @@ async function consumeStream(ctx) {
648
771
  const frame = decodeFrameLine(line);
649
772
  if (frame.kind === "event") {
650
773
  if (dispatchEvent(ctx, frame.event) === "delivered") delivered += 1;
774
+ } else if (frame.kind === "cursor" || frame.kind === "ready") {
775
+ // A server watermark. Forward-only, always — a rewind would replay
776
+ // events we have already woken on, and the cursor is the ONE piece of
777
+ // durable state this module owns.
778
+ advanceCursor(ctx, frame.cursor, frame.kind);
651
779
  } else if (frame.kind === "malformed") {
652
780
  // ONE bad frame must not cost the connection: count it, keep reading.
653
781
  ctx.stats.malformed += 1;
@@ -683,20 +811,49 @@ async function consumeStream(ctx) {
683
811
  // ---------------------------------------------------------------------------
684
812
 
685
813
  /**
686
- * One `agent.wait` round-trip. Follows the `approval.wait` precedent exactly:
687
- * a GET on the read lane returning the BARE payload, with the client abort
688
- * budgeted to outlast the server's own bound so the server gets to answer rather
814
+ * One `agent.wait` round-trip.
815
+ *
816
+ * ── IT IS A POST RPC, NOT A GET READ ──
817
+ * This used to call `client.read("agent.wait?cursor=…&timeoutMs=…")`, i.e. a GET
818
+ * on the `/v1` read lane. That lane dispatches from a CLOSED list
819
+ * (`hq src/server/rpc/reads.ts#HANDLED_READS`: snapshot, directory, events,
820
+ * board.ready, hierarchy, board.context, ops, approval.wait, cost.rollup,
821
+ * decision.list, policy, contacts.list, meetings.list, subagent.roster) —
822
+ * `agent.wait` is not on it and never was. Every rung-2 attempt therefore came
823
+ * back `NOT_FOUND` with HTTP 404, which `isUnsupportedStatus` reads as "this hq
824
+ * doesn't have the endpoint": instant demotion to CADENCE, no retry, no probe.
825
+ * The long-poll rung could not have worked on any hq, ever.
826
+ *
827
+ * `agent.wait` is a `sideEffecting:false` METHOD — `POST /api/v1/agent.wait`
828
+ * through the dispatcher (`hq src/server/methods/agent/wait.ts`). Its params are
829
+ * `{since, timeoutMs, limit}` under a `.strict()` zod schema, so `cursor` would
830
+ * have been rejected as an unknown key even on the right verb.
831
+ *
832
+ * ── THE RETURN CONTRACT IS `items`, NOT `events` ──
833
+ * `{cursor, items, timedOut, bootstrap, memberId, waitedMs}`. Each item is
834
+ * `{seq, family, kind, entityId, actor, at, reason, ref}` — ledger coordinates
835
+ * plus a RELATIONAL `reason` ("mention" | "channel" | "assignee" | "reviewer" |
836
+ * "call_participant" | "mailbox_owner" | "mailbox_member") that hq derived by
837
+ * joining the projection tables that name a seat. No body, no subject, no
838
+ * payload. That is the ACL-safe way to learn "this one is for me": the server
839
+ * resolved it against Mention / ChannelMember / Task / CallParticipant / Mailbox
840
+ * under `ctx.scopeWhere`, and we never widened a chain payload to carry it.
841
+ *
842
+ * The abort budget outlasts the server's own bound so hq gets to answer rather
689
843
  * than us aborting first. Never throws.
690
844
  *
691
845
  * @returns {Promise<{ok:boolean, unsupported?:boolean, reason:string, delivered:number}>}
692
846
  */
693
847
  async function waitOnce(ctx) {
694
848
  const startedAt = ctx.now();
695
- const q = ctx.cursor != null ? `?cursor=${encodeURIComponent(ctx.cursor)}&` : "?";
696
- const path = `${ctx.waitPath}${q}timeoutMs=${encodeURIComponent(ctx.waitMs)}`;
849
+ const params = { timeoutMs: ctx.waitMs };
850
+ // `since` ABSENT means "bootstrap at head, replay nothing" — the same
851
+ // distinction readCursor preserves. Sending 0 would replay the org's history.
852
+ if (ctx.cursor != null) params.since = ctx.cursor;
853
+
697
854
  let r;
698
855
  try {
699
- r = await ctx.client.read(path, {
856
+ r = await ctx.client.call(ctx.waitPath, params, {
700
857
  base: ctx.base,
701
858
  token: ctx.token,
702
859
  orgId: ctx.orgId,
@@ -704,36 +861,42 @@ async function waitOnce(ctx) {
704
861
  timeoutMs: ctx.waitMs + 5_000,
705
862
  });
706
863
  } catch (err) {
707
- // client.read is documented fail-open, but a caller must never assume.
864
+ // client.call is documented fail-open, but a caller must never assume.
708
865
  return { ok: false, reason: `wait threw: ${err && err.message}`, delivered: 0, lived: ctx.now() - startedAt };
709
866
  }
710
- if (!r || !r.ok) {
867
+ if (!r || r.ok !== true) {
711
868
  const status = Number(r && r.status) || 0;
712
- if (isUnsupportedStatus(status)) return { ok: false, unsupported: true, reason: `http ${status} (endpoint absent)`, delivered: 0, lived: ctx.now() - startedAt };
713
869
  const code = (r && r.error && r.error.code) || "UNKNOWN";
870
+ // A method this hq does not carry answers NOT_FOUND (the dispatcher's own
871
+ // frame) or 404/405/501 at the transport. Both mean "stop asking".
872
+ if (isUnsupportedStatus(status) || code === "NOT_FOUND") {
873
+ return { ok: false, unsupported: true, reason: `${code}${status ? ` (http ${status})` : ""} — endpoint absent`, delivered: 0, lived: ctx.now() - startedAt };
874
+ }
714
875
  return { ok: false, reason: `${code}${status ? ` (http ${status})` : ""}`, delivered: 0, lived: ctx.now() - startedAt };
715
876
  }
716
877
 
717
- const payload = r.payload || {};
718
- const events = Array.isArray(payload.events) ? payload.events : Array.isArray(payload) ? payload : [];
878
+ const result = (r.result && typeof r.result === "object" ? r.result : null) || {};
879
+ // `items` is the contract. `events` is tolerated so an older/newer hq, or the
880
+ // reference server, does not silently deliver nothing.
881
+ const items = Array.isArray(result.items)
882
+ ? result.items
883
+ : Array.isArray(result.events)
884
+ ? result.events
885
+ : [];
719
886
  let delivered = 0;
720
- for (const ev of events) {
887
+ for (const it of items) {
721
888
  if (ctx.stopped) break;
722
- const outcome = dispatchEvent(ctx, ev);
723
- if (outcome === "delivered") delivered += 1;
724
- }
725
- // hq may report a head beyond the last event it sent (e.g. everything in the
726
- // slice was filtered server-side). Trust it forward-only — never rewind.
727
- const head = Number(payload.cursor);
728
- if (Number.isFinite(head) && head > 0 && (ctx.cursor == null || head > ctx.cursor)) {
729
- ctx.cursor = head;
730
- writeCursor(ctx.agentRoot, head, ctx.now);
889
+ if (dispatchEvent(ctx, it) === "delivered") delivered += 1;
731
890
  }
891
+ // hq reports a head beyond the last item it sent whenever the span it
892
+ // evaluated held other members' business. Forward-only — never rewind.
893
+ advanceCursor(ctx, result.cursor, "wait");
894
+
732
895
  return {
733
896
  ok: true,
734
- reason: events.length ? `${events.length} event(s)` : "timeout (no events)",
897
+ reason: items.length ? `${items.length} item(s) for this seat` : "timeout (nothing for this seat)",
735
898
  delivered,
736
- events: events.length,
899
+ events: items.length,
737
900
  lived: ctx.now() - startedAt,
738
901
  };
739
902
  }
@@ -1013,6 +1176,7 @@ export const _internals = {
1013
1176
  consumeStream,
1014
1177
  waitOnce,
1015
1178
  dispatchEvent,
1179
+ advanceCursor,
1016
1180
  scheduleWake,
1017
1181
  wakeTargetFor,
1018
1182
  setRung,
@@ -49,18 +49,33 @@ function cfgDisabled() {
49
49
  return { org: { cohort: { enabled: false, base: BASE, token: "tok" } } };
50
50
  }
51
51
 
52
- /** A fake org client: only what push.mjs uses (isEnabled/configFromAgent/read). */
52
+ /**
53
+ * A fake org client: only what push.mjs uses.
54
+ *
55
+ * `call` is the LONG-POLL seam. `agent.wait` is a POST METHOD on hq's
56
+ * dispatcher, not a GET read — it is absent from `reads.ts#HANDLED_READS`, so
57
+ * the GET this suite used to assert would have 404'd against every hq that has
58
+ * ever existed. The ok-frame shape below (`{ok:true, result:{cursor, items}}`)
59
+ * is hq `methods/agent/wait.ts`'s documented return contract.
60
+ */
53
61
  function fakeClient(opts = {}) {
54
62
  const c = {
55
63
  isEnabled: (cfg) => !!(cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.enabled),
56
64
  configFromAgent: () => ({ base: BASE, token: "tok", orgId: "acme", enabled: true }),
57
65
  readCalls: [],
66
+ callCalls: [],
58
67
  _reads: opts.reads ? opts.reads.slice() : [],
68
+ _calls: opts.calls ? opts.calls.slice() : [],
59
69
  read(path, o) {
60
70
  c.readCalls.push({ path, o });
61
71
  const next = c._reads.length ? c._reads.shift() : { ok: true, payload: { events: [] }, status: 200 };
62
72
  return Promise.resolve(typeof next === "function" ? next(path, o) : next);
63
73
  },
74
+ call(method, params, o) {
75
+ c.callCalls.push({ method, params, o });
76
+ const next = c._calls.length ? c._calls.shift() : { ok: true, status: 200, result: { items: [], cursor: null, timedOut: true } };
77
+ return Promise.resolve(typeof next === "function" ? next(method, params, o) : next);
78
+ },
64
79
  };
65
80
  return c;
66
81
  }
@@ -315,7 +330,7 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
315
330
  new Error("ECONNRESET"),
316
331
  ]);
317
332
  const client = fakeClient({
318
- reads: [{ ok: true, status: 200, payload: { events: [JSON.parse(EV(100)), JSON.parse(EV(101))], cursor: 101 } }],
333
+ calls: [{ ok: true, status: 200, result: { items: [JSON.parse(EV(100)), JSON.parse(EV(101))], cursor: 101, timedOut: false } }],
319
334
  });
320
335
  const { handle, ctx } = await mkHandle(root, {
321
336
  fetchImpl, client, wakeCoalesceMs: 0, enqueueTick: (t) => woke.push(t),
@@ -333,9 +348,10 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
333
348
 
334
349
  await _internals.ladderStep(ctx);
335
350
  await flush();
336
- assert.equal(client.readCalls.length, 1, "the long-poll lane took over");
337
- assert.match(client.readCalls[0].path, /^agent\.wait\?/);
338
- assert.match(client.readCalls[0].path, /timeoutMs=55000/, "the 55s bound approval.wait proved");
351
+ assert.equal(client.callCalls.length, 1, "the long-poll lane took over");
352
+ assert.equal(client.readCalls.length, 0, "agent.wait is a POST method — the GET read lane has no handler for it and 404s");
353
+ assert.equal(client.callCalls[0].method, "agent.wait");
354
+ assert.equal(client.callCalls[0].params.timeoutMs, 25_000, "hq caps agent.wait at 25s (MAX_WAIT_MS); asking for 55s only makes our abort budget wrong");
339
355
  assert.equal(ctx.cursor, 101);
340
356
  assert.deepEqual(woke.map((w) => w.metadata.pushSeq), [100, 101]);
341
357
  handle.stop();
@@ -344,7 +360,7 @@ test("ladder: repeated stream failures demote to the long-poll, which then deliv
344
360
  test("ladder: repeated long-poll failures demote to CADENCE, which idles instead of hammering", async () => {
345
361
  const root = tmpRepo();
346
362
  const client = fakeClient({
347
- reads: [
363
+ calls: [
348
364
  { ok: false, status: 500, error: { code: "INTERNAL" } },
349
365
  { ok: false, status: 500, error: { code: "INTERNAL" } },
350
366
  { ok: false, status: 0, error: { code: "INTERNAL" } },
@@ -361,9 +377,9 @@ test("ladder: repeated long-poll failures demote to CADENCE, which idles instead
361
377
  await _internals.ladderStep(ctx);
362
378
  assert.equal(ctx.rung, PUSH_RUNG.CADENCE, "the floor of the ladder");
363
379
 
364
- const before = client.readCalls.length;
380
+ const before = client.callCalls.length;
365
381
  const delay = await _internals.ladderStep(ctx);
366
- assert.equal(client.readCalls.length, before, "the cadence rung issues NO requests — it is the 45s poll doing the work");
382
+ assert.equal(client.callCalls.length, before, "the cadence rung issues NO requests — it is the 45s poll doing the work");
367
383
  assert.ok(delay >= 1_000, "it sleeps out the dwell rather than spinning");
368
384
 
369
385
  // The status file must say, out loud, which lane the agent is on.
@@ -396,7 +412,7 @@ test("ladder: a lower rung is probed back up to the stream after the dwell perio
396
412
  test("ladder: a 404 (hq has no such endpoint) demotes INSTANTLY, with no retries and no backoff", async () => {
397
413
  const root = tmpRepo();
398
414
  const fetchImpl = fakeFetch([{ status: 404, body: null }]);
399
- const client = fakeClient({ reads: [{ ok: false, status: 404, error: { code: "NOT_FOUND" } }] });
415
+ const client = fakeClient({ calls: [{ ok: false, status: 404, error: { code: "NOT_FOUND" } }] });
400
416
  const { handle, ctx } = await mkHandle(root, { fetchImpl, client, demoteAfter: 3 });
401
417
 
402
418
  const d1 = await _internals.ladderStep(ctx);
@@ -514,7 +530,7 @@ test("a long-poll that answers INSTANTLY with nothing is floored, not spun", asy
514
530
  // A half-implemented (or proxy-terminated) agent.wait that returns at once
515
531
  // instead of holding would otherwise re-arm at wire speed against hq.
516
532
  let clock = 7_000;
517
- const client = fakeClient({ reads: [{ ok: true, status: 200, payload: { events: [] } }] });
533
+ const client = fakeClient({ calls: [{ ok: true, status: 200, result: { items: [], cursor: null, timedOut: true } }] });
518
534
  const { handle, ctx } = await mkHandle(root, {
519
535
  client, now: () => clock, startRung: PUSH_RUNG.LONGPOLL, baseBackoffMs: 750, minHealthyMs: 2_000,
520
536
  });
@@ -688,3 +704,89 @@ test("housekeeping: tmp roots are removable (no lingering handles)", () => {
688
704
  rmSync(root, { recursive: true, force: true });
689
705
  assert.equal(existsSync(root), false);
690
706
  });
707
+
708
+ // ---------------------------------------------------------------------------
709
+ // JOINT 3 — the wire contract, corrected against the DEPLOYED hq
710
+ // ---------------------------------------------------------------------------
711
+
712
+ test("JOINT 3: the stream path is /api/v1/stream, not /api/v1/agent.stream", async () => {
713
+ const root = tmpRepo();
714
+ const fetchImpl = fakeFetch([streamResponse([EV(5)])]);
715
+ const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
716
+ await _internals.ladderStep(ctx);
717
+ // `agent.stream` resolves to hq's [...method] catch-all, which answers 404 for
718
+ // an unknown read — read by the ladder as "endpoint absent" and demoted
719
+ // instantly, forever. hq serves the stream as a STATIC route at /api/v1/stream.
720
+ assert.match(fetchImpl.calls[0].url, /\/api\/v1\/stream(\?|$)/);
721
+ assert.ok(!/agent\.stream/.test(fetchImpl.calls[0].url), "agent.stream is a path that has never existed on hq");
722
+ handle.stop();
723
+ });
724
+
725
+ test("JOINT 3: hq's untyped `cursor` watermark frame advances the resume point", () => {
726
+ // These are hq's REAL frames (src/server/agent-stream/sse.ts): no `type`, no
727
+ // `seq`, no `family` — the old decoder returned "ignore" for all three, so a
728
+ // reconnect re-asked hq to rescan every event that was not this seat's.
729
+ assert.deepEqual(decodeFrameLine('data: {"cursor":12766}'), { kind: "cursor", cursor: 12766 });
730
+ assert.deepEqual(
731
+ decodeFrameLine('data: {"orgId":"o","memberId":"m","cursor":12600,"heartbeatMs":20000,"lifetimeMs":900000}'),
732
+ { kind: "cursor", cursor: 12600 },
733
+ );
734
+ assert.deepEqual(decodeFrameLine('data: {"reason":"lifetime","cursor":13000,"message":"reconnect"}'), { kind: "cursor", cursor: 13000 });
735
+ // An event frame is still an event frame — the watermark branch must not eat it.
736
+ assert.equal(decodeFrameLine(`data: ${EV(9)}`).kind, "event");
737
+ });
738
+
739
+ test("JOINT 3: a watermark frame moves the persisted cursor FORWARD ONLY", async () => {
740
+ const root = tmpRepo();
741
+ const fetchImpl = fakeFetch([
742
+ streamResponse([
743
+ 'data: {"orgId":"o","memberId":"m","cursor":500}',
744
+ EV(501),
745
+ 'data: {"cursor":900}', // 399 events that were NOT ours
746
+ 'data: {"cursor":400}', // a stale/mis-ordered frame must not rewind us
747
+ ]),
748
+ ]);
749
+ const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
750
+ await _internals.ladderStep(ctx);
751
+ await flush();
752
+ assert.equal(ctx.cursor, 900, "the checkpoint past other members' business is honoured");
753
+ assert.equal(readCursor(root), 900, "and persisted, so a restart does not rescan it");
754
+ handle.stop();
755
+ });
756
+
757
+ test("JOINT 3: every inbound family wakes the authoritative pull — board included", async () => {
758
+ const root = tmpRepo();
759
+ const woke = [];
760
+ const families = ["messaging", "board", "calendar", "escalation", "approval", "decision", "files", "handoff", "calling"];
761
+ const lines = families.map((f, i) => EV(200 + i, f, "x"));
762
+ const fetchImpl = fakeFetch([streamResponse(lines)]);
763
+ const { handle, ctx } = await mkHandle(root, {
764
+ fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: (t) => woke.push(t),
765
+ });
766
+ await _internals.ladderStep(ctx);
767
+ await flush();
768
+ assert.equal(woke.length, families.length, `every inbound family must wake something; got ${woke.length}/${families.length}`);
769
+ assert.ok(woke.every((w) => w.cadence === "messaging-inbound"), "one nudge, one authoritative ACL'd pull, one cursor");
770
+ handle.stop();
771
+ });
772
+
773
+ test("JOINT 3: a family hq proved is ours but nothing consumes is dropped LOUDLY", async () => {
774
+ const root = tmpRepo();
775
+ const warns = [];
776
+ const realWarn = console.warn;
777
+ console.warn = (...a) => warns.push(a.join(" "));
778
+ try {
779
+ const fetchImpl = fakeFetch([streamResponse([EV(300, "artifact", "action")])]);
780
+ const { handle, ctx } = await mkHandle(root, { fetchImpl, wakeCoalesceMs: 0, minHealthyMs: 0, enqueueTick: () => {} });
781
+ await _internals.ladderStep(ctx);
782
+ await flush();
783
+ assert.equal(ctx.cursor, 300, "it is still consumed — not advancing would re-deliver it forever");
784
+ assert.ok(
785
+ warns.some((w) => /"artifact" frame it had already resolved as THIS SEAT'S/.test(w)),
786
+ `a dropped for-me frame must be visible; got ${JSON.stringify(warns)}`,
787
+ );
788
+ handle.stop();
789
+ } finally {
790
+ console.warn = realWarn;
791
+ }
792
+ });