@cohortapp/agent-sdk 2.4.1 → 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 (79) 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/sections/mandate.mjs +43 -1
  57. package/lib/subagents/schema.mjs +14 -2
  58. package/lib/subagents/schema.test.mjs +22 -0
  59. package/package.json +1 -1
  60. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  61. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  62. package/scripts/ci/check.mjs +3 -0
  63. package/scripts/ci/conformance-org-api.mjs +16 -0
  64. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  65. package/scripts/daemon/agent-daemon.mjs +582 -28
  66. package/scripts/daemon/cadence-handlers.mjs +273 -17
  67. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  68. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  69. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  70. package/scripts/daemon/maestro-daemon.mjs +53 -0
  71. package/scripts/daemon/prompt-builder.mjs +47 -0
  72. package/scripts/daemon/responder.mjs +70 -3
  73. package/scripts/poller/imap-client.mjs +20 -1
  74. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  75. package/scripts/poller/utils.mjs +51 -0
  76. package/scripts/setup/generate-capability.mjs +120 -11
  77. package/scripts/setup/generate-capability.test.mjs +134 -0
  78. package/scripts/setup/generate-plan.mjs +6 -1
  79. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -14,7 +14,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
16
 
17
- import { checkOrgConnectivity } from "./doctor.mjs";
17
+ import { checkOrgConnectivity, checkReactiveLaneWiring } from "./doctor.mjs";
18
18
  import { PROTOCOL_VERSION } from "./protocol.mjs";
19
19
 
20
20
  // Ambient COHORT_* vars must not leak in.
@@ -239,3 +239,75 @@ test("inbound probe FAILS loudly when the server rejects this SDK's params", ()
239
239
  rmSync(r, { recursive: true, force: true });
240
240
  });
241
241
  });
242
+
243
+ /* ── probe 0: reactive-lane wiring (local, no network) ─────────────────────
244
+ * REGRESSION — observed live 2026-08-11 on a real seat. The agent repo carried
245
+ * a CURRENT lib/execution/ and a STALE scripts/daemon/agent-daemon.mjs, so
246
+ * `maestro-daemon.mjs` (which resolves ./agent-daemon.mjs from the agent's own
247
+ * dir) ran a build with no `runExecutionLadder` call. The wide inbound was
248
+ * current to within one ledger row and every event was still answered, so the
249
+ * agent looked healthy — while intake / obligation-match / rung / journal never
250
+ * ran at all and state/execution/journal.jsonl stayed empty. Nothing detected
251
+ * it, because the new build cannot warn about the old one.
252
+ */
253
+
254
+ function laneRoot({ daemon, pipeline = true }) {
255
+ const r = mkdtempSync(join(tmpdir(), "org-doctor-lane-"));
256
+ if (pipeline) {
257
+ mkdirSync(join(r, "lib", "execution"), { recursive: true });
258
+ writeFileSync(join(r, "lib", "execution", "pipeline.mjs"), "export const processOne = () => {};\n");
259
+ }
260
+ if (daemon !== undefined) {
261
+ mkdirSync(join(r, "scripts", "daemon"), { recursive: true });
262
+ writeFileSync(join(r, "scripts", "daemon", "agent-daemon.mjs"), daemon);
263
+ }
264
+ return r;
265
+ }
266
+
267
+ test("reactive-lane probe FAILS when the daemon copy predates the execution ladder", () => {
268
+ const r = laneRoot({ daemon: "async function processItemTraced(){ return classifyItem(item); }\n" });
269
+ const v = checkReactiveLaneWiring(r);
270
+ assert.equal(v.level, "fail");
271
+ assert.match(v.msg, /never calls runExecutionLadder/);
272
+ assert.match(v.msg, /journal\.jsonl will stay empty/);
273
+ assert.match(v.msg, /upgrade/, "must tell the operator what to DO");
274
+ rmSync(r, { recursive: true, force: true });
275
+ });
276
+
277
+ test("reactive-lane probe is OK when the daemon does call the ladder", () => {
278
+ const r = laneRoot({ daemon: "const gate = await runExecutionLadder(item, service, itemId, trace_id, deps);\n" });
279
+ const v = checkReactiveLaneWiring(r);
280
+ assert.equal(v.level, "ok");
281
+ rmSync(r, { recursive: true, force: true });
282
+ });
283
+
284
+ test("reactive-lane probe stays silent when the repo is not an agent layout", () => {
285
+ // No lib/execution → the framework is not vendored here; nothing to compare.
286
+ const r = laneRoot({ daemon: "whatever\n", pipeline: false });
287
+ assert.equal(checkReactiveLaneWiring(r), null);
288
+ // …and no daemon at all is equally not-applicable.
289
+ const r2 = laneRoot({});
290
+ assert.equal(checkReactiveLaneWiring(r2), null);
291
+ rmSync(r, { recursive: true, force: true });
292
+ rmSync(r2, { recursive: true, force: true });
293
+ });
294
+
295
+ test("reactive-lane probe fails OPEN (warn, never throw) on an unreadable daemon", () => {
296
+ const r = laneRoot({ daemon: "runExecutionLadder\n" });
297
+ const v = checkReactiveLaneWiring(r, {
298
+ existsSync: () => true,
299
+ readFileSync: () => { throw new Error("EACCES"); },
300
+ });
301
+ assert.equal(v.level, "warn");
302
+ assert.match(v.msg, /EACCES/, "the cause must be named, not swallowed");
303
+ rmSync(r, { recursive: true, force: true });
304
+ });
305
+
306
+ test("checkOrgConnectivity surfaces the lane probe even for an un-enrolled agent", async () => {
307
+ const r = laneRoot({ daemon: "processItemTraced\n" });
308
+ const rs = await checkOrgConnectivity({ agentRoot: r, env: {}, fetchImpl: () => { throw new Error("no network"); } });
309
+ const lane = rs.find((x) => /Reactive lane/.test(x.msg));
310
+ assert.ok(lane, "the lane probe must run before the not-enrolled short-circuit");
311
+ assert.equal(lane.level, "fail");
312
+ rmSync(r, { recursive: true, force: true });
313
+ });
@@ -335,6 +335,36 @@ export function classifyEvent(ev) {
335
335
  };
336
336
  }
337
337
 
338
+ // ── calendar: `entityId` is the Meeting id. The payload names the CALENDAR
339
+ // OWNER (`calendarMemberId`, on create) or the RSVP'ing member
340
+ // (`memberId`, on rsvp) and NOTHING else — `attendeeCount` is a count, by
341
+ // design. Attendance is resolved by `calendar.list`, which is ACL'd to the
342
+ // acting seat (facts.myEventIds).
343
+ if (family === "calendar") {
344
+ const eventId = base.entityId ?? str(p, "eventId", "meetingId");
345
+ if (!eventId) return null;
346
+ const owner = str(p, "calendarMemberId");
347
+ const rsvper = str(p, "memberId");
348
+ return {
349
+ ...base,
350
+ topic: "calendar",
351
+ surfaces: ["calendar"],
352
+ ids: {
353
+ eventId,
354
+ title: str(p, "title"),
355
+ startsAt: str(p, "startsAt", "to"),
356
+ eventKind: str(p, "eventKind"),
357
+ rsvp: str(p, "rsvp"),
358
+ channelId: str(p, "channelId"),
359
+ },
360
+ // Only the owner is a PAYLOAD-proven address. An RSVP names the person who
361
+ // responded, which is news to the OWNER, not to that person — so it is
362
+ // deliberately NOT a directTo (it would deliver every agent its own RSVP).
363
+ directTo: owner ? [owner] : [],
364
+ hints: { rsvpBy: rsvper || undefined },
365
+ };
366
+ }
367
+
338
368
  // ── email: the one family whose redacted payload already names its seat
339
369
  // (`memberId` = the owning mailbox's member). Unrouted mail
340
370
  // (`memberId: null`) belongs to nobody and is dropped.
@@ -406,6 +436,10 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
406
436
  return yes(surface, "assignee");
407
437
  case "calling":
408
438
  return yes(surface, "participant");
439
+ case "calendar":
440
+ // The payload can only ever prove OWNERSHIP of the calendar; attendance
441
+ // comes from the ACL'd read below.
442
+ return yes(surface, "calendar_owner");
409
443
  default:
410
444
  return yes(surface, "direct");
411
445
  }
@@ -428,11 +462,38 @@ export function resolveDirected(cand, me, facts = {}, opts = {}) {
428
462
  return resolveEscalation(cand, meId, facts, on);
429
463
  case "approval":
430
464
  return resolveApproval(cand, meId, facts, on);
465
+ case "calendar":
466
+ return resolveCalendar(cand, meId, facts, on);
431
467
  default:
432
468
  return no("no_rule");
433
469
  }
434
470
  }
435
471
 
472
+ /**
473
+ * Calendar. The join is `calendar.list` — hq's own seat-scoped query, which
474
+ * returns an event when the acting seat OWNS the calendar or appears in
475
+ * `attendeeRows`, and applies the PERSONAL_RESTRICTED lens floor so a
476
+ * colleague's private event is absent rather than redacted.
477
+ *
478
+ * `myEventIds === null` means the read FAILED. That degrades to "unknown", which
479
+ * closes the surface rather than opening it — a calendar is a private surface,
480
+ * and guessing wrong here means an agent reacting to a meeting it is not in.
481
+ */
482
+ function resolveCalendar(cand, me, facts, on) {
483
+ if (!on("calendar")) return no("surface_disabled");
484
+ const mine = facts.myEventIds;
485
+ if (mine == null) return no("calendar_unknown");
486
+ const eventId = String(cand.ids.eventId || "");
487
+ if (!eventId || !mine.has(eventId)) return no("calendar_not_mine");
488
+ // An RSVP I made myself is my own echo, not news. (`own_echo` above only
489
+ // catches it when the RSVP was appended with me as the ACTOR; hq stamps the
490
+ // acting principal, which for a human-driven RSVP on my behalf is not me.)
491
+ if (cand.kind === "event.rsvp" && cand.hints && cand.hints.rsvpBy === me) {
492
+ return no("own_rsvp");
493
+ }
494
+ return yes("calendar", "attendee");
495
+ }
496
+
436
497
  // ---------------------------------------------------------------------------
437
498
  // Per-family resolvers
438
499
  // ---------------------------------------------------------------------------
@@ -605,13 +666,70 @@ function resolveEscalation(cand, me, facts, on) {
605
666
  return no("not_my_escalation");
606
667
  }
607
668
 
608
- /** Approval: the approver seat is on the row, not in the payload — read it back. */
669
+ /**
670
+ * Approval: the approver seat is on the row, not in the payload — read it back.
671
+ *
672
+ * ── WHY THIS IS NOT JUST `approver === me` ──
673
+ * It cannot be. hq's `methods/approval/request.ts` creates the row with
674
+ * `approver: NULL` — deliberately, because the G5 database CHECK constrains a
675
+ * SET approver and the approver is by definition the seat that LATER decides.
676
+ * So on a *pending* approval — the only kind anyone needs to act on — the
677
+ * `approver` column is always blank, and an "approver" verdict can only ever
678
+ * fire on an already-decided one, where the decider is someone else.
679
+ *
680
+ * The consequence, before this: `resolveApproval` had exactly one reachable
681
+ * branch, `requester === me` — the agent's own request echoing back. An approval
682
+ * raised BY a peer AGAINST a board item this seat reviews resolved as
683
+ * `not_my_approval` and was dropped at the transport. The live journey harness
684
+ * (`scripts/ci/journey-approval-escalation.mjs`) stopped there:
685
+ *
686
+ * scanned=1 classified=1 directed=0 delivered=0
687
+ * dropped={"not_my_approval":1}
688
+ *
689
+ * hq has no "requested of" column, so "is this on my desk?" has to be answered
690
+ * the way hq itself answers it when it decides where to post the approval CARD
691
+ * (`services/approval-artifact.ts`): by the approval's ATTACHMENTS. Two of them
692
+ * name a seat:
693
+ *
694
+ * - the board item it BLOCKS. `approval.request` flips that Task to `blocked`,
695
+ * so its assignee and reviewer are precisely the people whose work stopped.
696
+ * That is the strongest claim in the system that an approval is yours, and
697
+ * it is T0 — exactly as `resolveEscalation` already treats the same signal.
698
+ * - the room its card is posted into (`subject.channelId`, or the blocked
699
+ * task's channel). Membership of that room is T1: notice it, don't jump.
700
+ *
701
+ * ACL: every fact consulted here is the seat's OWN entitled read —
702
+ * `facts.taskRole` from `GET board.context`, `facts.memberChannelIds` from
703
+ * `messaging.channels`. Nothing is taken from the redacted org feed, and a room
704
+ * this seat is not in still never matches.
705
+ */
609
706
  function resolveApproval(cand, me, facts, on) {
610
707
  if (!on("approval")) return no("surface_disabled");
611
708
  const a = facts.approvals instanceof Map ? facts.approvals.get(String(cand.ids.approvalId)) : undefined;
612
709
  if (!a) return no(facts.approvals ? "approval_not_visible" : "approval_unknown");
613
710
  if (me && String(a.approver || "") === me) return yes("approval", "approver");
614
711
  if (me && String(a.requester || "") === me) return yes("approval", "requester");
712
+
713
+ // The blocked board item. `cand.ids.taskId` comes off the chain payload
714
+ // (`itemId`); `a.itemId` is the row's own copy — prefer the row, it cannot be
715
+ // stale relative to the frame.
716
+ const taskId = String(a.itemId || cand.ids.taskId || "");
717
+ const role = taskId && facts.taskRole instanceof Map ? facts.taskRole.get(taskId) : undefined;
718
+ if (role) return yes("approval", role); // "assignee" | "reviewer" — both T0
719
+
720
+ // The room the approval card lives in. Same explicit-only resolution hq uses:
721
+ // subject.channelId, else the blocked task's channel. Never a guessed room.
722
+ const subject = a.subject && typeof a.subject === "object" ? a.subject : {};
723
+ const task = taskId && facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
724
+ const channelId = String(subject.channelId || (task && task.channelId) || "");
725
+ if (channelId && has(asSet(facts.memberChannelIds), channelId)) {
726
+ return yes("approval", "channel");
727
+ }
728
+
729
+ // NEVER SILENT: an approval that named a task or a room we could not read is a
730
+ // different failure from one that genuinely is not ours, and the two must not
731
+ // share a reason string.
732
+ if (taskId && !(facts.taskRole instanceof Map)) return no("approval_board_unknown");
615
733
  return no("not_my_approval");
616
734
  }
617
735
 
@@ -443,6 +443,73 @@ test("POSITIVE: the resolution of an approval I requested reaches me (payload-na
443
443
  assert.deepEqual({ s: v.surface, r: v.reason }, { s: "approval", r: "requester" });
444
444
  });
445
445
 
446
+ /**
447
+ * THE BRANCH THAT MADE THIS SURFACE WORK AT ALL.
448
+ *
449
+ * hq creates an Approval with `approver: NULL` (G5 constrains only a SET
450
+ * approver, and the approver is the seat that LATER decides). So on a PENDING
451
+ * approval — the only kind anyone can act on — the `approver === me` branch
452
+ * above is unreachable, and `requester === me` only ever matches the agent's own
453
+ * request echoing back. Before this, an approval raised by a peer against a
454
+ * board item this seat REVIEWS resolved `not_my_approval` and was dropped at the
455
+ * transport. Live: `scanned=1 classified=1 directed=0 dropped={not_my_approval:1}`.
456
+ */
457
+ test("POSITIVE: a PENDING approval blocking a task I review is mine, at T0", () => {
458
+ const f = facts({
459
+ approvals: new Map([["A-4", { id: "A-4", requester: THEM, approver: null, status: "pending", itemId: "T-9" }]]),
460
+ taskRole: new Map([["T-9", "reviewer"]]),
461
+ tasks: new Map([["T-9", { id: "T-9" }]]),
462
+ });
463
+ const c = classifyEvent(
464
+ ev({ family: "approval", kind: "approval.requested", entity_id: "A-4", payload: { requester: THEM, itemId: "T-9" } }),
465
+ );
466
+ const v = resolveDirected(c, ME, f);
467
+ assert.deepEqual({ s: v.surface, r: v.reason }, { s: "approval", r: "reviewer" });
468
+ });
469
+
470
+ test("POSITIVE: assignee of the blocked item counts too — their work is what stopped", () => {
471
+ const f = facts({
472
+ approvals: new Map([["A-5", { id: "A-5", requester: THEM, approver: null, status: "pending", itemId: "T-1" }]]),
473
+ taskRole: new Map([["T-1", "assignee"]]),
474
+ });
475
+ const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-5", payload: { requester: THEM, itemId: "T-1" } }));
476
+ assert.equal(resolveDirected(c, ME, f).reason, "assignee");
477
+ });
478
+
479
+ test("POSITIVE: membership of the room the approval CARD lives in is T1, not T0", () => {
480
+ // Mirrors hq services/approval-artifact.ts: subject.channelId, else the
481
+ // blocked task's channel. Noticing it is not the same as owning it.
482
+ const f = facts({
483
+ approvals: new Map([["A-6", { id: "A-6", requester: THEM, approver: null, status: "pending", subject: { channelId: "C-priv" } }]]),
484
+ taskRole: new Map(),
485
+ });
486
+ withChannel(f, "C-priv", "PRIVATE");
487
+ const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-6", payload: { requester: THEM } }));
488
+ assert.equal(resolveDirected(c, ME, f).reason, "channel");
489
+ });
490
+
491
+ test("NEGATIVE: a room I am NOT in never makes an approval mine", () => {
492
+ const f = facts({
493
+ approvals: new Map([["A-7", { id: "A-7", requester: THEM, approver: null, status: "pending", subject: { channelId: "C-theirs" } }]]),
494
+ taskRole: new Map(),
495
+ });
496
+ // visible (PUBLIC) is not membership, and never gets upgraded to it
497
+ withChannel(f, "C-theirs", "PUBLIC");
498
+ const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-7", payload: { requester: THEM } }));
499
+ assert.equal(resolveDirected(c, ME, f).directed, false);
500
+ });
501
+
502
+ test("an unreadable board is reported as such, not collapsed into `not mine`", () => {
503
+ const f = facts({
504
+ approvals: new Map([["A-8", { id: "A-8", requester: THEM, approver: null, status: "pending", itemId: "T-?" }]]),
505
+ taskRole: null, // board.context failed this tick
506
+ });
507
+ const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-8", payload: { requester: THEM, itemId: "T-?" } }));
508
+ const v = resolveDirected(c, ME, f);
509
+ assert.equal(v.directed, false);
510
+ assert.equal(v.reason, "approval_board_unknown", "a failed read must not look like a negative verdict");
511
+ });
512
+
446
513
  test("NEGATIVE: an approval on someone else's desk is not directed", () => {
447
514
  const f = facts({ approvals: new Map([["A-3", { id: "A-3", requester: THEM, approver: "M-boss", status: "pending" }]]) });
448
515
  const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-3", payload: { requester: THEM } }));
@@ -17,6 +17,7 @@
17
17
  * | open escalations | escalation.list | org-scoped |
18
18
  * | approvals (approver seat) | approval.get | org-scoped, NOT_FOUND otherwise |
19
19
  * | doc ownership / shares | files.get | NOT_FOUND when not visible |
20
+ * | my calendar events | calendar.list | owner OR attendee, lens floor |
20
21
  *
21
22
  * ── THE ONE RULE THIS FILE ENFORCES ──
22
23
  * A channel that is not in the visible roster is NEVER paged. `messaging.channels`
@@ -76,6 +77,8 @@ export const DEFAULT_LIMITS = Object.freeze({
76
77
  * @property {Map<string,object>|null} escalations
77
78
  * @property {Map<string,object>|null} approvals
78
79
  * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe}
80
+ * @property {Set<string>|null} myEventIds calendar events I own or attend (null == unreadable)
81
+ * @property {Map<string,object>|null} myEvents eventId → the calendar event DTO
79
82
  * @property {string[]} degraded names of facts that could not be read
80
83
  */
81
84
 
@@ -126,6 +129,8 @@ export async function resolveFacts(o = {}) {
126
129
  escalations: null,
127
130
  approvals: null,
128
131
  fileAcl: null,
132
+ myEventIds: null,
133
+ myEvents: null,
129
134
  degraded,
130
135
  };
131
136
  if (!io || candidates.length === 0) return facts;
@@ -148,8 +153,29 @@ export async function resolveFacts(o = {}) {
148
153
  // `messaging.channels` returns a NON-PUBLIC channel only to its members,
149
154
  // so appearing here with a non-PUBLIC kind IS the membership proof. For
150
155
  // a PUBLIC channel the roster proves visibility, never membership — and
151
- // we never upgrade one to the other.
152
- if (kind && kind !== "PUBLIC") members.add(id);
156
+ // we never upgrade one to the other by GUESSING.
157
+ //
158
+ // hq now states it outright: `joined` is the acting member's OWN
159
+ // membership row (hq src/server/methods/messaging/channels.ts), pinned
160
+ // to the requester, so it discloses nothing about anyone else. Without
161
+ // it every huddle started in a PUBLIC space was dropped `not_a_member`
162
+ // — verified live on `calling/start` seq 8194 in a space the agent
163
+ // really is a member of. `joined` is TRUSTED ONLY WHEN TRUE and only on
164
+ // a PUBLIC row: an older hq omits the field, `joined` is undefined, and
165
+ // the old visibility-only behaviour stands. A private room is still
166
+ // proven by its kind alone, so a `joined:false` from any hq can never
167
+ // close a room membership already proved.
168
+ const joined = c && c.joined === true;
169
+ if ((kind && kind !== "PUBLIC") || joined) members.add(id);
170
+ }
171
+ if (rows.length > 0 && !rows.some((c) => c && typeof c.joined === "boolean")) {
172
+ // FAIL-OPEN IS FINE; SILENT IS NOT. On an hq that predates `joined`,
173
+ // public-space membership is unknowable and every huddle/file event in
174
+ // a public space will be dropped. Say which capability is dark.
175
+ log(
176
+ "warn",
177
+ "[inbound] messaging.channels carries no `joined` flag (older hq) — membership of PUBLIC spaces is unknowable, so calls and file comments in public spaces WILL be dropped as not_a_member",
178
+ );
153
179
  }
154
180
  facts.visibleChannelIds = visible;
155
181
  facts.memberChannelIds = members;
@@ -166,7 +192,24 @@ export async function resolveFacts(o = {}) {
166
192
  }
167
193
 
168
194
  // ── 3. Board: my tasks (assignee OR reviewer) from the board context read.
169
- if (families.has("board") || families.has("escalation") || families.has("file")) {
195
+ //
196
+ // `approval` is in this list for the same reason `escalation` is: an approval
197
+ // BLOCKS a board item (`approval.request` flips the Task to `blocked`), and
198
+ // the assignee/reviewer of the item whose work just stopped is the closest
199
+ // thing hq has to "the seat this approval is asked of" — hq's Approval row
200
+ // carries no such column, so `resolveApproval` has to reach the board to
201
+ // answer at all. Without this line `facts.taskRole` is null on an
202
+ // approval-only tick, every approval reads as `not_my_approval`, and the
203
+ // surface is dark no matter what the resolver says.
204
+ //
205
+ // It is ONE `GET board.context` shared across every family in the tick, so
206
+ // widening it here costs nothing when board events are already present.
207
+ if (
208
+ families.has("board") ||
209
+ families.has("escalation") ||
210
+ families.has("file") ||
211
+ families.has("approval")
212
+ ) {
170
213
  await resolveBoardFacts({ facts, io, log, degraded });
171
214
  }
172
215
 
@@ -203,9 +246,63 @@ export async function resolveFacts(o = {}) {
203
246
  await resolveFileFacts({ candidates, facts, io, limits, log, degraded });
204
247
  }
205
248
 
249
+ // ── 8. Calendar: which events are MINE. `calendar.list` with no
250
+ // `calendarMemberId` is the acting seat's own viewpoint — hq returns
251
+ // events where the seat owns the calendar OR appears in `attendeeRows`,
252
+ // under the PERSONAL_RESTRICTED lens floor. One read per pull.
253
+ if (families.has("calendar") && (on("calendar") || !enabled)) {
254
+ await resolveCalendarFacts({ candidates, facts, io, log, degraded });
255
+ }
256
+
206
257
  return facts;
207
258
  }
208
259
 
260
+ /**
261
+ * My calendar. ONE `calendar.list` per pull, windowed around the events this
262
+ * batch actually mentions so a long-lived agent does not re-page its whole year.
263
+ *
264
+ * A failed read leaves `myEventIds === null`, which `resolveCalendar` treats as
265
+ * "unknown" and therefore NOT directed — a calendar is a private surface and the
266
+ * safe direction is closed. It is logged, never silent.
267
+ */
268
+ async function resolveCalendarFacts({ candidates, facts, io, log, degraded }) {
269
+ // Window: [earliest mentioned start - 1d, latest + 1d], falling back to a
270
+ // ±30d window around now when no payload carried a usable timestamp.
271
+ const stamps = [];
272
+ for (const c of candidates) {
273
+ if (!c || c.family !== "calendar") continue;
274
+ for (const v of [c.ids && c.ids.startsAt, c.at]) {
275
+ const t = v ? Date.parse(v) : NaN;
276
+ if (Number.isFinite(t)) stamps.push(t);
277
+ }
278
+ }
279
+ const DAY = 86_400_000;
280
+ const now = Date.now();
281
+ const lo = (stamps.length ? Math.min(...stamps) : now - 30 * DAY) - DAY;
282
+ const hi = (stamps.length ? Math.max(...stamps) : now + 30 * DAY) + DAY;
283
+
284
+ const frame = await io.call("calendar.list", {
285
+ fromIso: new Date(lo).toISOString(),
286
+ toIso: new Date(hi).toISOString(),
287
+ limit: 200,
288
+ });
289
+ if (!frame || frame.ok !== true) {
290
+ degraded.push("calendar");
291
+ log("warn", "[inbound] calendar.list unreadable — calendar events stay CLOSED this tick (a private surface fails closed)");
292
+ return;
293
+ }
294
+ const ids = new Set();
295
+ const byId = new Map();
296
+ for (const e of arrayOf(resultOf(frame), "events")) {
297
+ const id = String((e && e.id) || "");
298
+ if (!id) continue;
299
+ ids.add(id);
300
+ byId.set(id, e);
301
+ }
302
+ facts.myEventIds = ids;
303
+ facts.myEvents = byId;
304
+ }
305
+
209
306
  // ---------------------------------------------------------------------------
210
307
  // Step implementations
211
308
  // ---------------------------------------------------------------------------
@@ -343,7 +440,18 @@ async function resolveBoardFacts({ facts, io, log, degraded }) {
343
440
  }
344
441
  const role = new Map();
345
442
  const tasks = new Map();
346
- for (const task of collectTasks(r.payload)) {
443
+ const collected = collectTasks(r.payload);
444
+ // NEVER SILENT. `board.context` answering 200 with a payload we then extract
445
+ // ZERO tasks from is not "an empty board" — it is almost always a shape
446
+ // mismatch, and it degrades every board verdict to `not_my_task` while every
447
+ // log line stays green. That is exactly how it failed: hq nests cards at
448
+ // board.swimlanes[].columns[].tasks[] (depth 7) and this walker stopped at 6,
449
+ // so 144 live tasks — 3 of them assigned to the seat — extracted as 0.
450
+ if (collected.length === 0 && r.payload && Object.keys(r.payload).length > 0) {
451
+ log("warn", "[inbound] board.context returned a payload but NO task cards were recognised — every board event will read as not_my_task this tick. This is a shape mismatch, not an empty board.");
452
+ degraded.push("board_shape");
453
+ }
454
+ for (const task of collected) {
347
455
  const id = String((task && task.id) || "");
348
456
  if (!id) continue;
349
457
  tasks.set(id, task);
@@ -357,16 +465,31 @@ async function resolveBoardFacts({ facts, io, log, degraded }) {
357
465
  }
358
466
 
359
467
  /**
360
- * `board.context` is a swimlane projection (`{lanes:[{columns:{col:[card]}}]}`),
361
- * and older/alternate shapes exist. Walk defensively and collect anything that
362
- * looks like a task card — a shape change upstream must degrade to "fewer
363
- * tasks", never to a throw.
468
+ * `board.context` is a swimlane projection and older/alternate shapes exist.
469
+ * Walk defensively and collect anything that looks like a task card — a shape
470
+ * change upstream must degrade to "fewer tasks", never to a throw.
471
+ *
472
+ * THE DEPTH CAP IS LOAD-BEARING AND WAS OFF BY ONE. hq's live shape is
473
+ *
474
+ * payload → board → swimlanes[] → swimlane → columns[] → column → tasks[] → task
475
+ * 0 1 2 3 4 5 6 7
476
+ *
477
+ * i.e. the cards sit at depth SEVEN. The cap was `depth > 6`, so `visit` was
478
+ * called on every task and returned immediately, and this function reported an
479
+ * empty board against a live org holding 144 tasks (3 assigned to the seat).
480
+ * Nothing threw and nothing logged: `facts.taskRole` was an empty Map, which
481
+ * `resolveBoard` reads as "none of these are mine", so EVERY board event was
482
+ * dropped as `not_my_task` — the wide reader was wired correctly and still
483
+ * delivered no task, ever.
484
+ *
485
+ * 12 is chosen with headroom for a projection that adds a wrapper or two, and
486
+ * the caller now LOGS a zero-card extraction rather than trusting it.
364
487
  */
365
488
  export function collectTasks(payload) {
366
489
  const out = [];
367
490
  const seen = new Set();
368
491
  const visit = (node, depth) => {
369
- if (!node || depth > 6) return;
492
+ if (!node || depth > 12) return;
370
493
  if (Array.isArray(node)) {
371
494
  for (const n of node) visit(n, depth + 1);
372
495
  return;
@@ -129,6 +129,44 @@ test("a PUBLIC channel is visible but NOT proof of membership", async () => {
129
129
  assert.deepEqual([...facts.memberChannelIds].sort(), ["C-dm", "C-priv"]);
130
130
  });
131
131
 
132
+ test("an older hq with no `joined` flag says out loud that public membership is dark", async () => {
133
+ const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
134
+ await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m1")], me: ME, io });
135
+ assert.ok(
136
+ io.logs.some((l) => /no `joined` flag/.test(l.message)),
137
+ "a dark capability must be named, not inferred from an agent that never answers",
138
+ );
139
+ });
140
+
141
+ test("`joined:true` on a PUBLIC room IS membership — the huddle-in-a-public-space fix", async () => {
142
+ const roster = {
143
+ "messaging.channels": okFrame({
144
+ channels: [
145
+ { id: "C-dm", kind: "DM", slug: "dm-them", name: "them", joined: true },
146
+ { id: "C-pub", kind: "PUBLIC", slug: "platform", name: "Platform", joined: true },
147
+ { id: "C-seen", kind: "PUBLIC", slug: "risk", name: "Risk", joined: false },
148
+ ],
149
+ }),
150
+ };
151
+ const io = fakeIo({ ...roster, "messaging.history": () => okFrame({ messages: [] }) });
152
+ const facts = await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m1")], me: ME, io });
153
+
154
+ // Seen-but-not-joined stays out. That is the whole safety property.
155
+ assert.deepEqual([...facts.memberChannelIds].sort(), ["C-dm", "C-pub"]);
156
+ assert.deepEqual([...facts.visibleChannelIds].sort(), ["C-dm", "C-pub", "C-seen"]);
157
+ });
158
+
159
+ test("`joined:false` can never close a private room membership the kind already proved", async () => {
160
+ const roster = {
161
+ "messaging.channels": okFrame({
162
+ channels: [{ id: "C-priv", kind: "PRIVATE", slug: "leads", name: "Leads", joined: false }],
163
+ }),
164
+ };
165
+ const io = fakeIo({ ...roster, "messaging.history": () => okFrame({ messages: [] }) });
166
+ const facts = await resolveFacts({ candidates: [msgEvent("C-priv", "PRIVATE", "m1")], me: ME, io });
167
+ assert.deepEqual([...facts.memberChannelIds], ["C-priv"]);
168
+ });
169
+
132
170
  // ---------------------------------------------------------------------------
133
171
  // cost: the redacted counts avoid pages entirely
134
172
  // ---------------------------------------------------------------------------
@@ -373,3 +411,61 @@ test("a disabled surface is never probed for", async () => {
373
411
  });
374
412
  assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
375
413
  });
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // collectTasks depth (JOINT 3) — the live shape, not a convenient one
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Regression, found against the LIVE org: hq's `GET board.context` nests cards
421
+ * at `board.swimlanes[].columns[].tasks[]`, which is depth SEVEN from the
422
+ * payload root. The walker capped at `depth > 6`, so it extracted 0 cards from
423
+ * a board holding 144 — 3 of them assigned to the seat — and every board event
424
+ * was then dropped as `not_my_task`. Silently: nothing threw, nothing logged.
425
+ */
426
+ test("collectTasks reaches hq's REAL board.context depth (board>swimlanes>columns>tasks)", () => {
427
+ const live = {
428
+ board: {
429
+ columns: ["triage", "todo", "done"],
430
+ swimlanes: [
431
+ {
432
+ workstreamId: "ws-1",
433
+ name: "Alpha Research",
434
+ status: "active",
435
+ objective: null,
436
+ ownerId: "M-boss",
437
+ columns: [
438
+ { col: "todo", tasks: [{ id: "T-1", title: "Ship it", col: "todo", assigneeId: "M-me", reviewerId: null }] },
439
+ { col: "done", tasks: [{ id: "T-2", title: "Old", col: "done", assigneeId: "M-them", reviewerId: "M-me" }] },
440
+ ],
441
+ taskCount: 2,
442
+ },
443
+ ],
444
+ },
445
+ };
446
+ const tasks = collectTasks(live);
447
+ assert.equal(tasks.length, 2, "a task at depth 7 must be reachable — this is hq's actual wire shape");
448
+ assert.deepEqual(tasks.map((t) => t.id).sort(), ["T-1", "T-2"]);
449
+ });
450
+
451
+ test("board.context that yields NO task cards is reported as a shape mismatch, not an empty board", async () => {
452
+ const logs = [];
453
+ const io = {
454
+ stats: { calls: 0, reads: 0, failures: 0, methods: [] },
455
+ log: (level, message) => logs.push({ level, message }),
456
+ async call() { return { ok: false, error: { code: "NOT_FOUND", message: "x" } }; },
457
+ // 200 with a payload the walker recognises nothing in.
458
+ async read() { return { ok: true, status: 200, payload: { board: { somethingElse: [{ nope: 1 }] } } }; },
459
+ };
460
+ const facts = await resolveFacts({
461
+ candidates: [{ seq: 1, family: "board", kind: "item.assigned", entityId: "T-9", actor: "M-boss", at: "", topic: "task", surfaces: ["task_assigned"], ids: { taskId: "T-9" }, directTo: [], payload: {} }],
462
+ me: "M-me",
463
+ io,
464
+ });
465
+ assert.equal(facts.taskRole.size, 0);
466
+ assert.ok(facts.degraded.includes("board_shape"), "the degradation is reported so a green log is not mistaken for a quiet board");
467
+ assert.ok(
468
+ logs.some((l) => l.level === "warn" && /shape mismatch, not an empty board/.test(l.message)),
469
+ `fail-open is fine, silent is not; got ${JSON.stringify(logs)}`,
470
+ );
471
+ });