@cohortapp/agent-sdk 2.3.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +84 -16
  97. package/lib/setup/enroll-from-cohort.test.mjs +43 -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,501 @@
1
+ /**
2
+ * lib/org/inbound/facts.mjs — the agent's OWN half of "is this for me?".
3
+ *
4
+ * The org event feed is org-wide and redacted (see `directedness.mjs` for the
5
+ * long version), so directedness is a JOIN: the event names ids, the agent knows
6
+ * which of those ids are its own. This module resolves that second half — and
7
+ * ONLY over the agent's own entitled reads, so the aperture can never be wider
8
+ * than what hq would already hand this API key:
9
+ *
10
+ * | fact | read | hq ACL |
11
+ * |----------------------------|-------------------------|---------------------------------|
12
+ * | visible / member channels | messaging.channels | PUBLIC floor + own memberships |
13
+ * | message bodies + mentions | messaging.history | assertChannelAccess |
14
+ * | my tasks (assignee/review) | GET board.context | org-scoped board read |
15
+ * | decisions + their voices | GET decision.list, | org-scoped |
16
+ * | | decision.listComments | |
17
+ * | open escalations | escalation.list | org-scoped |
18
+ * | approvals (approver seat) | approval.get | org-scoped, NOT_FOUND otherwise |
19
+ * | doc ownership / shares | files.get | NOT_FOUND when not visible |
20
+ *
21
+ * ── THE ONE RULE THIS FILE ENFORCES ──
22
+ * A channel that is not in the visible roster is NEVER paged. `messaging.channels`
23
+ * returns a private room only to its members (hq `methods/messaging/channels.ts`),
24
+ * so refusing to page anything outside that set means a private room the agent is
25
+ * not in is never even TOUCHED — no history request, no body, no verdict. The
26
+ * negative test in `facts.test.mjs` asserts exactly that, by call log.
27
+ *
28
+ * ── COST ──
29
+ * Everything is BATCHED and CAPPED. One roster read per pull; one board/decision/
30
+ * escalation read per pull *only when that family appears in the batch*; one
31
+ * history page per affected channel (not per message); a bounded number of
32
+ * targeted thread pages. Every cap is in {@link DEFAULT_LIMITS} and every time a
33
+ * cap bites it is logged, never silently dropped.
34
+ *
35
+ * Fail-open everywhere, NEVER silent: a fact that cannot be read degrades the
36
+ * verdict to "unknown" (→ not directed for private surfaces, which is the safe
37
+ * direction) and logs the failure through `io.log`.
38
+ *
39
+ * @module lib/org/inbound/facts
40
+ */
41
+
42
+ "use strict";
43
+
44
+ import { arrayOf, resultOf } from "./io.mjs";
45
+ import { needsSpaceProbe, matchesMyName, PRIVATE_ROOM_KINDS } from "./directedness.mjs";
46
+
47
+ /** Bounds on how much one pull may spend resolving facts. */
48
+ export const DEFAULT_LIMITS = Object.freeze({
49
+ /** `messaging.history` pages per pull (one per affected channel). */
50
+ maxChannelPages: 25,
51
+ /** messages per history page. */
52
+ historyLimit: 50,
53
+ /** targeted `messaging.history {threadRootId}` fetches per pull. */
54
+ maxThreadPages: 10,
55
+ /** `approval.get` probes per pull. */
56
+ maxApprovalProbes: 20,
57
+ /** `files.get` (+ optional `files.comments`) probes per pull. */
58
+ maxFileProbes: 20,
59
+ /** `decision.listComments` probes per pull. */
60
+ maxDecisionProbes: 20,
61
+ });
62
+
63
+ /**
64
+ * @typedef {Object} Facts
65
+ * @property {string} me
66
+ * @property {string[]} myNames display names for prose-mention matching
67
+ * @property {Map<string,object>} channels visible channel roster, by id
68
+ * @property {Set<string>|null} visibleChannelIds null == roster unreadable
69
+ * @property {Set<string>|null} memberChannelIds non-PUBLIC visible == proven member
70
+ * @property {Map<string,object>} channelPages channelId → {messages, byId}
71
+ * @property {Set<string>} myThreadRootIds
72
+ * @property {Map<string,string>|null} taskRole taskId → "assignee"|"reviewer"
73
+ * @property {Map<string,object>|null} tasks taskId → task card
74
+ * @property {Map<string,object>|null} decisions
75
+ * @property {Map<string,Set<string>>} decisionVoices
76
+ * @property {Map<string,object>|null} escalations
77
+ * @property {Map<string,object>|null} approvals
78
+ * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe}
79
+ * @property {string[]} degraded names of facts that could not be read
80
+ */
81
+
82
+ /** Which families are present in this batch of candidates? */
83
+ function familiesIn(candidates) {
84
+ const s = new Set();
85
+ for (const c of candidates) if (c && c.family) s.add(c.family);
86
+ return s;
87
+ }
88
+
89
+ /**
90
+ * Resolve every fact the batch needs. One pass, batched by family, capped, and
91
+ * fail-open with a log line per degradation.
92
+ *
93
+ * @param {Object} o
94
+ * @param {import("./directedness.mjs").Candidate[]} o.candidates
95
+ * @param {string} o.me the agent's member id
96
+ * @param {import("./io.mjs").InboundIo} o.io
97
+ * @param {string[]} [o.myNames] display names for prose-mention matching
98
+ * @param {Record<string,boolean>} [o.enabled] the surface enable map
99
+ * @param {object} [o.limits] overrides for {@link DEFAULT_LIMITS}
100
+ * @returns {Promise<Facts>}
101
+ */
102
+ export async function resolveFacts(o = {}) {
103
+ const candidates = Array.isArray(o.candidates) ? o.candidates.filter(Boolean) : [];
104
+ const me = o.me ? String(o.me) : "";
105
+ const io = o.io;
106
+ const log = (io && io.log) || (() => {});
107
+ const limits = { ...DEFAULT_LIMITS, ...(o.limits || {}) };
108
+ const enabled = o.enabled || null;
109
+ const on = (s) => (enabled ? !!enabled[s] : true);
110
+ const families = familiesIn(candidates);
111
+ const degraded = [];
112
+
113
+ /** @type {Facts} */
114
+ const facts = {
115
+ me,
116
+ myNames: Array.isArray(o.myNames) ? o.myNames.filter((n) => typeof n === "string" && n.trim()) : [],
117
+ channels: new Map(),
118
+ visibleChannelIds: null,
119
+ memberChannelIds: null,
120
+ channelPages: new Map(),
121
+ myThreadRootIds: new Set(),
122
+ taskRole: null,
123
+ tasks: null,
124
+ decisions: null,
125
+ decisionVoices: new Map(),
126
+ escalations: null,
127
+ approvals: null,
128
+ fileAcl: null,
129
+ degraded,
130
+ };
131
+ if (!io || candidates.length === 0) return facts;
132
+
133
+ // ── 1. The channel roster. Needed by messaging, calling, file and escalation.
134
+ const needsRoster =
135
+ families.has("messaging") || families.has("calling") || families.has("file") || families.has("escalation");
136
+ if (needsRoster) {
137
+ const frame = await io.call("messaging.channels", {});
138
+ const rows = arrayOf(resultOf(frame), "channels");
139
+ if (frame && frame.ok === true) {
140
+ const visible = new Set();
141
+ const members = new Set();
142
+ for (const c of rows) {
143
+ const id = String((c && (c.id || c.channelId)) || "");
144
+ if (!id) continue;
145
+ const kind = String((c && c.kind) || "").toUpperCase();
146
+ facts.channels.set(id, { id, kind, name: c.name || c.slug || "", slug: c.slug || "" });
147
+ visible.add(id);
148
+ // `messaging.channels` returns a NON-PUBLIC channel only to its members,
149
+ // so appearing here with a non-PUBLIC kind IS the membership proof. For
150
+ // a PUBLIC channel the roster proves visibility, never membership — and
151
+ // we never upgrade one to the other.
152
+ if (kind && kind !== "PUBLIC") members.add(id);
153
+ }
154
+ facts.visibleChannelIds = visible;
155
+ facts.memberChannelIds = members;
156
+ } else {
157
+ degraded.push("channels");
158
+ log("warn", "[inbound] channel roster unreadable — private surfaces stay closed this tick");
159
+ }
160
+ }
161
+
162
+ // ── 2. Message pages: one per affected, VISIBLE channel. Public-space traffic
163
+ // that the redacted counts already prove uninteresting is skipped.
164
+ if (families.has("messaging")) {
165
+ await pageChannels({ candidates, facts, io, limits, log, on });
166
+ }
167
+
168
+ // ── 3. Board: my tasks (assignee OR reviewer) from the board context read.
169
+ if (families.has("board") || families.has("escalation") || families.has("file")) {
170
+ await resolveBoardFacts({ facts, io, log, degraded });
171
+ }
172
+
173
+ // ── 4. Decisions I proposed / decided, plus the voices in each thread.
174
+ if (families.has("decision") && (on("decision") || !enabled)) {
175
+ await resolveDecisionFacts({ candidates, facts, io, limits, log, degraded });
176
+ }
177
+
178
+ // ── 5. Escalations (one list read; the payload's taskId/channelId do the rest).
179
+ if (families.has("escalation") && (on("escalation") || !enabled)) {
180
+ const frame = await io.call("escalation.list", {});
181
+ if (frame && frame.ok === true) {
182
+ const map = new Map();
183
+ for (const e of arrayOf(resultOf(frame), "escalations")) {
184
+ if (e && e.id) map.set(String(e.id), e);
185
+ }
186
+ facts.escalations = map;
187
+ } else {
188
+ degraded.push("escalations");
189
+ }
190
+ }
191
+
192
+ // ── 6. Approvals: the approver seat is on the ROW, never in the payload
193
+ // (hq `methods/approval/request.ts` emits only `requester`), so an
194
+ // approver only learns a request landed by reading it back. `approval.get`
195
+ // is org-scoped and NOT_FOUND for another tenant, so this cannot widen.
196
+ if (families.has("approval") && (on("approval") || !enabled)) {
197
+ await resolveApprovalFacts({ candidates, facts, io, limits, log, degraded });
198
+ }
199
+
200
+ // ── 7. Workspace docs: ownership + effective permission via `files.get`,
201
+ // which is itself ACL'd (a doc I cannot see is NOT_FOUND, not a leak).
202
+ if (families.has("files") && (on("doc_comment") || !enabled)) {
203
+ await resolveFileFacts({ candidates, facts, io, limits, log, degraded });
204
+ }
205
+
206
+ return facts;
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Step implementations
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Fetch one `messaging.history` page per affected channel and fold it into
215
+ * `facts.channelPages` + `facts.myThreadRootIds`.
216
+ *
217
+ * THE GATE: a channel id absent from `facts.visibleChannelIds` is skipped
218
+ * outright. When the roster is unreadable (`visibleChannelIds === null`) NOTHING
219
+ * is paged — we would not be able to tell a private room from a public one, and
220
+ * guessing is how an agent ends up reading someone else's DM.
221
+ */
222
+ async function pageChannels({ candidates, facts, io, limits, log, on }) {
223
+ const visible = facts.visibleChannelIds;
224
+ const members = facts.memberChannelIds;
225
+ if (!visible) {
226
+ log("warn", "[inbound] skipping all history pages — channel roster unknown");
227
+ return;
228
+ }
229
+
230
+ /** channelId → candidates needing it */
231
+ const wanted = new Map();
232
+ let skippedInvisible = 0;
233
+ let skippedByCounts = 0;
234
+
235
+ for (const cand of candidates) {
236
+ if (cand.family !== "messaging") continue;
237
+ const channelId = String(cand.ids.channelId || "");
238
+ if (!channelId) continue;
239
+ if (!visible.has(channelId)) {
240
+ skippedInvisible += 1;
241
+ continue; // ← the hard gate. Never probe a room outside my roster.
242
+ }
243
+ const chan = facts.channels.get(channelId);
244
+ const kind = String(cand.ids.channelKind || (chan && chan.kind) || "").toUpperCase();
245
+ const isPrivateRoom = PRIVATE_ROOM_KINDS.includes(kind);
246
+
247
+ if (isPrivateRoom) {
248
+ // A private room I am in: paged for the BODY (the verdict is already
249
+ // decided by membership). If I am somehow not a member, do not page.
250
+ if (!members || !members.has(channelId)) continue;
251
+ if (!on("dm")) continue;
252
+ } else {
253
+ // A space: paged only if it could be a mention or a thread reply.
254
+ if (!on("mention") && !on("thread_reply")) continue;
255
+ if (needsSpaceProbe(cand, facts) === false) {
256
+ skippedByCounts += 1;
257
+ continue;
258
+ }
259
+ // A PRIVATE space I am not a member of is not readable — do not try.
260
+ if (kind === "PRIVATE" && (!members || !members.has(channelId))) continue;
261
+ }
262
+ if (!wanted.has(channelId)) wanted.set(channelId, []);
263
+ wanted.get(channelId).push(cand);
264
+ }
265
+
266
+ if (skippedInvisible) {
267
+ log("info", `[inbound] ${skippedInvisible} messaging event(s) in channels outside my roster — not probed`);
268
+ }
269
+ if (skippedByCounts) {
270
+ log("info", `[inbound] ${skippedByCounts} space message(s) dropped on mentionCount=0/threaded=false (no RPC spent)`);
271
+ }
272
+
273
+ const channelIds = Array.from(wanted.keys());
274
+ if (channelIds.length > limits.maxChannelPages) {
275
+ log("warn", `[inbound] ${channelIds.length} channels need a history page; capping at ${limits.maxChannelPages}`);
276
+ }
277
+
278
+ for (const channelId of channelIds.slice(0, limits.maxChannelPages)) {
279
+ const frame = await io.call("messaging.history", { channelId, limit: limits.historyLimit });
280
+ if (!frame || frame.ok !== true) continue; // io.call already logged it
281
+ const messages = arrayOf(resultOf(frame), "messages");
282
+ foldPage(facts, channelId, messages);
283
+ }
284
+
285
+ // Targeted thread pages: a reply whose root fell outside the 50-message page.
286
+ const missing = [];
287
+ for (const [channelId, cands] of wanted) {
288
+ const page = facts.channelPages.get(channelId);
289
+ if (!page) continue;
290
+ for (const cand of cands) {
291
+ const msg = page.byId.get(String(cand.ids.messageId || ""));
292
+ const root = msg && (msg.threadRootId || msg.thread_root_id);
293
+ if (!root) continue;
294
+ if (facts.myThreadRootIds.has(String(root))) continue;
295
+ const key = `${channelId}:${root}`;
296
+ if (!missing.some((m) => m.key === key)) missing.push({ key, channelId, root: String(root) });
297
+ }
298
+ }
299
+ if (missing.length > limits.maxThreadPages) {
300
+ log("warn", `[inbound] ${missing.length} thread pages wanted; capping at ${limits.maxThreadPages}`);
301
+ }
302
+ for (const { channelId, root } of missing.slice(0, limits.maxThreadPages)) {
303
+ const frame = await io.call("messaging.history", {
304
+ channelId,
305
+ threadRootId: root,
306
+ limit: limits.historyLimit,
307
+ });
308
+ if (!frame || frame.ok !== true) continue;
309
+ foldPage(facts, channelId, arrayOf(resultOf(frame), "messages"));
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Fold a history page into the fact set: index the messages by id and record
315
+ * every thread root I have a stake in (authored under, or was named in).
316
+ */
317
+ function foldPage(facts, channelId, messages) {
318
+ const existing = facts.channelPages.get(String(channelId));
319
+ const byId = existing ? existing.byId : new Map();
320
+ const all = existing ? existing.messages.slice() : [];
321
+ for (const m of messages) {
322
+ const id = String((m && (m.id || m.messageId)) || "");
323
+ if (!id) continue;
324
+ if (!byId.has(id)) all.push(m);
325
+ byId.set(id, m);
326
+ const root = String(m.threadRootId || m.thread_root_id || "") || id;
327
+ const mine =
328
+ (facts.me && String(m.authorId || m.author || "") === facts.me) ||
329
+ (facts.me && Array.isArray(m.mentions) && m.mentions.map(String).includes(facts.me));
330
+ if (mine) facts.myThreadRootIds.add(root);
331
+ }
332
+ facts.channelPages.set(String(channelId), { byId, messages: all });
333
+ }
334
+
335
+ /** `GET board.context` → taskRole (assignee|reviewer) + the task cards. */
336
+ async function resolveBoardFacts({ facts, io, log, degraded }) {
337
+ if (facts.taskRole) return;
338
+ const r = await io.read("board.context");
339
+ if (!r || r.ok !== true || !r.payload) {
340
+ degraded.push("board");
341
+ log("warn", "[inbound] board.context unreadable — task surfaces stay closed this tick");
342
+ return;
343
+ }
344
+ const role = new Map();
345
+ const tasks = new Map();
346
+ for (const task of collectTasks(r.payload)) {
347
+ const id = String((task && task.id) || "");
348
+ if (!id) continue;
349
+ tasks.set(id, task);
350
+ const assignee = String(task.assigneeId || task.assignee || "");
351
+ const reviewer = String(task.reviewerId || task.reviewer || "");
352
+ if (facts.me && assignee === facts.me) role.set(id, "assignee");
353
+ else if (facts.me && reviewer === facts.me) role.set(id, "reviewer");
354
+ }
355
+ facts.taskRole = role;
356
+ facts.tasks = tasks;
357
+ }
358
+
359
+ /**
360
+ * `board.context` is a swimlane projection (`{lanes:[{columns:{col:[card]}}]}`),
361
+ * and older/alternate shapes exist. Walk defensively and collect anything that
362
+ * looks like a task card — a shape change upstream must degrade to "fewer
363
+ * tasks", never to a throw.
364
+ */
365
+ export function collectTasks(payload) {
366
+ const out = [];
367
+ const seen = new Set();
368
+ const visit = (node, depth) => {
369
+ if (!node || depth > 6) return;
370
+ if (Array.isArray(node)) {
371
+ for (const n of node) visit(n, depth + 1);
372
+ return;
373
+ }
374
+ if (typeof node !== "object") return;
375
+ if (typeof node.id === "string" && ("assigneeId" in node || "reviewerId" in node || "col" in node)) {
376
+ if (!seen.has(node.id)) {
377
+ seen.add(node.id);
378
+ out.push(node);
379
+ }
380
+ return;
381
+ }
382
+ for (const v of Object.values(node)) visit(v, depth + 1);
383
+ };
384
+ visit(payload, 0);
385
+ return out;
386
+ }
387
+
388
+ /** `GET decision.list` + one `decision.listComments` per decision in the batch. */
389
+ async function resolveDecisionFacts({ candidates, facts, io, limits, log, degraded }) {
390
+ const r = await io.read("decision.list");
391
+ if (r && r.ok === true && r.payload) {
392
+ const map = new Map();
393
+ for (const d of arrayOf(r.payload, "decisions")) {
394
+ if (d && d.id) map.set(String(d.id), d);
395
+ }
396
+ facts.decisions = map;
397
+ } else {
398
+ degraded.push("decisions");
399
+ }
400
+
401
+ const ids = [];
402
+ for (const c of candidates) {
403
+ if (c.family !== "decision") continue;
404
+ const id = String(c.ids.decisionId || "");
405
+ if (id && !ids.includes(id)) ids.push(id);
406
+ }
407
+ if (ids.length > limits.maxDecisionProbes) {
408
+ log("warn", `[inbound] ${ids.length} decision threads wanted; capping at ${limits.maxDecisionProbes}`);
409
+ }
410
+ for (const decisionId of ids.slice(0, limits.maxDecisionProbes)) {
411
+ // Skip the probe when the list read already proved it mine.
412
+ const d = facts.decisions && facts.decisions.get(decisionId);
413
+ if (d && facts.me && (String(d.proposedBy || "") === facts.me || String(d.decidedBy || "") === facts.me)) {
414
+ continue;
415
+ }
416
+ const frame = await io.call("decision.listComments", { decisionId });
417
+ if (!frame || frame.ok !== true) continue;
418
+ const voices = new Set();
419
+ for (const c of arrayOf(resultOf(frame), "comments")) {
420
+ const author = String((c && (c.authorId || c.author)) || "");
421
+ if (author) voices.add(author);
422
+ }
423
+ facts.decisionVoices.set(decisionId, voices);
424
+ }
425
+ }
426
+
427
+ /** One `approval.get` per distinct approval id in the batch. */
428
+ async function resolveApprovalFacts({ candidates, facts, io, limits, log, degraded }) {
429
+ const ids = [];
430
+ for (const c of candidates) {
431
+ if (c.family !== "approval") continue;
432
+ const id = String(c.ids.approvalId || "");
433
+ if (id && !ids.includes(id)) ids.push(id);
434
+ }
435
+ if (ids.length === 0) return;
436
+ if (ids.length > limits.maxApprovalProbes) {
437
+ log("warn", `[inbound] ${ids.length} approvals wanted; capping at ${limits.maxApprovalProbes}`);
438
+ }
439
+ const map = new Map();
440
+ let anyOk = false;
441
+ for (const id of ids.slice(0, limits.maxApprovalProbes)) {
442
+ const frame = await io.call("approval.get", { id });
443
+ if (!frame || frame.ok !== true) continue;
444
+ anyOk = true;
445
+ const a = resultOf(frame);
446
+ if (a && (a.id || id)) map.set(String(a.id || id), a);
447
+ }
448
+ if (!anyOk) degraded.push("approvals");
449
+ facts.approvals = map;
450
+ }
451
+
452
+ /**
453
+ * One `files.get` per distinct doc in the batch → `{ownerId, shared}`.
454
+ *
455
+ * `files.get` is the ACL: a doc this key cannot see is NOT_FOUND, so an absent
456
+ * entry means "not mine" and the resolver refuses it. When the doc IS visible
457
+ * but neither owned nor shared, and the caller supplied display names, we spend
458
+ * ONE `files.comments` read to see whether the thread names me by name.
459
+ */
460
+ async function resolveFileFacts({ candidates, facts, io, limits, log, degraded }) {
461
+ const ids = [];
462
+ for (const c of candidates) {
463
+ if (c.family !== "files") continue;
464
+ const id = String(c.ids.fileId || "");
465
+ if (id && !ids.includes(id)) ids.push(id);
466
+ }
467
+ if (ids.length === 0) return;
468
+ if (ids.length > limits.maxFileProbes) {
469
+ log("warn", `[inbound] ${ids.length} docs wanted; capping at ${limits.maxFileProbes}`);
470
+ }
471
+ const map = new Map();
472
+ let anyOk = false;
473
+ for (const fileId of ids.slice(0, limits.maxFileProbes)) {
474
+ const frame = await io.call("files.get", { fileId });
475
+ if (!frame || frame.ok !== true) continue;
476
+ anyOk = true;
477
+ const res = resultOf(frame) || {};
478
+ const file = res.file || res;
479
+ const permission = String(res.permission || "").toLowerCase();
480
+ const ownerId = String(file.ownerId || file.owner || "") || null;
481
+ const shared = Boolean(permission && permission !== "none");
482
+ const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "" };
483
+
484
+ if (!shared && !(ownerId && ownerId === facts.me) && facts.myNames.length > 0) {
485
+ const cf = await io.call("files.comments", { fileId, limit: 20 });
486
+ if (cf && cf.ok === true) {
487
+ for (const c of arrayOf(resultOf(cf), "comments")) {
488
+ if (matchesMyName(facts, c && c.body)) {
489
+ entry.mentionsMe = true;
490
+ break;
491
+ }
492
+ }
493
+ }
494
+ }
495
+ map.set(fileId, entry);
496
+ }
497
+ if (!anyOk) degraded.push("files");
498
+ facts.fileAcl = map;
499
+ }
500
+
501
+ export default { resolveFacts, collectTasks, DEFAULT_LIMITS };