@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.
- package/bin/maestro.mjs +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
package/lib/org/doctor.test.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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 >
|
|
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
|
+
});
|