@cohortapp/agent-sdk 2.3.2 → 2.4.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 (148) hide show
  1. package/framework-features.json +30 -0
  2. package/lib/backlog.mjs +136 -0
  3. package/lib/cadences.mjs +63 -2
  4. package/lib/cadences.test.mjs +105 -0
  5. package/lib/capability/inventory.mjs +542 -0
  6. package/lib/capability/inventory.test.mjs +232 -0
  7. package/lib/capability/probe.mjs +255 -0
  8. package/lib/channels/contract.mjs +37 -1
  9. package/lib/channels/contract.test.mjs +25 -1
  10. package/lib/claude-bin.mjs +37 -3
  11. package/lib/claude-bin.test.mjs +42 -8
  12. package/lib/execution/disposition.mjs +501 -0
  13. package/lib/execution/disposition.test.mjs +482 -0
  14. package/lib/execution/drive.mjs +352 -0
  15. package/lib/execution/drive.test.mjs +270 -0
  16. package/lib/execution/effects.mjs +340 -0
  17. package/lib/execution/effects.test.mjs +193 -0
  18. package/lib/execution/index.mjs +152 -0
  19. package/lib/execution/intake.mjs +581 -0
  20. package/lib/execution/intake.test.mjs +343 -0
  21. package/lib/execution/journal.mjs +374 -0
  22. package/lib/execution/journal.test.mjs +261 -0
  23. package/lib/execution/match.mjs +331 -0
  24. package/lib/execution/match.test.mjs +235 -0
  25. package/lib/execution/pipeline.mjs +341 -0
  26. package/lib/execution/pipeline.test.mjs +389 -0
  27. package/lib/execution/route.mjs +332 -0
  28. package/lib/execution/route.test.mjs +186 -0
  29. package/lib/execution/surface-policy.mjs +446 -0
  30. package/lib/execution/surface-policy.test.mjs +162 -0
  31. package/lib/goals/admission.mjs +209 -0
  32. package/lib/goals/admission.test.mjs +139 -0
  33. package/lib/goals/classify.mjs +206 -0
  34. package/lib/goals/classify.test.mjs +109 -0
  35. package/lib/goals/collaborate.mjs +415 -0
  36. package/lib/goals/collaborate.test.mjs +324 -0
  37. package/lib/goals/gaps.mjs +111 -0
  38. package/lib/goals/gaps.test.mjs +284 -0
  39. package/lib/goals/loop.mjs +537 -0
  40. package/lib/goals/loop.test.mjs +719 -0
  41. package/lib/identity/persona.mjs +247 -0
  42. package/lib/identity/persona.test.mjs +117 -0
  43. package/lib/kpi.mjs +469 -0
  44. package/lib/kpi.test.mjs +244 -0
  45. package/lib/mandate/audit.mjs +168 -0
  46. package/lib/mandate/audit.test.mjs +195 -0
  47. package/lib/mandate/cache.mjs +162 -0
  48. package/lib/mandate/derive.mjs +317 -0
  49. package/lib/mandate/derive.test.mjs +224 -0
  50. package/lib/mandate/model.mjs +352 -0
  51. package/lib/mandate/model.test.mjs +145 -0
  52. package/lib/mandate/refresh.mjs +187 -0
  53. package/lib/mandate/refresh.test.mjs +293 -0
  54. package/lib/mcp/server.test.mjs +4 -4
  55. package/lib/org/approvals.mjs +14 -2
  56. package/lib/org/client.mjs +58 -22
  57. package/lib/org/client.test.mjs +3 -1
  58. package/lib/org/inbound/directedness.mjs +720 -0
  59. package/lib/org/inbound/directedness.test.mjs +543 -0
  60. package/lib/org/inbound/facts.mjs +501 -0
  61. package/lib/org/inbound/facts.test.mjs +375 -0
  62. package/lib/org/inbound/hydrate.mjs +535 -0
  63. package/lib/org/inbound/hydrate.test.mjs +326 -0
  64. package/lib/org/inbound/index.mjs +233 -0
  65. package/lib/org/inbound/index.test.mjs +324 -0
  66. package/lib/org/inbound/io.mjs +141 -0
  67. package/lib/org/inbound/project.mjs +201 -0
  68. package/lib/org/inbound/project.test.mjs +287 -0
  69. package/lib/org/inbound/surfaces.mjs +257 -0
  70. package/lib/org/knowledge.mjs +10 -1
  71. package/lib/org/knowledge.test.mjs +8 -1
  72. package/lib/org/leases.mjs +5 -0
  73. package/lib/org/mesh.mjs +17 -2
  74. package/lib/org/messaging.mjs +40 -4
  75. package/lib/org/messaging.test.mjs +40 -0
  76. package/lib/org/param-contract.mjs +694 -0
  77. package/lib/org/param-contract.test.mjs +451 -0
  78. package/lib/org/protocol.checksum +1 -1
  79. package/lib/org/protocol.mjs +8 -0
  80. package/lib/org/protocol.test.mjs +5 -1
  81. package/lib/org/push.mjs +1025 -0
  82. package/lib/org/push.test.mjs +690 -0
  83. package/lib/org/tool-surface.mjs +138 -38
  84. package/lib/org/tool-surface.test.mjs +13 -8
  85. package/lib/org/typing.mjs +341 -0
  86. package/lib/org/typing.test.mjs +291 -0
  87. package/lib/plan/compile.mjs +510 -0
  88. package/lib/plan/compile.test.mjs +286 -0
  89. package/lib/plan/emit.mjs +256 -0
  90. package/lib/plan/emit.test.mjs +246 -0
  91. package/lib/plan/explain.mjs +226 -0
  92. package/lib/plan/explain.test.mjs +188 -0
  93. package/lib/plan/schema.mjs +140 -0
  94. package/lib/resource-governor.mjs +47 -1
  95. package/lib/resource-governor.test.mjs +21 -1
  96. package/lib/setup/enroll-from-cohort.mjs +105 -17
  97. package/lib/setup/enroll-from-cohort.test.mjs +68 -1
  98. package/lib/setup/sections/identity.mjs +15 -4
  99. package/lib/setup/sections/identity.test.mjs +94 -0
  100. package/lib/setup/sections/inventory.mjs +178 -0
  101. package/lib/setup/sections/inventory.test.mjs +198 -0
  102. package/lib/setup/sections/mandate.mjs +392 -0
  103. package/lib/setup/sections/mandate.test.mjs +373 -0
  104. package/lib/setup/sections/subagents.mjs +427 -0
  105. package/lib/setup/sections/subagents.test.mjs +429 -0
  106. package/lib/setup/sections/verify.mjs +121 -0
  107. package/lib/setup/sections/verify.test.mjs +175 -0
  108. package/lib/setup/sot.mjs +2 -0
  109. package/lib/subagents/cli.mjs +463 -0
  110. package/lib/subagents/cli.test.mjs +389 -0
  111. package/lib/subagents/client.mjs +373 -0
  112. package/lib/subagents/client.test.mjs +309 -0
  113. package/lib/subagents/gap.mjs +268 -0
  114. package/lib/subagents/gap.test.mjs +234 -0
  115. package/lib/subagents/lock.mjs +296 -0
  116. package/lib/subagents/lock.test.mjs +248 -0
  117. package/lib/subagents/manifest.mjs +224 -0
  118. package/lib/subagents/manifest.test.mjs +175 -0
  119. package/lib/subagents/refs.mjs +274 -0
  120. package/lib/subagents/refs.test.mjs +204 -0
  121. package/lib/subagents/resolve.mjs +455 -0
  122. package/lib/subagents/resolve.test.mjs +422 -0
  123. package/lib/subagents/schema.mjs +467 -0
  124. package/lib/subagents/schema.test.mjs +306 -0
  125. package/package.json +8 -3
  126. package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
  127. package/policies/ai-disclosure.yaml +42 -2
  128. package/scaffold/CLAUDE.md +16 -2
  129. package/schedules/triggers/goal-steward.md +79 -0
  130. package/scripts/ci/conformance-org-api.mjs +792 -0
  131. package/scripts/ci/conformance-org-api.test.mjs +417 -0
  132. package/scripts/daemon/agent-daemon.mjs +36 -4
  133. package/scripts/daemon/cadence-handlers.mjs +145 -1
  134. package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
  135. package/scripts/daemon/inbox-deferral.mjs +45 -2
  136. package/scripts/daemon/inbox-deferral.test.mjs +56 -0
  137. package/scripts/daemon/inbox-wake.mjs +282 -0
  138. package/scripts/daemon/inbox-wake.test.mjs +199 -0
  139. package/scripts/daemon/prompt-builder.mjs +41 -1
  140. package/scripts/daemon/typing-registry.mjs +55 -2
  141. package/scripts/daemon/typing-registry.test.mjs +25 -0
  142. package/scripts/local-triggers/generate-plists.test.mjs +5 -5
  143. package/scripts/setup/gen-subagent-manifest.mjs +95 -0
  144. package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
  145. package/scripts/setup/generate-plan.mjs +108 -0
  146. package/scripts/setup/init-capability-manifest.mjs +70 -0
  147. package/scripts/setup/init-skill-marketplace.mjs +155 -0
  148. package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * hydrate.test.mjs — per-family body/context hydration.
3
+ *
4
+ * Two things are being asserted throughout:
5
+ * 1. every surface produces a body a classifier can actually act on, and
6
+ * 2. hydration only ever uses the agent's OWN entitled reads — the call log is
7
+ * checked so a hydrator cannot quietly acquire a wider aperture than the
8
+ * directedness probe that let the item through.
9
+ *
10
+ * Run: node --test lib/org/inbound/hydrate.test.mjs
11
+ */
12
+
13
+ "use strict";
14
+
15
+ import { test } from "node:test";
16
+ import assert from "node:assert/strict";
17
+
18
+ import { hydrate, clip, renderThread, describeBoardKind, describeApprovalKind } from "./hydrate.mjs";
19
+ import { classifyEvent } from "./directedness.mjs";
20
+
21
+ const ME = "M-me";
22
+ const THEM = "M-them";
23
+ const okFrame = (result) => ({ ok: true, result });
24
+
25
+ function fakeIo(routes = {}) {
26
+ const calls = [];
27
+ const logs = [];
28
+ return {
29
+ calls,
30
+ logs,
31
+ log: (level, message, meta) => logs.push({ level, message, meta }),
32
+ async call(method, params) {
33
+ calls.push({ method, params });
34
+ const h = routes[method];
35
+ if (!h) return { ok: false, error: { code: "NOT_FOUND", message: method } };
36
+ return typeof h === "function" ? await h(params) : h;
37
+ },
38
+ async read() {
39
+ return { ok: false, error: { code: "NOT_FOUND", message: "read" }, status: 404 };
40
+ },
41
+ };
42
+ }
43
+
44
+ function facts(over = {}) {
45
+ return {
46
+ me: ME,
47
+ myNames: [],
48
+ channels: new Map(),
49
+ channelPages: new Map(),
50
+ myThreadRootIds: new Set(),
51
+ tasks: null,
52
+ decisions: null,
53
+ escalations: null,
54
+ approvals: null,
55
+ fileAcl: null,
56
+ degraded: [],
57
+ ...over,
58
+ };
59
+ }
60
+
61
+ function ev(over) {
62
+ return { seq: 7, at: "2026-08-11T00:00:00.000Z", actor: THEM, ...over };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // messaging
67
+ // ---------------------------------------------------------------------------
68
+
69
+ test("messaging hydrates from the ACL'd history page — no extra RPC", async () => {
70
+ const f = facts();
71
+ f.channels.set("C-pub", { id: "C-pub", kind: "PUBLIC", name: "General", slug: "general" });
72
+ f.channelPages.set("C-pub", {
73
+ byId: new Map([
74
+ ["m1", { id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
75
+ ]),
76
+ messages: [{ id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
77
+ });
78
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1 } }));
79
+ const io = fakeIo({});
80
+ const h = await hydrate({ candidate: c, verdict: { surface: "mention", reason: "mention" }, facts: f, io });
81
+
82
+ assert.equal(h.ok, true);
83
+ assert.equal(h.text, "@Isla take a look");
84
+ assert.deepEqual(h.from, { id: THEM, name: "Casey" });
85
+ assert.equal(h.channelLabel, "General");
86
+ assert.equal(io.calls.length, 0, "the body was already on the page");
87
+ });
88
+
89
+ test("a message that never hydrated is DROPPED, not delivered blank", async () => {
90
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m404", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC" } }));
91
+ const h = await hydrate({ candidate: c, verdict: { surface: "mention" }, facts: facts(), io: fakeIo({}) });
92
+ assert.equal(h.ok, false);
93
+ assert.equal(h.reason, "message_not_in_page");
94
+ });
95
+
96
+ test("an empty body is dropped (a blank inbound produces a nonsense reply)", async () => {
97
+ const f = facts();
98
+ f.channelPages.set("C", { byId: new Map([["m1", { id: "m1", authorId: THEM, body: " " }]]), messages: [] });
99
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C", channelKind: "DM" } }));
100
+ const h = await hydrate({ candidate: c, verdict: { surface: "dm" }, facts: f, io: fakeIo({}) });
101
+ assert.equal(h.ok, false);
102
+ assert.equal(h.reason, "empty_body");
103
+ });
104
+
105
+ test("a threaded message carries prior-thread context", async () => {
106
+ const msgs = [
107
+ { id: "r1", authorId: ME, authorName: "Isla", body: "kick off", threadRootId: null },
108
+ { id: "m2", authorId: THEM, authorName: "Casey", body: "one more thing", threadRootId: "r1" },
109
+ ];
110
+ const f = facts();
111
+ f.channelPages.set("C-pub", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
112
+ const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", threaded: true } }));
113
+ const h = await hydrate({ candidate: c, verdict: { surface: "thread_reply", reason: "thread" }, facts: f, io: fakeIo({}) });
114
+
115
+ assert.equal(h.isReply, true);
116
+ assert.equal(h.threadId, "r1");
117
+ assert.match(h.threadContext, /Isla: kick off/);
118
+ assert.doesNotMatch(h.threadContext, /one more thing/, "the trigger itself is not its own context");
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // board
123
+ // ---------------------------------------------------------------------------
124
+
125
+ test("board hydrates the task card + its comment thread", async () => {
126
+ const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Ship the thing", col: "doing", priority: 1, detail: "make it fast", channelId: "C-eng" }]]) });
127
+ const io = fakeIo({
128
+ "board.taskComments": ({ taskId }) =>
129
+ okFrame({
130
+ taskId,
131
+ comments: [
132
+ { id: "c1", authorId: ME, authorName: "Isla", body: "on it" },
133
+ { id: "c2", authorId: THEM, authorName: "Casey", body: "any update?" },
134
+ ],
135
+ }),
136
+ });
137
+ const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM, kind: "human" } }));
138
+ const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
139
+
140
+ assert.equal(h.ok, true);
141
+ assert.match(h.text, /New comment on your board item — "Ship the thing"/);
142
+ assert.match(h.text, /Column: doing/);
143
+ assert.match(h.text, /any update\?/);
144
+ assert.match(h.threadContext, /Isla: on it/);
145
+ assert.equal(h.threadId, "T-1");
146
+ assert.deepEqual(io.calls.map((x) => x.method), ["board.taskComments"]);
147
+ });
148
+
149
+ test("a reviewer is TOLD they are the reviewer", async () => {
150
+ const f = facts({ tasks: new Map([["T-2", { id: "T-2", title: "Review me", col: "review" }]]) });
151
+ const io = fakeIo({ "board.taskComments": okFrame({ comments: [] }) });
152
+ const c = classifyEvent(ev({ family: "board", kind: "item.moved", entity_id: "T-2", payload: { actor: THEM, to: "review" } }));
153
+ const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "reviewer" }, facts: f, io });
154
+ assert.match(h.text, /You are the REVIEWER/);
155
+ });
156
+
157
+ test("two candidates on one task share a single taskComments read", async () => {
158
+ let n = 0;
159
+ const io = fakeIo({ "board.taskComments": () => { n += 1; return okFrame({ comments: [] }); } });
160
+ const f = facts({ tasks: new Map([["T-3", { id: "T-3", title: "x" }]]) });
161
+ const cache = new Map();
162
+ const mk = (seq, kind) => classifyEvent(ev({ seq, family: "board", kind, entity_id: "T-3", payload: { actor: THEM } }));
163
+ await hydrate({ candidate: mk(1, "item.commented"), verdict: { surface: "task_comment" }, facts: f, io, cache });
164
+ await hydrate({ candidate: mk(2, "item.moved"), verdict: { surface: "task_comment" }, facts: f, io, cache });
165
+ assert.equal(n, 1);
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // file / doc
170
+ // ---------------------------------------------------------------------------
171
+
172
+ test("a chat-file comment hydrates through file.listComments, scoped by channel", async () => {
173
+ const f = facts();
174
+ f.channels.set("C-team", { id: "C-team", kind: "PRIVATE", name: "Leads" });
175
+ const io = fakeIo({
176
+ "file.listComments": okFrame({ comments: [{ id: "fc1", authorId: THEM, authorName: "Casey", body: "typo on slide 3" }] }),
177
+ });
178
+ const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { fileKey: "message-attachment:abc", channelId: "C-team", authorId: THEM } }));
179
+ const h = await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "channel" }, facts: f, io });
180
+
181
+ assert.equal(h.ok, true);
182
+ assert.match(h.text, /typo on slide 3/);
183
+ // The channel is forwarded so hq can run its own visibility gate.
184
+ assert.equal(io.calls[0].params.channelId, "C-team");
185
+ });
186
+
187
+ test("an unreadable file thread is dropped rather than delivered empty", async () => {
188
+ const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc2", payload: { fileKey: "message-attachment:z", channelId: "C-team" } }));
189
+ const h = await hydrate({ candidate: c, verdict: { surface: "file_comment" }, facts: facts(), io: fakeIo({}) });
190
+ assert.equal(h.ok, false);
191
+ assert.equal(h.reason, "file_comment_unreadable");
192
+ });
193
+
194
+ test("a workspace doc comment hydrates through files.comments", async () => {
195
+ const f = facts({ fileAcl: new Map([["F-1", { ownerId: ME, shared: false, name: "Spec" }]]) });
196
+ const io = fakeIo({
197
+ "files.comments": okFrame({ comments: [{ id: "dc1", authorId: THEM, authorName: "Casey", body: "can we cut section 4?" }] }),
198
+ });
199
+ const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: { name: "Spec", commentId: "dc1" } }));
200
+ const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
201
+
202
+ assert.equal(h.ok, true);
203
+ assert.match(h.text, /New comment on "Spec"/);
204
+ assert.match(h.text, /cut section 4/);
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // decision / escalation / approval / handoff
209
+ // ---------------------------------------------------------------------------
210
+
211
+ test("a decision carries status, body, rationale and the latest comment", async () => {
212
+ const f = facts({
213
+ decisions: new Map([["D-1", { id: "D-1", title: "Adopt X", status: "proposed", scope: "eng", body: "we adopt X", rationale: "cheaper" }]]),
214
+ });
215
+ const io = fakeIo({
216
+ "decision.listComments": okFrame({ comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "what about Y?" }] }),
217
+ });
218
+ const c = classifyEvent(ev({ family: "decision", kind: "decision.adjustment_requested", entity_id: "D-1", payload: { commentId: "c1", authorId: THEM } }));
219
+ const h = await hydrate({ candidate: c, verdict: { surface: "decision", reason: "proposer" }, facts: f, io });
220
+
221
+ assert.match(h.text, /An adjustment was requested on a decision — "Adopt X"/);
222
+ assert.match(h.text, /Status: proposed/);
223
+ assert.match(h.text, /Rationale: cheaper/);
224
+ assert.match(h.text, /what about Y\?/);
225
+ assert.match(h.text, /needs a response/);
226
+ });
227
+
228
+ test("an escalation needs no extra read — the facts pass already had it", async () => {
229
+ const f = facts({ escalations: new Map([["E-1", { id: "E-1", title: "prod down", severity: "high", detail: "500s on /v1", waitingOn: ME }]]) });
230
+ const io = fakeIo({});
231
+ const c = classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM, severity: "high" } }));
232
+ const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "waiting_on" }, facts: f, io });
233
+
234
+ assert.match(h.text, /Escalation raised — "prod down"/);
235
+ assert.match(h.text, /Severity: high/);
236
+ assert.match(h.text, /Waiting on: M-me/);
237
+ assert.equal(io.calls.length, 0);
238
+ });
239
+
240
+ test("an approval renders the whole decision packet", async () => {
241
+ const f = facts({
242
+ approvals: new Map([["A-1", { id: "A-1", requester: THEM, approver: ME, status: "pending", actionClass: "spend.over_1k", reason: "vendor renewal", itemId: "T-9", expiresAt: "2026-08-12T00:00:00.000Z" }]]),
243
+ });
244
+ const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM, actionClass: "spend.over_1k" } }));
245
+ const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io: fakeIo({}) });
246
+
247
+ assert.match(h.text, /An approval is waiting on you — spend\.over_1k/);
248
+ assert.match(h.text, /Status: pending/);
249
+ assert.match(h.text, /vendor renewal/);
250
+ assert.match(h.text, /Board item: T-9/);
251
+ });
252
+
253
+ test("a handoff renders intent and both ends from the payload alone", async () => {
254
+ const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Migrate DB", detail: "zero downtime" }]]) });
255
+ const c = classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { itemId: "T-1", from: THEM, to: ME, intent: "please take this over" } }));
256
+ const h = await hydrate({ candidate: c, verdict: { surface: "handoff", reason: "direct" }, facts: f, io: fakeIo({}) });
257
+
258
+ assert.match(h.text, /A handoff was offered to you — "Migrate DB"/);
259
+ assert.match(h.text, /Intent: please take this over/);
260
+ assert.match(h.text, /From M-them → to M-me/);
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // email
265
+ // ---------------------------------------------------------------------------
266
+
267
+ test("email hydrates through email.thread, which hq scopes to MY mailbox", async () => {
268
+ const io = fakeIo({
269
+ "email.thread": ({ threadId }) =>
270
+ okFrame({
271
+ thread: { id: threadId, subject: "Contract renewal" },
272
+ messages: [
273
+ { id: "EM-0", subject: "Contract renewal", text: "here is the draft", from: { name: "Dana", address: "dana@acme.com" } },
274
+ { id: "EM-1", subject: "Re: Contract renewal", text: "any thoughts?", from: { name: "Dana", address: "dana@acme.com" } },
275
+ ],
276
+ }),
277
+ });
278
+ const c = classifyEvent(ev({ family: "email", kind: "received", actor: "system", entity_id: "EM-1", payload: { emailMessageId: "EM-1", threadId: "ET-1", mailboxId: "MB-1", memberId: ME } }));
279
+ const h = await hydrate({ candidate: c, verdict: { surface: "email", reason: "direct" }, facts: facts(), io });
280
+
281
+ assert.equal(h.ok, true);
282
+ assert.equal(h.text, "any thoughts?");
283
+ assert.equal(h.subjectDetail, "Re: Contract renewal");
284
+ assert.equal(h.from.name, "Dana");
285
+ assert.match(h.threadContext, /here is the draft/);
286
+ assert.deepEqual(io.calls.map((x) => x.method), ["email.thread"]);
287
+ });
288
+
289
+ test("unreadable mail is dropped (not another seat's mail leaking through)", async () => {
290
+ const c = classifyEvent(ev({ family: "email", kind: "received", entity_id: "EM-9", payload: { emailMessageId: "EM-9", threadId: "ET-9", memberId: ME } }));
291
+ const h = await hydrate({ candidate: c, verdict: { surface: "email" }, facts: facts(), io: fakeIo({}) });
292
+ assert.equal(h.ok, false);
293
+ assert.equal(h.reason, "email_unreadable");
294
+ });
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // robustness
298
+ // ---------------------------------------------------------------------------
299
+
300
+ test("a hydrator that throws fails open, loudly, and never takes the pull down", async () => {
301
+ const io = fakeIo({
302
+ "board.taskComments": () => {
303
+ throw new Error("boom");
304
+ },
305
+ });
306
+ const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
307
+ const h = await hydrate({ candidate: c, verdict: { surface: "task_comment" }, facts: facts(), io });
308
+ assert.equal(h.ok, false);
309
+ assert.equal(h.reason, "hydrate_threw");
310
+ assert.ok(io.logs.some((l) => l.level === "warn" && /hydrate board threw/.test(l.message)));
311
+ });
312
+
313
+ test("clip and renderThread are bounded", () => {
314
+ assert.equal(clip("abc", 10), "abc");
315
+ assert.ok(clip("x".repeat(100), 10).length <= 11);
316
+ assert.equal(renderThread([]), null);
317
+ assert.equal(renderThread(null), null);
318
+ assert.match(renderThread([{ authorName: "A", body: "hi" }]), /A: hi/);
319
+ });
320
+
321
+ test("event-kind phrasing is stable and total", () => {
322
+ assert.match(describeBoardKind("item.assigned"), /assigned to you/);
323
+ assert.match(describeBoardKind("who.knows"), /Update on your board item/);
324
+ assert.match(describeApprovalKind("approval.requested"), /waiting on you/);
325
+ assert.match(describeApprovalKind("approval.rejected"), /REJECTED/);
326
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * lib/org/inbound/index.mjs — WIDE inbound: give an SDK agent the same inbound
3
+ * coverage hq's own responder has.
4
+ *
5
+ * ── THE PROBLEM ──
6
+ * `lib/org/messaging.pullInbound` tails two families (`messaging`, `calling`)
7
+ * and recognises two things: a message in a private room I belong to, and a call
8
+ * in one of my rooms. hq's in-process responder (`src/server/llm-responder/`)
9
+ * reacts to eleven triggers — channel @mentions, thread replies, reactions,
10
+ * polls, decision comments, board comments, file comments, genUI actions,
11
+ * inbound email, call utterances, ambient. And since hq commit `4d762d30` it
12
+ * STANDS DOWN for any member whose daemon beat inside 100 s
13
+ * (`llm-responder/sdk-driven.ts`). So for a live SDK agent the net effect of an
14
+ * @mention in a space today is: **nobody answers**. hq is quiet by design and the
15
+ * SDK is deaf by omission.
16
+ *
17
+ * ── THE SHAPE OF THE FIX ──
18
+ * Four pure-ish stages over one injectable IO seam:
19
+ *
20
+ * events ──classifyEvent──▶ candidates (pure, directedness.mjs)
21
+ * │
22
+ * resolveFacts ◀───────┘ (agent's OWN entitled reads,
23
+ * │ facts.mjs — batched + capped)
24
+ * ▼
25
+ * resolveDirected (pure verdict + typed reason)
26
+ * │ directed only
27
+ * ▼
28
+ * hydrate (bodies, ACL'd, hydrate.mjs)
29
+ * │
30
+ * ▼
31
+ * toMessageEvent (existing contract, project.mjs)
32
+ *
33
+ * Nothing downstream changes: the output is the same `MessageEvent` array
34
+ * `pullInbound` already returns, so `guardMessagingInbound` keeps calling
35
+ * `eventToInboxItem` + `writeInboxItem` verbatim. The only new information the
36
+ * daemon sees is `kind`, which carries the surface so the classifier can route.
37
+ *
38
+ * ── SAFETY ──
39
+ * The org feed is org-wide and redacted. The hard rule, enforced in
40
+ * `directedness.mjs` and again in `facts.mjs`, is: **a channel outside my
41
+ * `messaging.channels` roster is never probed, never hydrated, and never
42
+ * directed.** hq returns a non-PUBLIC channel only to its members, so a private
43
+ * room I am not in cannot appear — and a negative test asserts that no
44
+ * `messaging.history` call is ever made for one.
45
+ *
46
+ * ── FAIL-OPEN, NEVER SILENT ──
47
+ * Every read that fails degrades the verdict to "unknown" (which closes private
48
+ * surfaces rather than opening them) and logs through `io.log`. `pullWideInbound`
49
+ * never throws into its caller, and returns `stats` so the cadence handler can
50
+ * log what it spent and what it dropped.
51
+ *
52
+ * @module lib/org/inbound
53
+ */
54
+
55
+ "use strict";
56
+
57
+ import { read as clientRead } from "../client.mjs";
58
+ import { classifyEvent, resolveDirected } from "./directedness.mjs";
59
+ import { resolveFacts, DEFAULT_LIMITS } from "./facts.mjs";
60
+ import { hydrate } from "./hydrate.mjs";
61
+ import { toMessageEvent } from "./project.mjs";
62
+ import { makeIo, resolveOrg } from "./io.mjs";
63
+ import { resolveEnabledSurfaces, parseSurfaceEnv, SURFACES } from "./surfaces.mjs";
64
+
65
+ export { SURFACES, parseSurfaceEnv, resolveEnabledSurfaces } from "./surfaces.mjs";
66
+ export { classifyEvent, resolveDirected } from "./directedness.mjs";
67
+ export { resolveFacts } from "./facts.mjs";
68
+ export { hydrate } from "./hydrate.mjs";
69
+ export { toMessageEvent } from "./project.mjs";
70
+ export { makeIo } from "./io.mjs";
71
+
72
+ /** Default page size for the `/v1/events` read. */
73
+ const DEFAULT_EVENT_LIMIT = 200;
74
+
75
+ /**
76
+ * Pull every DIRECTED org event since `cursor` and project it onto MessageEvent.
77
+ *
78
+ * Drop-in for `lib/org/messaging.pullInbound`: same `{cfg, agentId, cursor,
79
+ * limit, fetchImpl}` in, same `{events, nextCursor}` out — plus `stats`.
80
+ *
81
+ * @param {Object} o
82
+ * @param {object} o.cfg the agent config (org.cohort.*)
83
+ * @param {string} [o.agentId] the seat's member id (REQUIRED in
84
+ * practice — without it own-echo suppression is off and every direct surface
85
+ * misses, so we refuse to run and say so)
86
+ * @param {number|string} [o.cursor]
87
+ * @param {number} [o.limit]
88
+ * @param {Function} [o.fetchImpl]
89
+ * @param {string[]} [o.myNames] display names for prose-mention matching
90
+ * @param {string[]|Record<string,boolean>} [o.surfaces] surface enable override
91
+ * @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
92
+ * @param {Function} [o.log] (level, message, meta?) — daemon logger
93
+ * @param {import("./io.mjs").InboundIo} [o.io] injected IO (tests)
94
+ * @param {Function} [o.readImpl] injected `/v1` GET (tests)
95
+ * @returns {Promise<{events:object[], nextCursor:(number|string|null), stats:object}>}
96
+ */
97
+ export async function pullWideInbound(o = {}) {
98
+ const log = typeof o.log === "function" ? o.log : () => {};
99
+ const cursor = o.cursor ?? 0;
100
+ const stats = {
101
+ scanned: 0,
102
+ classified: 0,
103
+ directed: 0,
104
+ delivered: 0,
105
+ bySurface: {},
106
+ dropped: {},
107
+ degraded: [],
108
+ calls: 0,
109
+ reads: 0,
110
+ };
111
+ const empty = { events: [], nextCursor: cursor, stats };
112
+
113
+ const org = resolveOrg(o.cfg);
114
+ if (!org) {
115
+ log("info", "[inbound] org messaging not configured — wide inbound inert");
116
+ return empty;
117
+ }
118
+
119
+ const me = o.agentId ? String(o.agentId) : "";
120
+ if (!me) {
121
+ // NEVER SILENT. Without the seat id we cannot suppress the agent's own
122
+ // echoes and every payload-named surface (email/approval/handoff) misses.
123
+ // Running anyway would produce an agent talking to itself.
124
+ log("error", "[inbound] no agentId — refusing to pull (set COHORT_AGENT_ID)");
125
+ return empty;
126
+ }
127
+
128
+ const { enabled, unknown } = resolveEnabledSurfaces(
129
+ o.surfaces !== undefined ? o.surfaces : parseSurfaceEnv(process.env.COHORT_INBOUND_SURFACES),
130
+ );
131
+ if (unknown.length) {
132
+ log("warn", `[inbound] ignoring unknown surface name(s): ${unknown.join(", ")}`);
133
+ }
134
+
135
+ const io =
136
+ o.io ||
137
+ makeIo({ cfg: o.cfg, fetchImpl: o.fetchImpl, log, timeoutMs: o.timeoutMs });
138
+ if (!io) return empty;
139
+
140
+ // ── 1. Read the ledger window. hq IGNORES `family=` on this read
141
+ // (`src/server/rpc/reads.ts` looks at `cursor` + `limit` only), so the
142
+ // filter is ours to apply — which is exactly why widening the aperture
143
+ // costs no extra bandwidth: the rows were always arriving.
144
+ const limit = Number.isFinite(o.limit) ? o.limit : DEFAULT_EVENT_LIMIT;
145
+ const readImpl = o.readImpl || (io.read ? (p) => io.read(p) : clientRead);
146
+ const r = await readImpl(
147
+ `events?cursor=${encodeURIComponent(cursor)}&limit=${encodeURIComponent(limit)}`,
148
+ );
149
+ if (!r || r.ok !== true || !r.payload) {
150
+ log("warn", "[inbound] events read failed — cursor held, will retry next tick");
151
+ return empty;
152
+ }
153
+
154
+ const raw = Array.isArray(r.payload)
155
+ ? r.payload
156
+ : Array.isArray(r.payload.events)
157
+ ? r.payload.events
158
+ : [];
159
+ stats.scanned = raw.length;
160
+
161
+ let maxSeq = cursor;
162
+ const candidates = [];
163
+ for (const ev of raw) {
164
+ const seq = Number(ev && ev.seq);
165
+ if (Number.isFinite(seq)) maxSeq = Math.max(Number(maxSeq) || 0, seq);
166
+ const cand = classifyEvent(ev);
167
+ if (cand) candidates.push(cand);
168
+ }
169
+ stats.classified = candidates.length;
170
+ const nextCursor = raw.length ? maxSeq : cursor;
171
+
172
+ if (candidates.length === 0) return { events: [], nextCursor, stats };
173
+
174
+ // ── 2. The agent's own half of the join.
175
+ const facts = await resolveFacts({
176
+ candidates,
177
+ me,
178
+ io,
179
+ myNames: o.myNames,
180
+ enabled,
181
+ limits: { ...DEFAULT_LIMITS, ...(o.limits || {}) },
182
+ });
183
+ stats.degraded = facts.degraded.slice();
184
+
185
+ // ── 3. Verdicts.
186
+ const directed = [];
187
+ for (const cand of candidates) {
188
+ const verdict = resolveDirected(cand, me, facts, { enabled });
189
+ if (!verdict.directed) {
190
+ stats.dropped[verdict.reason] = (stats.dropped[verdict.reason] || 0) + 1;
191
+ continue;
192
+ }
193
+ directed.push({ cand, verdict });
194
+ }
195
+ stats.directed = directed.length;
196
+
197
+ // ── 4. Hydrate + project. Body reads happen ONLY for what is already mine.
198
+ const cache = new Map();
199
+ const events = [];
200
+ for (const { cand, verdict } of directed) {
201
+ const hydrated = await hydrate({ candidate: cand, verdict, facts, io, cache });
202
+ if (!hydrated || hydrated.ok !== true) {
203
+ const why = (hydrated && hydrated.reason) || "hydration_failed";
204
+ stats.dropped[why] = (stats.dropped[why] || 0) + 1;
205
+ // A blank inbound produces a nonsense reply in a real room. Drop it, loudly.
206
+ log("warn", `[inbound] dropping ${verdict.surface} ${cand.entityId || cand.seq}: ${why}`);
207
+ continue;
208
+ }
209
+ const projected = toMessageEvent({ candidate: cand, verdict, hydrated, me, facts });
210
+ if (!projected) {
211
+ stats.dropped.projection_refused = (stats.dropped.projection_refused || 0) + 1;
212
+ continue;
213
+ }
214
+ events.push(projected);
215
+ stats.bySurface[verdict.surface] = (stats.bySurface[verdict.surface] || 0) + 1;
216
+ }
217
+ stats.delivered = events.length;
218
+ if (io.stats) {
219
+ stats.calls = io.stats.calls;
220
+ stats.reads = io.stats.reads;
221
+ }
222
+
223
+ if (events.length) {
224
+ const summary = Object.entries(stats.bySurface)
225
+ .map(([k, v]) => `${k}×${v}`)
226
+ .join(", ");
227
+ log("info", `[inbound] ${events.length} directed item(s): ${summary}`);
228
+ }
229
+
230
+ return { events, nextCursor, stats };
231
+ }
232
+
233
+ export default { pullWideInbound };