@cohortapp/agent-sdk 2.4.1 → 2.5.1

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 (80) 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 +147 -15
  44. package/lib/org/inbound/hydrate.test.mjs +127 -0
  45. package/lib/org/inbound/index.test.mjs +83 -0
  46. package/lib/org/inbound/project.mjs +8 -0
  47. package/lib/org/inbound/surfaces.mjs +20 -0
  48. package/lib/org/param-contract.mjs +16 -2
  49. package/lib/org/protocol.checksum +1 -1
  50. package/lib/org/protocol.mjs +214 -2
  51. package/lib/org/protocol.test.mjs +11 -2
  52. package/lib/org/push.mjs +213 -49
  53. package/lib/org/push.test.mjs +112 -10
  54. package/lib/plan/compile.mjs +85 -8
  55. package/lib/plan/compile.test.mjs +82 -0
  56. package/lib/plan/emit.test.mjs +6 -1
  57. package/lib/setup/sections/mandate.mjs +43 -1
  58. package/lib/subagents/schema.mjs +14 -2
  59. package/lib/subagents/schema.test.mjs +22 -0
  60. package/package.json +1 -1
  61. package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
  62. package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
  63. package/scripts/ci/check.mjs +3 -0
  64. package/scripts/ci/conformance-org-api.mjs +16 -0
  65. package/scripts/ci/journey-approval-escalation.mjs +341 -0
  66. package/scripts/daemon/agent-daemon.mjs +582 -28
  67. package/scripts/daemon/cadence-handlers.mjs +273 -17
  68. package/scripts/daemon/cadence-handlers.test.mjs +101 -0
  69. package/scripts/daemon/execution-ladder.test.mjs +430 -0
  70. package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
  71. package/scripts/daemon/maestro-daemon.mjs +53 -0
  72. package/scripts/daemon/prompt-builder.mjs +47 -0
  73. package/scripts/daemon/responder.mjs +70 -3
  74. package/scripts/poller/imap-client.mjs +20 -1
  75. package/scripts/poller/inbox-scan-poller.mjs +15 -0
  76. package/scripts/poller/utils.mjs +51 -0
  77. package/scripts/setup/generate-capability.mjs +120 -11
  78. package/scripts/setup/generate-capability.test.mjs +134 -0
  79. package/scripts/setup/generate-plan.mjs +6 -1
  80. package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
@@ -51,16 +51,81 @@ export function clip(text, max = 4000) {
51
51
  return `${sp > max * 0.8 ? cut.slice(0, sp) : cut}…`;
52
52
  }
53
53
 
54
- /** Render a comment list into a compact prior-thread context block. */
54
+ /**
55
+ * How many of the most recent turns must survive a binding character budget.
56
+ * Without a per-turn cap one long message spends the whole allowance and the
57
+ * turns around it vanish.
58
+ */
59
+ const MIN_TURNS_VISIBLE = 4;
60
+
61
+ /**
62
+ * Who said it. hq denormalises `authorName` onto history rows, and when it is
63
+ * there it wins. When it is NOT — an older server, or any surface that never
64
+ * denormalised one — the fallback is a bare cuid, and a transcript of
65
+ * "cmqh0t9…: fix this / cmqh0tc…: which one?" loses the single distinction that
66
+ * matters most: which turns are MINE. An agent that cannot tell its own words
67
+ * from the sender's re-answers itself and misattributes its own statements back
68
+ * to them. `facts.me` is always known, so that distinction never has to be lost.
69
+ */
70
+ export function speakerFor(m, facts) {
71
+ const name = s(m && m.authorName);
72
+ if (name) return name;
73
+ const id = s(m && (m.authorId || m.author));
74
+ if (facts && facts.me && id === s(facts.me)) {
75
+ return (Array.isArray(facts.myNames) && facts.myNames[0]) || "me";
76
+ }
77
+ return id || "someone";
78
+ }
79
+
80
+ /**
81
+ * Render a comment list into a compact prior-thread context block, oldest first.
82
+ *
83
+ * ORDER IS NOT A DETAIL HERE. The sources disagree: every comment read
84
+ * (`board.taskComments`, `file.listComments`, `files.comments`,
85
+ * `decision.listComments`) returns ASCENDING, but `messaging.history` returns
86
+ * DESCENDING — hq orders `createdAt desc` unless the caller pages forward with
87
+ * an `after` cursor, and the inbound pull never does. So the bare `slice(-limit)`
88
+ * this used to do took the OLDEST rows off a message page and then printed them
89
+ * backwards: the wrong window, in the wrong direction, silently.
90
+ *
91
+ * Sorting on the timestamp makes the tail mean "the most recent turns" for every
92
+ * source. The sort is stable and the comparator declines to order rows with no
93
+ * parseable timestamp, so a source that carries none keeps its arrival order.
94
+ */
55
95
  export function renderThread(comments, { limit = 8, max = 2000 } = {}) {
56
- const rows = (Array.isArray(comments) ? comments : []).slice(-limit);
96
+ const all = (Array.isArray(comments) ? comments : []).slice();
97
+ all.sort((a, b) => {
98
+ const ta = Date.parse(s(a && (a.createdAt || a.at)));
99
+ const tb = Date.parse(s(b && (b.createdAt || b.at)));
100
+ if (!Number.isFinite(ta) || !Number.isFinite(tb)) return 0;
101
+ return ta - tb;
102
+ });
103
+ const rows = all.slice(-limit);
57
104
  if (rows.length === 0) return null;
105
+
106
+ // Spend the budget from the NEWEST turn backwards. Clipping the joined string
107
+ // — which is what this did — keeps the oldest turns and drops the newest, the
108
+ // exact inverse of what context is for: the turns nearest the message being
109
+ // answered are the ones that explain it. Caught on the live CEO DM, where one
110
+ // long reply of mine filled the 2000 chars and displaced the four newer turns,
111
+ // including the question under discussion. The per-turn cap is the other half:
112
+ // without it a single long message starves every turn around it.
113
+ const perTurn = Math.max(200, Math.floor(max / Math.min(rows.length, MIN_TURNS_VISIBLE)));
58
114
  const lines = rows.map((c) => {
59
115
  const who = s(c.authorName || c.authorId || c.author || "someone");
60
- const body = s(c.body || c.text).replace(/\s+/g, " ").trim();
116
+ const body = clip(s(c.body || c.text).replace(/\s+/g, " ").trim(), perTurn);
61
117
  return `${who}: ${body}`;
62
118
  });
63
- return clip(lines.join("\n"), max) || null;
119
+
120
+ const kept = [];
121
+ let used = 0;
122
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
123
+ const cost = lines[i].length + (kept.length ? 1 : 0); // +1 for the joining newline
124
+ if (kept.length > 0 && used + cost > max) break;
125
+ kept.unshift(lines[i]);
126
+ used += cost;
127
+ }
128
+ return kept.join("\n") || null;
64
129
  }
65
130
 
66
131
  /**
@@ -114,6 +179,8 @@ export async function hydrate(o = {}) {
114
179
  return hydrateApproval(c, facts);
115
180
  case "handoff":
116
181
  return hydrateHandoff(c, facts);
182
+ case "calendar":
183
+ return hydrateCalendar(c, facts);
117
184
  case "email":
118
185
  return await hydrateEmail(c, facts, io, cache);
119
186
  default:
@@ -144,17 +211,24 @@ function hydrateMessage(c, facts) {
144
211
 
145
212
  const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
146
213
  const root = s(msg.threadRootId || msg.thread_root_id);
147
- let threadContext = null;
148
- if (root && page) {
149
- const siblings = page.messages.filter(
150
- (m) => s(m.threadRootId || m.thread_root_id) === root || s(m.id) === root,
151
- );
152
- threadContext = renderThread(
153
- siblings
154
- .filter((m) => s(m.id) !== s(c.ids.messageId))
155
- .map((m) => ({ authorName: m.authorName || m.authorId, body: m.body ?? m.text })),
156
- );
157
- }
214
+
215
+ // THE CONVERSATION SO FAR. For a thread reply that is the thread; for a
216
+ // TOP-LEVEL message it is the channel itself — which is the whole of a DM.
217
+ // This used to be gated on `root`, so a DM (no thread root, by definition)
218
+ // arrived with no context at all and the agent answered "fix this" without
219
+ // ever seeing what "this" referred to. The page is already in hand from the
220
+ // ACL'd `messaging.history` read `pageChannels` made, so re-joining it here
221
+ // costs no RPC and widens nothing: it is the same read, same aperture.
222
+ const scope = root
223
+ ? page.messages.filter((m) => s(m.threadRootId || m.thread_root_id) === root || s(m.id) === root)
224
+ : page.messages;
225
+ const threadContext = renderThread(
226
+ priorTo(scope, msg, c.ids.messageId).map((m) => ({
227
+ authorName: speakerFor(m, facts),
228
+ body: m.body ?? m.text,
229
+ createdAt: m.createdAt,
230
+ })),
231
+ );
158
232
 
159
233
  return {
160
234
  ok: true,
@@ -324,6 +398,44 @@ function hydrateEscalation(c, facts) {
324
398
  };
325
399
  }
326
400
 
401
+ /**
402
+ * A calendar event. The body came back on the ACL'd `calendar.list` read in the
403
+ * facts pass — that read IS the probe, so there is no second request here and
404
+ * nothing is rendered that hq did not already hand this seat.
405
+ */
406
+ function hydrateCalendar(c, facts) {
407
+ const id = s(c.ids.eventId);
408
+ const e = facts.myEvents instanceof Map ? facts.myEvents.get(id) : null;
409
+ const title = s((e && e.title) || c.ids.title || id);
410
+ const lines = [`${describeCalendarKind(c.kind)} — "${title}"`];
411
+ const startsAt = s((e && e.startsAt) || c.ids.startsAt);
412
+ if (startsAt) lines.push(`Starts: ${startsAt}${e && e.endsAt ? ` · ends ${s(e.endsAt)}` : ""}`);
413
+ if (c.kind === "event.rsvp" && c.ids.rsvp) lines.push(`RSVP: ${s(c.ids.rsvp)}`);
414
+ if (e && e.location) lines.push(`Location: ${s(e.location)}`);
415
+ if (e && e.description) lines.push(clip(e.description, 1200));
416
+ return {
417
+ ok: true,
418
+ text: clip(lines.join("\n")),
419
+ subjectDetail: title,
420
+ threadId: id,
421
+ from: { id: s(c.actor), name: s(c.actor) },
422
+ channelId: s(c.ids.channelId || (e && e.channelId) || ""),
423
+ channelLabel: `calendar/${title}`,
424
+ };
425
+ }
426
+
427
+ /** Human phrasing for a `calendar.*` event kind. */
428
+ export function describeCalendarKind(kind) {
429
+ switch (kind) {
430
+ case "event.created": return "Calendar event created";
431
+ case "event.updated": return "Calendar event updated";
432
+ case "event.rescheduled": return "Calendar event rescheduled";
433
+ case "event.rsvp": return "Calendar RSVP";
434
+ case "event.deleted": return "Calendar event cancelled";
435
+ default: return "Calendar update";
436
+ }
437
+ }
438
+
327
439
  /** An approval — already read back by the facts pass (that IS the probe). */
328
440
  function hydrateApproval(c, facts) {
329
441
  const id = s(c.ids.approvalId);
@@ -427,6 +539,26 @@ async function hydrateEmail(c, facts, io, cache) {
427
539
  // Small helpers
428
540
  // ---------------------------------------------------------------------------
429
541
 
542
+ /**
543
+ * The messages that came BEFORE the trigger — never the ones after it.
544
+ *
545
+ * A history page is a window, not a prefix: when the pull is running behind, or
546
+ * replaying after a crash, the page can hold turns that were said AFTER the
547
+ * message being hydrated. Handing those to the agent as "prior context" would
548
+ * have it answer a question using an answer it has not given yet. When a row
549
+ * carries no parseable timestamp we keep it rather than guess — dropping real
550
+ * conversation is the failure mode being fixed here.
551
+ */
552
+ function priorTo(messages, trigger, triggerId) {
553
+ const at = Date.parse(s(trigger && trigger.createdAt));
554
+ return (Array.isArray(messages) ? messages : []).filter((m) => {
555
+ if (s(m.id) === s(triggerId)) return false;
556
+ if (!Number.isFinite(at)) return true;
557
+ const t = Date.parse(s(m.createdAt));
558
+ return !Number.isFinite(t) || t <= at;
559
+ });
560
+ }
561
+
430
562
  /** Memoise an async read for the lifetime of one pull. */
431
563
  async function cached(cache, key, fn) {
432
564
  if (cache.has(key)) return cache.get(key);
@@ -118,6 +118,64 @@ test("a threaded message carries prior-thread context", async () => {
118
118
  assert.doesNotMatch(h.threadContext, /one more thing/, "the trigger itself is not its own context");
119
119
  });
120
120
 
121
+ // A DM has no thread root — the CHANNEL is the conversation. Before this, the
122
+ // `if (root)` guard meant a top-level message carried no context at all, so the
123
+ // agent answered "fix this" with no idea what "this" referred to. The page is
124
+ // already in hand from the ACL'd `messaging.history` read, so this costs no RPC.
125
+ test("a top-level DM carries the preceding conversation as context", async () => {
126
+ // Newest-first, exactly as hq's messaging.history returns it.
127
+ const msgs = [
128
+ { id: "m3", authorId: THEM, authorName: "Casey", body: "fix this then", createdAt: "2026-08-11T15:33:54.000Z" },
129
+ { id: "m2", authorId: ME, authorName: "Alex", body: "16 min pickup on the morning batch", createdAt: "2026-08-11T15:32:00.000Z" },
130
+ { id: "m1", authorId: THEM, authorName: "Casey", body: "what is the reply latency here", createdAt: "2026-08-11T15:31:00.000Z" },
131
+ ];
132
+ const f = facts();
133
+ f.channels.set("C-dm", { id: "C-dm", kind: "DM", name: "dm-h001-a016" });
134
+ f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
135
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m3", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
136
+ const io = fakeIo({});
137
+ const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io });
138
+
139
+ assert.equal(h.ok, true);
140
+ assert.equal(io.calls.length, 0, "the page was already in hand — context must cost no extra RPC");
141
+ assert.ok(h.threadContext, "a DM with prior turns must carry them");
142
+ assert.match(h.threadContext, /Casey: what is the reply latency here/);
143
+ assert.match(h.threadContext, /Alex: 16 min pickup/);
144
+ assert.doesNotMatch(h.threadContext, /fix this then/, "the trigger itself is not its own context");
145
+ assert.ok(
146
+ h.threadContext.indexOf("what is the reply latency") < h.threadContext.indexOf("16 min pickup"),
147
+ "oldest first — the conversation must read forwards",
148
+ );
149
+ });
150
+
151
+ test("a first-ever message in a channel has no context, not an empty block", async () => {
152
+ const msgs = [{ id: "m1", authorId: THEM, authorName: "Casey", body: "hello", createdAt: "2026-08-11T15:31:00.000Z" }];
153
+ const f = facts();
154
+ f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
155
+ f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
156
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
157
+ const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
158
+
159
+ assert.equal(h.ok, true);
160
+ assert.equal(h.threadContext, null);
161
+ });
162
+
163
+ test("messages AFTER the trigger are never passed off as prior context", async () => {
164
+ const msgs = [
165
+ { id: "m3", authorId: THEM, authorName: "Casey", body: "later message", createdAt: "2026-08-11T16:00:00.000Z" },
166
+ { id: "m2", authorId: THEM, authorName: "Casey", body: "the trigger", createdAt: "2026-08-11T15:00:00.000Z" },
167
+ { id: "m1", authorId: ME, authorName: "Alex", body: "earlier message", createdAt: "2026-08-11T14:00:00.000Z" },
168
+ ];
169
+ const f = facts();
170
+ f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
171
+ f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
172
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
173
+ const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
174
+
175
+ assert.match(h.threadContext, /earlier message/);
176
+ assert.doesNotMatch(h.threadContext, /later message/);
177
+ });
178
+
121
179
  // ---------------------------------------------------------------------------
122
180
  // board
123
181
  // ---------------------------------------------------------------------------
@@ -318,6 +376,75 @@ test("clip and renderThread are bounded", () => {
318
376
  assert.match(renderThread([{ authorName: "A", body: "hi" }]), /A: hi/);
319
377
  });
320
378
 
379
+ // `messaging.history` returns DESCENDING; every comment source returns ASCENDING.
380
+ // A bare slice(-limit) therefore took the OLDEST rows off a message page and
381
+ // printed them backwards — the wrong window, in the wrong order.
382
+ test("renderThread reads forwards and keeps the most RECENT turns", () => {
383
+ const rows = [];
384
+ for (let i = 12; i >= 1; i -= 1) {
385
+ rows.push({ authorName: "A", body: `turn ${i}`, createdAt: `2026-08-11T00:${String(i).padStart(2, "0")}:00.000Z` });
386
+ }
387
+ const out = renderThread(rows); // limit 8
388
+
389
+ assert.match(out, /turn 12/, "the newest turn is the one that matters most");
390
+ assert.doesNotMatch(out, /turn 1\b/, "the oldest turns fall off the window, not the newest");
391
+ assert.ok(out.indexOf("turn 5") < out.indexOf("turn 12"), "oldest first");
392
+ });
393
+
394
+ test("renderThread leaves undated rows in the order they arrived", () => {
395
+ const out = renderThread([
396
+ { authorName: "A", body: "first" },
397
+ { authorName: "B", body: "second" },
398
+ ]);
399
+ assert.ok(out.indexOf("first") < out.indexOf("second"));
400
+ });
401
+
402
+ // The `limit` window and the `max` CHARACTER budget are two different windows,
403
+ // and only the first was covered: twelve short turns never reach 2000 chars, so
404
+ // the clip never fired and the direction it clips in was never asserted. On the
405
+ // real DM it fired immediately — one long reply filled the budget and the four
406
+ // newest turns, including the question being answered, were the ones discarded.
407
+ test("renderThread keeps the NEWEST turns when the CHARACTER budget binds", () => {
408
+ const out = renderThread(
409
+ [
410
+ { authorName: "A", body: "x".repeat(1500), createdAt: "2026-08-11T00:01:00.000Z" },
411
+ { authorName: "B", body: "the thing I actually asked", createdAt: "2026-08-11T00:02:00.000Z" },
412
+ ],
413
+ { max: 300 },
414
+ );
415
+ assert.match(out, /the thing I actually asked/, "the latest turn must survive the budget");
416
+ });
417
+
418
+ test("renderThread does not let one huge turn starve the turns around it", () => {
419
+ const out = renderThread(
420
+ [
421
+ { authorName: "A", body: "y".repeat(5000), createdAt: "2026-08-11T00:01:00.000Z" },
422
+ { authorName: "B", body: "second", createdAt: "2026-08-11T00:02:00.000Z" },
423
+ { authorName: "C", body: "third", createdAt: "2026-08-11T00:03:00.000Z" },
424
+ ],
425
+ { max: 2000 },
426
+ );
427
+ assert.match(out, /B: second/);
428
+ assert.match(out, /C: third/);
429
+ assert.ok(out.length <= 2100, `bounded, got ${out.length}`);
430
+ });
431
+
432
+ // Until hq's `authorName` denormalisation is deployed every row carries a bare
433
+ // cuid, and the agent must still be able to see which turns are its own.
434
+ test("a transcript labels my own turns even when the server sends no names", async () => {
435
+ const msgs = [
436
+ { id: "m1", authorId: ME, body: "already answered that", createdAt: "2026-08-11T14:00:00.000Z" },
437
+ { id: "m2", authorId: THEM, body: "and the other thing?", createdAt: "2026-08-11T15:00:00.000Z" },
438
+ ];
439
+ const f = facts({ myNames: ["Isla"] });
440
+ f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
441
+ f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
442
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
443
+ const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
444
+
445
+ assert.match(h.threadContext, /Isla: already answered that/, "my own turn is mine, not a cuid");
446
+ });
447
+
321
448
  test("event-kind phrasing is stable and total", () => {
322
449
  assert.match(describeBoardKind("item.assigned"), /assigned to you/);
323
450
  assert.match(describeBoardKind("who.knows"), /Update on your board item/);
@@ -322,3 +322,86 @@ test("an empty ledger window is a no-op that still advances nothing", async () =
322
322
  assert.deepEqual(out.events, []);
323
323
  assert.equal(out.nextCursor, 5);
324
324
  });
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Calendar (JOINT 3) — the surface hq's own agent.wait lanes do not carry
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * The calendar payload is redacted like every other family: `event.created`
332
+ * carries `attendeeCount` and the CALENDAR OWNER, never the attendee list. So
333
+ * "am I in this meeting?" is answered by `calendar.list` — hq's seat-scoped
334
+ * query (owner OR attendeeRows, under the PERSONAL_RESTRICTED lens floor) — and
335
+ * NEVER by widening the chain payload.
336
+ */
337
+ test("calendar: an event I attend arrives; a colleague's event never does", async () => {
338
+ const io = fakeIo({
339
+ "read:events": okRead({
340
+ events: [
341
+ // Mine — I am an attendee (owner is someone else).
342
+ { seq: 900, family: "calendar", kind: "event.created", entity_id: "EV-mine", actor: BOSS,
343
+ at: "2026-08-11T10:00:00.000Z",
344
+ payload: { title: "Transport review", startsAt: "2026-08-12T10:00:00.000Z", endsAt: "2026-08-12T11:00:00.000Z",
345
+ eventKind: "MEETING", attendeeCount: 4, calendarMemberId: BOSS } },
346
+ // NOT mine — a meeting between two colleagues. `calendar.list` will not
347
+ // return it, so it must never be delivered or hydrated.
348
+ { seq: 901, family: "calendar", kind: "event.created", entity_id: "EV-theirs", actor: THEM,
349
+ at: "2026-08-11T10:05:00.000Z",
350
+ payload: { title: "Their 1:1", startsAt: "2026-08-12T14:00:00.000Z",
351
+ eventKind: "MEETING", attendeeCount: 2, calendarMemberId: THEM } },
352
+ ],
353
+ nextCursor: 901,
354
+ }),
355
+ "calendar.list": okFrame({
356
+ events: [
357
+ { id: "EV-mine", title: "Transport review", startsAt: "2026-08-12T10:00:00.000Z",
358
+ endsAt: "2026-08-12T11:00:00.000Z", location: "Huddle 2", description: "Walk the five hops",
359
+ calendarMemberId: BOSS, channelId: "C-pub" },
360
+ ],
361
+ }),
362
+ });
363
+
364
+ const res = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 899, io });
365
+
366
+ assert.equal(res.events.length, 1, "exactly one calendar item — the one I am in");
367
+ const ev = res.events[0];
368
+ assert.equal(ev.kind, "calendar");
369
+ assert.equal(ev.cohort.surface, "calendar");
370
+ assert.equal(ev.cohort.reason, "attendee", "attendance was proven by an ACL'd read, not by a payload");
371
+ assert.match(ev.text, /Calendar event created — "Transport review"/);
372
+ assert.match(ev.text, /Walk the five hops/);
373
+ assert.equal(res.stats.bySurface.calendar, 1);
374
+ assert.equal(res.stats.dropped.calendar_not_mine, 1, "the colleague's meeting was dropped with a named reason");
375
+
376
+ // ACL by call log: hq was asked ONE seat-scoped question, and we never probed
377
+ // the event we were not entitled to.
378
+ const calendarCalls = io.calls.filter((c) => c.method === "calendar.list");
379
+ assert.equal(calendarCalls.length, 1, "one list read per pull, not one probe per event");
380
+ assert.ok(
381
+ calendarCalls[0].params.calendarMemberId === undefined,
382
+ "no calendarMemberId → hq uses the ACTING SEAT's viewpoint; naming one would be asking about someone else's calendar",
383
+ );
384
+ assert.equal(io.calls.filter((c) => c.method === "calendar.get").length, 0, "no per-event probe at all");
385
+ });
386
+
387
+ test("calendar: an unreadable calendar fails CLOSED and says so", async () => {
388
+ const io = fakeIo({
389
+ "read:events": okRead({
390
+ events: [
391
+ { seq: 910, family: "calendar", kind: "event.rescheduled", entity_id: "EV-mine", actor: BOSS,
392
+ at: "2026-08-11T10:00:00.000Z", payload: { title: "Transport review", to: "2026-08-13T10:00:00.000Z" } },
393
+ ],
394
+ nextCursor: 910,
395
+ }),
396
+ // calendar.list not routed → the fake returns NOT_FOUND, i.e. a failed read.
397
+ });
398
+
399
+ const res = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 909, io });
400
+ assert.equal(res.events.length, 0, "a private surface guesses CLOSED, never open");
401
+ assert.equal(res.stats.dropped.calendar_unknown, 1);
402
+ assert.ok(res.stats.degraded.includes("calendar"), "and the degradation is reported, not swallowed");
403
+ assert.ok(
404
+ io.logs.some((l) => l.level === "warn" && /calendar\.list unreadable/.test(l.message)),
405
+ `fail-open is fine, silent is not; got ${JSON.stringify(io.logs)}`,
406
+ );
407
+ });
@@ -144,6 +144,14 @@ export function toMessageEvent(o = {}) {
144
144
  family: c.family,
145
145
  event_kind: c.kind,
146
146
  entity_id: entityId || null,
147
+ // The subject this surface hangs off, when it is NOT a channel: the task
148
+ // an approval blocks or an escalation flags, the decision a comment sits
149
+ // on, the file a comment is against. `channel_id` above deliberately only
150
+ // ever carries a real Cohort channel — a reply routes with it — so
151
+ // without this the downstream ladder has the event and no handle on the
152
+ // thing the event is about. `effects.escalate` needs exactly this to
153
+ // satisfy hq's "an escalation must reference a task or a channel".
154
+ scope_id: s(c.ids.taskId || c.ids.decisionId || c.ids.fileId || "") || null,
147
155
  seq: c.seq ?? null,
148
156
  me: s(me),
149
157
  },
@@ -127,6 +127,26 @@ export const SURFACES = Object.freeze({
127
127
  scope: "channel",
128
128
  default: true,
129
129
  }),
130
+ /**
131
+ * A calendar event on MY calendar, or one I am an attendee of: created,
132
+ * updated, rescheduled, RSVP'd, deleted.
133
+ *
134
+ * ADDRESSING. The `calendar.*` payload is redacted like every other family —
135
+ * `event.created` carries `attendeeCount`, `invitesQueued` and the CALENDAR
136
+ * OWNER (`calendarMemberId`), never the attendee list. So ownership is the
137
+ * only thing the frame can prove, and attendance has to come from
138
+ * `calendar.list`, which is ACL'd to the acting seat: it returns events where
139
+ * `calendarMemberId = viewpoint` OR `attendeeRows.some(memberId = viewpoint)`,
140
+ * under hq's PERSONAL_RESTRICTED lens floor (a colleague's restricted event is
141
+ * ABSENT, never redacted). We never widen the payload to carry attendees.
142
+ */
143
+ calendar: Object.freeze({
144
+ topic: "calendar",
145
+ kind: "calendar",
146
+ subject: "Cohort calendar",
147
+ scope: "channel",
148
+ default: true,
149
+ }),
130
150
  /** A delegation offered to me, or one I offered being accepted/declined. */
131
151
  handoff: Object.freeze({
132
152
  topic: "handoff",
@@ -422,11 +422,25 @@ export const PARAM_CONTRACT = {
422
422
  note: "hq resolves a member by SLUG, not id.",
423
423
  },
424
424
  "escalation.create": {
425
- alias: { subject: "title", body: "detail", description: "detail" },
425
+ alias: { subject: "title", body: "detail", description: "detail", summary: "title" },
426
426
  enums: { severity: ESCALATION_SEVERITIES },
427
427
  required: ["title"],
428
428
  oneOf: [["taskId", "channelId"]],
429
- note: "hq requires a title AND a target (taskId or channelId).",
429
+ unsupported: ["waitingOnMemberId", "options", "context"],
430
+ note:
431
+ "hq requires a title AND a target (taskId or channelId). `summary` is aliased " +
432
+ "because lib/execution/effects.mjs sent exactly that and 400'd on every call; " +
433
+ "`waitingOnMemberId`/`options`/`context` are NOT fields of this method — an " +
434
+ "escalation carrying OPTIONS is an OpenQuestion and belongs at escalation.ask.",
435
+ },
436
+ "escalation.ask": {
437
+ // hq methods/escalation/ask.ts: `question` (<=2000) + >=2 DISTINCT options,
438
+ // `context` is a STRING (<=4000) — not an object — and `trigger` <=100.
439
+ // The two-option floor is enforced server-side and is the whole point: an
440
+ // escalation with one option is a notification, with none it is prose.
441
+ alias: { text: "question", prompt: "question", choices: "options" },
442
+ required: ["question", "options"],
443
+ note: "at least two distinct options, or hq 400s; `context` is a string, not an object.",
430
444
  },
431
445
 
432
446
  // ── leases ───────────────────────────────────────────────────────────────
@@ -1 +1 @@
1
- 85e71129422236b2de83d7f2da1ed04821b9f7c3f7a025a31939cf95d5a4a7db
1
+ 1946d98145f4c1e56d80e58c37d9b54d5ea8d88c7d30251a694484418327dddf