@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,324 @@
1
+ /**
2
+ * index.test.mjs — end-to-end `pullWideInbound` over an injected fake org.
3
+ *
4
+ * One realistic ledger window carrying ten families is pulled and the result is
5
+ * asserted twice: as MessageEvents, and as the flat inbox items the daemon
6
+ * actually consumes. The negative half is asserted by CALL LOG — a private room
7
+ * this agent is not in must produce no history fetch, no body, and no item.
8
+ *
9
+ * Run: node --test lib/org/inbound/index.test.mjs
10
+ */
11
+
12
+ "use strict";
13
+
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ import { pullWideInbound } from "./index.mjs";
18
+ import { eventToInboxItem } from "../../channels/inbox-item.mjs";
19
+
20
+ const ME = "M-me";
21
+ const THEM = "M-them";
22
+ const BOSS = "M-boss";
23
+
24
+ const okFrame = (result) => ({ ok: true, result });
25
+ const okRead = (payload) => ({ ok: true, payload, status: 200 });
26
+
27
+ /** An org fake: routes for `call` + `read`, with a full call log. */
28
+ function fakeIo(routes = {}) {
29
+ const calls = [];
30
+ const logs = [];
31
+ return {
32
+ calls,
33
+ logs,
34
+ stats: { calls: 0, reads: 0, failures: 0, methods: [] },
35
+ log: (level, message, meta) => logs.push({ level, message, meta }),
36
+ async call(method, params) {
37
+ calls.push({ method, params });
38
+ this.stats.calls += 1;
39
+ const h = routes[method];
40
+ if (!h) return { ok: false, error: { code: "NOT_FOUND", message: method } };
41
+ return typeof h === "function" ? await h(params) : h;
42
+ },
43
+ async read(path) {
44
+ const base = String(path).split("?")[0];
45
+ calls.push({ method: `read:${base}`, path });
46
+ this.stats.reads += 1;
47
+ const h = routes[`read:${base}`];
48
+ if (!h) return { ok: false, error: { code: "NOT_FOUND", message: base }, status: 404 };
49
+ return typeof h === "function" ? await h(path) : h;
50
+ },
51
+ };
52
+ }
53
+
54
+ /** A cfg that passes `isEnabled` + `configFromAgent`. */
55
+ const CFG = { org: { cohort: { enabled: true, base: "https://os.cohortapp.com/adaptic", token: "tok" } } };
56
+
57
+ // A ledger window with one row per surface, plus three rows that must NOT reach me.
58
+ const LEDGER = [
59
+ // (1) DM to me
60
+ { seq: 1, family: "messaging", kind: "send", entity_id: "m-dm", actor: THEM, at: "2026-08-11T09:00:00.000Z",
61
+ payload: { actor: THEM, channelId: "C-dm", channelKind: "DM", mentionCount: 0, threaded: false } },
62
+ // (2) @mention in a public space — THE headline gap
63
+ { seq: 2, family: "messaging", kind: "send", entity_id: "m-mention", actor: THEM, at: "2026-08-11T09:01:00.000Z",
64
+ payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1, threaded: false } },
65
+ // (3) thread reply in a thread I started
66
+ { seq: 3, family: "messaging", kind: "send", entity_id: "m-thread", actor: THEM, at: "2026-08-11T09:02:00.000Z",
67
+ payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: true } },
68
+ // (4) NEGATIVE: someone else's DM
69
+ { seq: 4, family: "messaging", kind: "send", entity_id: "m-theirs", actor: THEM, at: "2026-08-11T09:03:00.000Z",
70
+ payload: { actor: THEM, channelId: "C-theirs", channelKind: "DM", mentionCount: 0, threaded: false } },
71
+ // (5) NEGATIVE: ambient public chatter
72
+ { seq: 5, family: "messaging", kind: "send", entity_id: "m-noise", actor: THEM, at: "2026-08-11T09:04:00.000Z",
73
+ payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: false } },
74
+ // (6) board assignment to me
75
+ { seq: 6, family: "board", kind: "item.assigned", entity_id: "T-1", actor: BOSS, at: "2026-08-11T09:05:00.000Z",
76
+ payload: { actor: BOSS, assignee: ME, assignedBy: BOSS } },
77
+ // (7) comment on my task
78
+ { seq: 7, family: "board", kind: "item.commented", entity_id: "T-1", actor: THEM, at: "2026-08-11T09:06:00.000Z",
79
+ payload: { actor: THEM, kind: "human" } },
80
+ // (8) NEGATIVE: comment on someone else's task
81
+ { seq: 8, family: "board", kind: "item.commented", entity_id: "T-99", actor: THEM, at: "2026-08-11T09:07:00.000Z",
82
+ payload: { actor: THEM, kind: "human" } },
83
+ // (9) doc comment on a doc I own
84
+ { seq: 9, family: "files", kind: "doc.comment", entity_id: "F-1", actor: THEM, at: "2026-08-11T09:08:00.000Z",
85
+ payload: { name: "Spec", commentId: "dc1", anchored: false } },
86
+ // (10) approval on my desk
87
+ { seq: 10, family: "approval", kind: "approval.requested", entity_id: "A-1", actor: THEM, at: "2026-08-11T09:09:00.000Z",
88
+ payload: { requester: THEM, actionClass: "spend.over_1k", payloadHash: "h" } },
89
+ // (11) decision I proposed gets an adjustment request
90
+ { seq: 11, family: "decision", kind: "decision.adjustment_requested", entity_id: "D-1", actor: THEM, at: "2026-08-11T09:10:00.000Z",
91
+ payload: { commentId: "c1", authorId: THEM, isAdjustment: true, length: 12 } },
92
+ // (12) handoff offered to me
93
+ { seq: 12, family: "handoff", kind: "handoff.offered", entity_id: "R-1", actor: THEM, at: "2026-08-11T09:11:00.000Z",
94
+ payload: { itemId: "T-1", from: THEM, to: ME, intent: "take this over", handoffId: "H-1" } },
95
+ // (13) inbound email to my mailbox (surface OFF by default)
96
+ { seq: 13, family: "email", kind: "received", entity_id: "EM-1", actor: "system", at: "2026-08-11T09:12:00.000Z",
97
+ payload: { emailMessageId: "EM-1", threadId: "ET-1", mailboxId: "MB-1", memberId: ME, autoSubmitted: false } },
98
+ // (14) noise family the daemon must never be woken for
99
+ { seq: 14, family: "presence", kind: "beat", entity_id: null, actor: THEM, at: "2026-08-11T09:13:00.000Z",
100
+ payload: { focus: "x" } },
101
+ // (15) my OWN message — the echo that must never come back
102
+ { seq: 15, family: "messaging", kind: "send", entity_id: "m-mine", actor: ME, at: "2026-08-11T09:14:00.000Z",
103
+ payload: { actor: ME, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1, threaded: false } },
104
+ ];
105
+
106
+ function routes(over = {}) {
107
+ return {
108
+ "read:events": okRead({ events: LEDGER }),
109
+ "messaging.channels": okFrame({
110
+ channels: [
111
+ { id: "C-dm", kind: "DM", slug: "dm-them", name: "them" },
112
+ { id: "C-pub", kind: "PUBLIC", slug: "general", name: "General" },
113
+ ],
114
+ }),
115
+ "messaging.history": ({ channelId }) => {
116
+ if (channelId === "C-dm") {
117
+ return okFrame({ channelId, messages: [{ id: "m-dm", authorId: THEM, authorName: "Casey", body: "are you free at 3?", mentions: [] }] });
118
+ }
119
+ if (channelId === "C-pub") {
120
+ return okFrame({
121
+ channelId,
122
+ messages: [
123
+ { id: "root-1", authorId: ME, authorName: "Isla", body: "kicking off the migration", mentions: [] },
124
+ { id: "m-mention", authorId: THEM, authorName: "Casey", body: "@Isla can you review this?", mentions: [ME] },
125
+ { id: "m-thread", authorId: THEM, authorName: "Casey", body: "one more thing on that", threadRootId: "root-1", mentions: [] },
126
+ { id: "m-noise", authorId: THEM, authorName: "Casey", body: "standup at 10", mentions: [] },
127
+ { id: "m-mine", authorId: ME, authorName: "Isla", body: "on it", mentions: [ME] },
128
+ ],
129
+ });
130
+ }
131
+ throw new Error(`history fetched for a channel outside my roster: ${channelId}`);
132
+ },
133
+ "read:board.context": okRead({
134
+ lanes: [{ columns: { doing: [{ id: "T-1", title: "Migrate the DB", col: "doing", assigneeId: ME, reviewerId: null, detail: "zero downtime" }] } }],
135
+ }),
136
+ "board.taskComments": ({ taskId }) => okFrame({ taskId, comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "how is this going?" }] }),
137
+ "files.get": ({ fileId }) =>
138
+ fileId === "F-1"
139
+ ? okFrame({ file: { id: "F-1", name: "Spec", ownerId: ME }, permission: "owner" })
140
+ : { ok: false, error: { code: "NOT_FOUND", message: "no" } },
141
+ "files.comments": okFrame({ comments: [{ id: "dc1", authorId: THEM, authorName: "Casey", body: "can we cut section 4?" }] }),
142
+ "approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME, status: "pending", actionClass: "spend.over_1k", reason: "vendor renewal" }),
143
+ "read:decision.list": okRead({ decisions: [{ id: "D-1", title: "Adopt X", status: "proposed", proposedBy: ME, body: "we adopt X" }] }),
144
+ "decision.listComments": okFrame({ comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "what about Y?" }] }),
145
+ "email.thread": ({ threadId }) =>
146
+ okFrame({ thread: { id: threadId, subject: "Contract" }, messages: [{ id: "EM-1", subject: "Contract", text: "please review", from: { name: "Dana", address: "dana@acme.com" } }] }),
147
+ ...over,
148
+ };
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+
153
+ test("one pull delivers every surface the agent is entitled to", async () => {
154
+ const io = fakeIo(routes());
155
+ const { events, nextCursor, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 0, io });
156
+
157
+ assert.equal(nextCursor, 15, "the cursor advances past every scanned row, delivered or not");
158
+ const bySurface = {};
159
+ for (const e of events) bySurface[e.cohort.surface] = (bySurface[e.cohort.surface] || 0) + 1;
160
+
161
+ assert.deepEqual(bySurface, {
162
+ dm: 1,
163
+ mention: 1,
164
+ thread_reply: 1,
165
+ task_assigned: 1,
166
+ task_comment: 1,
167
+ doc_comment: 1,
168
+ approval: 1,
169
+ decision: 1,
170
+ handoff: 1,
171
+ });
172
+ assert.equal(stats.scanned, 15);
173
+ assert.equal(stats.delivered, 9);
174
+ });
175
+
176
+ test("the typed reason rides along on every delivered item", async () => {
177
+ const io = fakeIo(routes());
178
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
179
+ const reasons = Object.fromEntries(events.map((e) => [e.cohort.surface, e.cohort.reason]));
180
+ assert.deepEqual(reasons, {
181
+ dm: "dm",
182
+ mention: "mention",
183
+ thread_reply: "thread",
184
+ task_assigned: "assignee",
185
+ task_comment: "assignee",
186
+ doc_comment: "owner",
187
+ approval: "approver",
188
+ decision: "proposer",
189
+ handoff: "direct",
190
+ });
191
+ });
192
+
193
+ test("NEGATIVE: nothing from someone else's private room, my own echo, or ambient noise", async () => {
194
+ const io = fakeIo(routes());
195
+ const { events, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
196
+
197
+ const ids = events.map((e) => e.message_id);
198
+ assert.equal(ids.includes("m-theirs"), false, "someone else's DM must never be delivered");
199
+ assert.equal(ids.includes("m-mine"), false, "my own message must never come back");
200
+ assert.equal(ids.includes("m-noise"), false, "ambient public chatter is not inbound");
201
+ assert.equal(events.some((e) => e.cohort.entity_id === "T-99"), false, "another seat's task is not mine");
202
+
203
+ // Asserted by CALL LOG, not just by absence of output: the private room was
204
+ // never even probed.
205
+ const probed = io.calls.filter((c) => c.method === "messaging.history").map((c) => c.params.channelId);
206
+ assert.deepEqual(probed.sort(), ["C-dm", "C-pub"]);
207
+ assert.ok(stats.dropped.own_echo >= 1);
208
+ assert.ok(stats.dropped.channel_not_visible >= 1);
209
+ });
210
+
211
+ test("email is OFF by default and is not even hydrated", async () => {
212
+ const io = fakeIo(routes());
213
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
214
+ assert.equal(events.some((e) => e.cohort.surface === "email"), false);
215
+ assert.equal(io.calls.some((c) => c.method === "email.thread"), false, "a disabled surface costs nothing");
216
+ });
217
+
218
+ test("email switches on explicitly (for a fleet with the orgmail adapter off)", async () => {
219
+ const io = fakeIo(routes());
220
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, surfaces: { email: true } });
221
+ const mail = events.find((e) => e.cohort.surface === "email");
222
+ assert.ok(mail, "email must be deliverable when enabled");
223
+ assert.equal(mail.kind, "email");
224
+ assert.equal(mail.text, "please review");
225
+ });
226
+
227
+ test("a surface subset turns everything else off", async () => {
228
+ const io = fakeIo(routes());
229
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, surfaces: ["mention"] });
230
+ assert.deepEqual(events.map((e) => e.cohort.surface), ["mention"]);
231
+ });
232
+
233
+ test("the delivered events survive eventToInboxItem with their routing hints intact", async () => {
234
+ const io = fakeIo(routes());
235
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
236
+ const items = events.map(eventToInboxItem);
237
+
238
+ for (const item of items) {
239
+ assert.equal(item.service, "cohort");
240
+ assert.ok(item.id, "every item needs a dedup id");
241
+ assert.ok(item.content, "every item needs a body");
242
+ assert.ok(item.timestamp);
243
+ }
244
+ const kinds = items.map((i) => i.kind || "message").sort();
245
+ assert.deepEqual(kinds, [
246
+ "approval", "decision", "file_comment", "handoff", "mention",
247
+ "message", "task_assigned", "task_comment", "thread_reply",
248
+ ]);
249
+ });
250
+
251
+ test("the DM item is unchanged in shape from what ships today", async () => {
252
+ const io = fakeIo(routes());
253
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
254
+ const dm = eventToInboxItem(events.find((e) => e.cohort.surface === "dm"));
255
+ assert.equal(dm.id, "cohort-m-dm");
256
+ assert.equal("kind" in dm, false);
257
+ assert.equal(dm.subject, "Cohort message");
258
+ assert.equal(dm.content, "are you free at 3?");
259
+ assert.equal(dm.sender, "Casey");
260
+ assert.equal(dm.source, "cohort");
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // failure modes
265
+ // ---------------------------------------------------------------------------
266
+
267
+ test("no agentId → refuses to pull, LOUDLY (own echoes would come back)", async () => {
268
+ const logs = [];
269
+ const io = fakeIo(routes());
270
+ const out = await pullWideInbound({ cfg: CFG, agentId: "", io, log: (l, m) => logs.push(`${l}:${m}`) });
271
+ assert.deepEqual(out.events, []);
272
+ assert.equal(io.calls.length, 0);
273
+ assert.ok(logs.some((l) => l.startsWith("error:") && /agentId/.test(l)));
274
+ });
275
+
276
+ test("org not configured → inert, no throw", async () => {
277
+ const out = await pullWideInbound({ cfg: { org: { cohort: { enabled: false } } }, agentId: ME });
278
+ assert.deepEqual(out.events, []);
279
+ assert.equal(out.nextCursor, 0);
280
+ });
281
+
282
+ test("events read fails → cursor HELD so nothing is skipped", async () => {
283
+ const io = fakeIo({ ...routes(), "read:events": { ok: false, error: { code: "INTERNAL", message: "boom" }, status: 500 } });
284
+ const out = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 7, io });
285
+ assert.deepEqual(out.events, []);
286
+ assert.equal(out.nextCursor, 7);
287
+ });
288
+
289
+ test("a degraded fact set closes surfaces rather than opening them", async () => {
290
+ // Roster unreadable: every messaging surface must go quiet, and say why.
291
+ const io = fakeIo({ ...routes(), "messaging.channels": { ok: false, error: { code: "INTERNAL", message: "down" } } });
292
+ const { events, stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
293
+ assert.equal(events.some((e) => e.cohort.family === "messaging"), false);
294
+ assert.ok(stats.degraded.includes("channels"));
295
+ // The families that do not need the roster still work.
296
+ assert.ok(events.some((e) => e.cohort.surface === "task_assigned"));
297
+ });
298
+
299
+ test("an unhydratable directed item is dropped with a WARNING, not delivered blank", async () => {
300
+ const io = fakeIo({ ...routes(), "board.taskComments": { ok: false, error: { code: "INTERNAL", message: "down" } }, "files.get": { ok: false, error: { code: "INTERNAL", message: "down" } } });
301
+ const logs = [];
302
+ const { events } = await pullWideInbound({ cfg: CFG, agentId: ME, io, log: (l, m) => logs.push(`${l}:${m}`) });
303
+ // The board item still renders from the task card; the doc has no ACL entry so
304
+ // it never becomes directed in the first place.
305
+ assert.equal(events.some((e) => e.cohort.surface === "doc_comment"), false);
306
+ for (const e of events) assert.ok(e.text.trim(), "no blank body may ever be delivered");
307
+ });
308
+
309
+ test("stats report what was spent and why things were dropped", async () => {
310
+ const io = fakeIo(routes());
311
+ const { stats } = await pullWideInbound({ cfg: CFG, agentId: ME, io });
312
+ assert.equal(stats.scanned, 15);
313
+ // 15 rows in, 1 (`presence`) has no rule at all → never becomes a candidate.
314
+ assert.equal(stats.classified, 14, "noise families are dropped before any RPC is spent");
315
+ assert.ok(stats.calls > 0 && stats.reads > 0);
316
+ assert.ok(Object.keys(stats.dropped).length > 0);
317
+ });
318
+
319
+ test("an empty ledger window is a no-op that still advances nothing", async () => {
320
+ const io = fakeIo({ ...routes(), "read:events": okRead({ events: [] }) });
321
+ const out = await pullWideInbound({ cfg: CFG, agentId: ME, cursor: 5, io });
322
+ assert.deepEqual(out.events, []);
323
+ assert.equal(out.nextCursor, 5);
324
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * lib/org/inbound/io.mjs — the ONE injectable network seam for wide inbound.
3
+ *
4
+ * Everything the directedness resolver and the hydrators need from hq goes
5
+ * through this object, so a test injects a plain fake and never touches the
6
+ * wire. It is a thin adapter over `lib/org/client.mjs` `call` / `read` that
7
+ * adds exactly three things the raw client does not:
8
+ *
9
+ * 1. Config resolution — `{cfg}` → `{base, token, orgId}` via the SAME
10
+ * `isEnabled` + `configFromAgent` chain every other org module uses, so a
11
+ * disabled agent is inert here for the same reason it is inert everywhere.
12
+ * 2. Uniform, NEVER-SILENT fail-open — every call returns
13
+ * `{ok:false, error}` instead of throwing, AND logs the failure with the
14
+ * method that failed. Silent fail-open has caused every bug in this area:
15
+ * a swallowed `registry.register` error is why no agent was ever in the
16
+ * directory, and a swallowed roster read is why DMs went missing.
17
+ * 3. A call counter, so the pull loop can report how much it spent and a test
18
+ * can assert that a private room was NEVER probed.
19
+ *
20
+ * @module lib/org/inbound/io
21
+ */
22
+
23
+ "use strict";
24
+
25
+ import { isEnabled, configFromAgent, call as clientCall, read as clientRead } from "../client.mjs";
26
+
27
+ /** A no-op logger with the daemon's `(level, message, meta?)` shape. */
28
+ export function noopLog() {}
29
+
30
+ /**
31
+ * Resolve `{base, token, orgId}` from an agent cfg, or null when the org
32
+ * integration is disabled/unconfigured. Mirrors `lib/org/messaging.resolveOrg`.
33
+ * @param {object} cfg
34
+ * @returns {{base:string, token:string, orgId:string}|null}
35
+ */
36
+ export function resolveOrg(cfg) {
37
+ if (!isEnabled(cfg)) return null;
38
+ const c = configFromAgent(cfg);
39
+ if (!c.base) return null;
40
+ return { base: c.base, token: c.token, orgId: c.orgId };
41
+ }
42
+
43
+ /**
44
+ * @typedef {Object} InboundIo
45
+ * @property {(method:string, params?:object)=>Promise<object>} call RPC → res frame
46
+ * @property {(path:string)=>Promise<object>} read GET → {ok,payload}
47
+ * @property {(level:string, msg:string, meta?:object)=>void} log
48
+ * @property {{calls:number, reads:number, failures:number, methods:string[]}} stats
49
+ */
50
+
51
+ /**
52
+ * Build the real IO seam from an agent cfg. Returns null when the org
53
+ * integration is off — the caller then does nothing at all (fail-open).
54
+ *
55
+ * @param {{cfg:object, fetchImpl?:Function, log?:Function, timeoutMs?:number}} o
56
+ * @returns {InboundIo|null}
57
+ */
58
+ export function makeIo(o = {}) {
59
+ const org = resolveOrg(o.cfg);
60
+ if (!org) return null;
61
+ const log = typeof o.log === "function" ? o.log : noopLog;
62
+ const conn = {
63
+ base: org.base,
64
+ token: org.token,
65
+ orgId: org.orgId,
66
+ fetchImpl: o.fetchImpl,
67
+ ...(Number.isFinite(o.timeoutMs) ? { timeoutMs: o.timeoutMs } : {}),
68
+ };
69
+ const stats = { calls: 0, reads: 0, failures: 0, methods: [] };
70
+
71
+ return {
72
+ stats,
73
+ log,
74
+ async call(method, params = {}) {
75
+ stats.calls += 1;
76
+ stats.methods.push(method);
77
+ try {
78
+ const frame = await clientCall(method, params, conn);
79
+ if (!frame || frame.ok !== true) {
80
+ stats.failures += 1;
81
+ // NEVER SILENT. A directedness probe that fails changes the verdict,
82
+ // so the operator has to be able to see it in the daemon log.
83
+ log("warn", `[inbound] ${method} failed`, {
84
+ code: frame && frame.error ? frame.error.code : "NO_FRAME",
85
+ message: frame && frame.error ? frame.error.message : "no frame",
86
+ });
87
+ }
88
+ return frame;
89
+ } catch (err) {
90
+ stats.failures += 1;
91
+ log("warn", `[inbound] ${method} threw`, { error: err && err.message ? err.message : String(err) });
92
+ return { ok: false, error: { code: "INTERNAL", message: String((err && err.message) || err) } };
93
+ }
94
+ },
95
+ async read(path) {
96
+ stats.reads += 1;
97
+ stats.methods.push(`read:${String(path).split("?")[0]}`);
98
+ try {
99
+ const r = await clientRead(path, conn);
100
+ if (!r || r.ok !== true) {
101
+ stats.failures += 1;
102
+ log("warn", `[inbound] read ${path} failed`, {
103
+ code: r && r.error ? r.error.code : "NO_RESULT",
104
+ status: r ? r.status : 0,
105
+ });
106
+ }
107
+ return r;
108
+ } catch (err) {
109
+ stats.failures += 1;
110
+ log("warn", `[inbound] read ${path} threw`, { error: err && err.message ? err.message : String(err) });
111
+ return { ok: false, error: { code: "INTERNAL", message: String((err && err.message) || err) }, status: 0 };
112
+ }
113
+ },
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Unwrap an RPC res frame into its result, or null. Accepts the two shapes hq
119
+ * returns (`{ok:true, result}` and a bare object) without guessing.
120
+ * @param {object} frame
121
+ * @returns {object|null}
122
+ */
123
+ export function resultOf(frame) {
124
+ if (!frame || frame.ok !== true) return null;
125
+ return frame.result !== undefined ? frame.result : null;
126
+ }
127
+
128
+ /**
129
+ * Pull a named array out of an RPC result, tolerating the bare-array form.
130
+ * @param {object|null} result
131
+ * @param {string} key e.g. "messages" | "channels" | "comments"
132
+ * @returns {object[]}
133
+ */
134
+ export function arrayOf(result, key) {
135
+ if (Array.isArray(result)) return result;
136
+ if (result && Array.isArray(result[key])) return result[key];
137
+ if (result && Array.isArray(result.items)) return result.items;
138
+ return [];
139
+ }
140
+
141
+ export default { makeIo, resolveOrg, resultOf, arrayOf, noopLog };
@@ -0,0 +1,201 @@
1
+ /**
2
+ * lib/org/inbound/project.mjs — surface → the existing MessageEvent contract.
3
+ *
4
+ * The whole point of this module is that the DAEMON NEEDS NO CHANGE. The legacy
5
+ * ingestion path (`scripts/daemon/agent-daemon.mjs poll()` → classifier →
6
+ * directed gate → thread lease → responder → dispatcher) consumes a flat inbox
7
+ * item produced by `lib/channels/inbox-item.eventToInboxItem`. Everything a new
8
+ * surface carries therefore has to ride on fields that converter already
9
+ * forwards:
10
+ *
11
+ * kind → `item.kind` — the ROUTING HINT. One value per surface
12
+ * (see surfaces.mjs). `classifyByKind` returns null for all
13
+ * of them, so they fall through to the normal LLM classifier
14
+ * — correct, because a task assignment or an approval does
15
+ * need triage, unlike a reaction.
16
+ * subject → `item.subject` — "Cohort @mention: <channel>" etc.
17
+ * content → `item.content` — the hydrated body.
18
+ * channel/_id → the surface's own scope key (channel id, task id, …).
19
+ * thread_id → the reply anchor the responder replies INTO.
20
+ * thread_context → prior thread, already rendered.
21
+ * raw_ref → `cohort:<surface>:<entityId>:<seq>` — stable, greppable.
22
+ * priority_signals→ mentions_agent / tagged_urgent are real, not guessed.
23
+ * is_private → a CONFIDENTIALITY assertion the daemon's outcome gate
24
+ * reads. Set only when we actually know the room class.
25
+ *
26
+ * The DM and call projections are deliberately byte-compatible with what
27
+ * `lib/org/messaging.toMessageEvent` produces today (kind "message" / "call",
28
+ * `id: cohort-<messageId>`, `raw_ref: cohort:<channel>:<message>`), so turning
29
+ * the wide tail on cannot re-deliver or re-shape the inbound that already works.
30
+ *
31
+ * PURE — no IO, no clock beyond an injectable `now`.
32
+ *
33
+ * @module lib/org/inbound/project
34
+ */
35
+
36
+ "use strict";
37
+
38
+ import { SURFACES } from "./surfaces.mjs";
39
+
40
+ /** Blank-safe string. */
41
+ function s(v) {
42
+ return v == null ? "" : String(v);
43
+ }
44
+
45
+ /** Urgency words that justify raising the item's priority signal. */
46
+ const URGENT = /\b(urgent|asap|immediately|critical|p0|blocker|blocked|sev-?[01]|escalat\w*)\b/i;
47
+ const DEADLINE = /\b(by (mon|tue|wed|thu|fri|sat|sun|tomorrow|today|eod|eow)|due\b|deadline|before \d)/i;
48
+
49
+ /**
50
+ * Project a directed, hydrated candidate onto a MessageEvent.
51
+ *
52
+ * @param {Object} o
53
+ * @param {import("./directedness.mjs").Candidate} o.candidate
54
+ * @param {{surface:string, reason:string}} o.verdict
55
+ * @param {import("./hydrate.mjs").Hydrated} o.hydrated
56
+ * @param {string} o.me
57
+ * @param {import("./facts.mjs").Facts} [o.facts]
58
+ * @param {string} [o.now] ISO timestamp fallback
59
+ * @returns {object|null} a MessageEvent, or null when it must not be delivered
60
+ */
61
+ export function toMessageEvent(o = {}) {
62
+ const { candidate: c, verdict, hydrated, me } = o;
63
+ if (!c || !verdict || !verdict.surface || !hydrated || hydrated.ok !== true) return null;
64
+ const def = SURFACES[verdict.surface];
65
+ if (!def) return null;
66
+
67
+ const facts = o.facts || {};
68
+ const seq = c.seq != null ? String(c.seq) : "";
69
+ const entityId = s(c.entityId);
70
+ const text = s(hydrated.text);
71
+ if (!text.trim() && verdict.surface !== "call") return null;
72
+
73
+ // ── ids. The inbox item's `id` is the DEDUP KEY: `writeInboxItem` refuses a
74
+ // second write of the same id, and the cursor is only advanced AFTER the
75
+ // write, so a crash re-pulls the same rows and the id must be STABLE across
76
+ // that replay. `seq` is stable (it is the ledger position), so it is the
77
+ // right discriminator for families whose entityId repeats across events
78
+ // (a task gets many comments).
79
+ const messageId = messageIdFor(c, verdict);
80
+ const inboxId = inboxIdFor(c, verdict, messageId, seq);
81
+
82
+ const channelId = s(hydrated.channelId || c.ids.channelId || "");
83
+ const channelLabel = s(hydrated.channelLabel || channelId || "cohort");
84
+ const chan = facts.channels instanceof Map ? facts.channels.get(channelId) : null;
85
+ const channelKind = s((c.ids.channelKind || (chan && chan.kind) || "")).toUpperCase();
86
+
87
+ const subjectDetail = s(hydrated.subjectDetail);
88
+ const subject = subjectDetail ? `${def.subject}: ${subjectDetail}` : def.subject;
89
+
90
+ const from = hydrated.from || { id: s(c.actor), name: s(c.actor) };
91
+ const scope = scopeFor(verdict.surface, channelKind, def.scope);
92
+
93
+ const ev = {
94
+ channel: "cohort",
95
+ service: "cohort",
96
+ message_id: messageId,
97
+ id: inboxId,
98
+ thread_id: s(hydrated.threadId),
99
+ from: { id: s(from.id), name: s(from.name || from.id) },
100
+ text,
101
+ timestamp: c.at || o.now || new Date().toISOString(),
102
+ kind: def.kind,
103
+ subject,
104
+ // ONLY ever a real Cohort channel id, never an entity id standing in for
105
+ // one. `scripts/daemon/responder.mjs sendCohortReply` routes its reply with
106
+ // `messaging.send({channel: item.channel_id})`, so putting a task/decision
107
+ // id here would post-to-a-non-channel and 400. A surface with no room leaves
108
+ // this blank and is routed by `kind` + `raw_ref` instead (see the wiring
109
+ // note in this directory's module docs).
110
+ channel_id: channelId,
111
+ channel_label: channelLabel,
112
+ sender: s(from.name || from.id),
113
+ is_reply: Boolean(hydrated.isReply),
114
+ thread_context: hydrated.threadContext != null ? hydrated.threadContext : null,
115
+ priority_signals: {
116
+ from_ceo: false,
117
+ tagged_urgent: URGENT.test(text) || c.kind === "board.blocked" || c.family === "escalation",
118
+ contains_deadline: DEADLINE.test(text),
119
+ // TRUE only when this really is a direct address, so downstream triage can
120
+ // trust it. `channel`/`assignee` delivery is directed at the SEAT, not a
121
+ // literal @mention, and says so.
122
+ mentions_agent:
123
+ verdict.reason === "mention" ||
124
+ verdict.reason === "named" ||
125
+ verdict.reason === "dm" ||
126
+ verdict.reason === "direct",
127
+ },
128
+ raw_ref: `cohort:${verdict.surface}:${entityId || messageId}:${seq}`,
129
+ ingest_source: "cohort",
130
+ source: {
131
+ channel: "cohort",
132
+ chatId: channelId,
133
+ ...(hydrated.threadId ? { threadRef: s(hydrated.threadId) } : {}),
134
+ userScope: scope,
135
+ },
136
+ // ── Cohort-specific provenance. `eventToInboxItem` drops unknown keys, so
137
+ // these never reach the YAML — they are for in-process consumers (the
138
+ // cadence handler's log line, tests, and any future router) that see the
139
+ // MessageEvent before it is flattened.
140
+ cohort: {
141
+ surface: verdict.surface,
142
+ topic: def.topic,
143
+ reason: verdict.reason,
144
+ family: c.family,
145
+ event_kind: c.kind,
146
+ entity_id: entityId || null,
147
+ seq: c.seq ?? null,
148
+ me: s(me),
149
+ },
150
+ };
151
+
152
+ // CONFIDENTIALITY. The daemon's outcome gate reads `is_private === false` as a
153
+ // positive "this source is public" assertion, so we stamp it ONLY when the
154
+ // room class is actually known — a default here would forge one.
155
+ if (channelKind) {
156
+ ev.channel_type = channelKind.toLowerCase();
157
+ ev.is_private = channelKind !== "PUBLIC";
158
+ ev.is_dm = channelKind === "DM";
159
+ } else if (!channelId) {
160
+ // A non-channel surface (task / decision / approval / doc / email) is org-
161
+ // internal and never public.
162
+ ev.is_private = true;
163
+ }
164
+
165
+ return ev;
166
+ }
167
+
168
+ /**
169
+ * The per-surface `message_id`. For messaging this stays the hq message id so
170
+ * the projection is byte-identical to today's DM path; for everything else the
171
+ * ledger seq is the only value that is both unique per event and stable across
172
+ * an at-least-once replay.
173
+ */
174
+ export function messageIdFor(c, verdict) {
175
+ if (c.family === "messaging") return s(c.ids.messageId || c.entityId || c.seq);
176
+ if (c.family === "calling") return s(c.ids.callId || c.entityId || c.seq);
177
+ return s(c.seq != null ? c.seq : c.entityId);
178
+ }
179
+
180
+ /**
181
+ * The inbox item id — the DEDUP key. `cohort-<messageId>` for the two legacy
182
+ * surfaces (byte-compat with `lib/org/messaging.toMessageEvent`), and
183
+ * `cohort-<surface>-<seq>` for the new ones so two comments on the same task
184
+ * never collide.
185
+ */
186
+ export function inboxIdFor(c, verdict, messageId, seq) {
187
+ if (c.family === "messaging" || c.family === "calling") {
188
+ return messageId ? `cohort-${messageId}` : undefined;
189
+ }
190
+ return `cohort-${verdict.surface}-${seq || messageId}`;
191
+ }
192
+
193
+ /** The coarse audience scope the daemon uses for pairing vs membership rules. */
194
+ export function scopeFor(surface, channelKind, fallback) {
195
+ if (channelKind === "DM") return "dm";
196
+ if (channelKind === "GROUP_DM" || channelKind === "HUDDLE") return "group";
197
+ if (channelKind) return "channel";
198
+ return fallback || "channel";
199
+ }
200
+
201
+ export default { toMessageEvent, messageIdFor, inboxIdFor, scopeFor };