@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,375 @@
1
+ /**
2
+ * facts.test.mjs — the agent-side half of the directedness join.
3
+ *
4
+ * Everything here runs against an injected fake IO that RECORDS every call, so
5
+ * the tests can assert not just what was resolved but what was *spent* — and,
6
+ * critically, what was NEVER asked for. The headline assertion is the negative
7
+ * one: a private room outside the agent's roster must never produce a
8
+ * `messaging.history` call. A leak here would be invisible in a "did it deliver"
9
+ * test, so it is asserted by call log.
10
+ *
11
+ * Run: node --test lib/org/inbound/facts.test.mjs
12
+ */
13
+
14
+ "use strict";
15
+
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+
19
+ import { resolveFacts, collectTasks } from "./facts.mjs";
20
+ import { classifyEvent } from "./directedness.mjs";
21
+
22
+ const ME = "M-me";
23
+ const THEM = "M-them";
24
+
25
+ /**
26
+ * A fake IO seam. `routes` maps `method` or `read:<path>` to a handler; anything
27
+ * unrouted returns an error frame (which the real seam would have logged).
28
+ */
29
+ function fakeIo(routes = {}) {
30
+ const calls = [];
31
+ const logs = [];
32
+ return {
33
+ calls,
34
+ logs,
35
+ stats: { calls: 0, reads: 0, failures: 0, methods: [] },
36
+ log: (level, message, meta) => logs.push({ level, message, meta }),
37
+ async call(method, params) {
38
+ calls.push({ kind: "call", method, params });
39
+ this.stats.calls += 1;
40
+ const h = routes[method];
41
+ if (!h) return { ok: false, error: { code: "NOT_FOUND", message: `no route ${method}` } };
42
+ const out = typeof h === "function" ? await h(params) : h;
43
+ return out === undefined ? { ok: true, result: null } : out;
44
+ },
45
+ async read(path) {
46
+ const base = String(path).split("?")[0];
47
+ calls.push({ kind: "read", path, base });
48
+ this.stats.reads += 1;
49
+ const h = routes[`read:${base}`];
50
+ if (!h) return { ok: false, error: { code: "NOT_FOUND", message: `no route ${base}` }, status: 404 };
51
+ const out = typeof h === "function" ? await h(path) : h;
52
+ return out === undefined ? { ok: true, payload: null } : out;
53
+ },
54
+ };
55
+ }
56
+
57
+ const okFrame = (result) => ({ ok: true, result });
58
+ const okRead = (payload) => ({ ok: true, payload, status: 200 });
59
+
60
+ function ev(over) {
61
+ return { seq: over.seq ?? 1, at: "2026-08-11T00:00:00.000Z", actor: THEM, ...over };
62
+ }
63
+
64
+ function msgEvent(channelId, channelKind, messageId, extra = {}) {
65
+ return classifyEvent(
66
+ ev({
67
+ family: "messaging",
68
+ kind: "send",
69
+ entity_id: messageId,
70
+ payload: { actor: THEM, channelId, channelKind, mentionCount: 1, threaded: false, ...extra },
71
+ }),
72
+ );
73
+ }
74
+
75
+ const ROSTER = {
76
+ "messaging.channels": okFrame({
77
+ channels: [
78
+ { id: "C-dm", kind: "DM", slug: "dm-them", name: "them" },
79
+ { id: "C-pub", kind: "PUBLIC", slug: "general", name: "General" },
80
+ { id: "C-priv", kind: "PRIVATE", slug: "leads", name: "Leads" },
81
+ ],
82
+ }),
83
+ };
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // THE negative: never touch a room outside my roster
87
+ // ---------------------------------------------------------------------------
88
+
89
+ test("NEVER probes a channel outside my roster (asserted by call log)", async () => {
90
+ const io = fakeIo({
91
+ ...ROSTER,
92
+ "messaging.history": ({ channelId }) => okFrame({ channelId, messages: [] }),
93
+ });
94
+ const candidates = [
95
+ msgEvent("C-theirs", "DM", "m1"), // someone else's DM — invisible to me
96
+ msgEvent("C-secret", "PRIVATE", "m2"), // a private space I am not in
97
+ ];
98
+ await resolveFacts({ candidates, me: ME, io });
99
+
100
+ const histories = io.calls.filter((c) => c.method === "messaging.history");
101
+ assert.equal(histories.length, 0, "no history may be fetched for a room outside my roster");
102
+ assert.equal(io.calls.filter((c) => c.method === "messaging.channels").length, 1);
103
+ assert.ok(
104
+ io.logs.some((l) => /outside my roster/.test(l.message)),
105
+ "the skip must be LOGGED, never silent",
106
+ );
107
+ });
108
+
109
+ test("roster unreadable → nothing is paged at all, and it says so", async () => {
110
+ const io = fakeIo({ "messaging.history": () => okFrame({ messages: [] }) });
111
+ const facts = await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io });
112
+
113
+ assert.equal(facts.visibleChannelIds, null);
114
+ assert.equal(facts.memberChannelIds, null);
115
+ assert.deepEqual(facts.degraded, ["channels"]);
116
+ assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
117
+ assert.ok(io.logs.some((l) => /roster/.test(l.message)));
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // roster → visible vs member
122
+ // ---------------------------------------------------------------------------
123
+
124
+ test("a PUBLIC channel is visible but NOT proof of membership", async () => {
125
+ const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
126
+ const facts = await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m1")], me: ME, io });
127
+
128
+ assert.deepEqual([...facts.visibleChannelIds].sort(), ["C-dm", "C-priv", "C-pub"]);
129
+ assert.deepEqual([...facts.memberChannelIds].sort(), ["C-dm", "C-priv"]);
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // cost: the redacted counts avoid pages entirely
134
+ // ---------------------------------------------------------------------------
135
+
136
+ test("a space message with mentionCount=0 and threaded=false costs ZERO history calls", async () => {
137
+ const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
138
+ const cand = classifyEvent(
139
+ ev({
140
+ family: "messaging",
141
+ kind: "send",
142
+ entity_id: "m1",
143
+ payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 0, threaded: false },
144
+ }),
145
+ );
146
+ await resolveFacts({ candidates: [cand], me: ME, io });
147
+ assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
148
+ assert.ok(io.logs.some((l) => /mentionCount=0/.test(l.message)));
149
+ });
150
+
151
+ test("one history page per affected channel, not per message", async () => {
152
+ let pages = 0;
153
+ const io = fakeIo({
154
+ ...ROSTER,
155
+ "messaging.history": ({ channelId }) => {
156
+ pages += 1;
157
+ return okFrame({ channelId, messages: [{ id: "m1", authorId: THEM, body: "a", mentions: [ME] }] });
158
+ },
159
+ });
160
+ await resolveFacts({
161
+ candidates: [msgEvent("C-pub", "PUBLIC", "m1"), msgEvent("C-pub", "PUBLIC", "m2"), msgEvent("C-pub", "PUBLIC", "m3")],
162
+ me: ME,
163
+ io,
164
+ });
165
+ assert.equal(pages, 1);
166
+ });
167
+
168
+ test("the channel-page cap bites and is logged", async () => {
169
+ const channels = Array.from({ length: 5 }, (_, i) => ({ id: `C-${i}`, kind: "PUBLIC", slug: `c${i}` }));
170
+ const io = fakeIo({
171
+ "messaging.channels": okFrame({ channels }),
172
+ "messaging.history": ({ channelId }) => okFrame({ channelId, messages: [] }),
173
+ });
174
+ await resolveFacts({
175
+ candidates: channels.map((c, i) => msgEvent(c.id, "PUBLIC", `m${i}`)),
176
+ me: ME,
177
+ io,
178
+ limits: { maxChannelPages: 2 },
179
+ });
180
+ assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 2);
181
+ assert.ok(io.logs.some((l) => /capping at 2/.test(l.message)));
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // thread roots
186
+ // ---------------------------------------------------------------------------
187
+
188
+ test("myThreadRootIds records threads I authored in and threads that named me", async () => {
189
+ const io = fakeIo({
190
+ ...ROSTER,
191
+ "messaging.history": () =>
192
+ okFrame({
193
+ messages: [
194
+ { id: "r1", authorId: ME, body: "kick off", mentions: [] },
195
+ { id: "m2", authorId: THEM, body: "reply", threadRootId: "r1", mentions: [] },
196
+ { id: "m3", authorId: THEM, body: "hi", threadRootId: "r2", mentions: [ME] },
197
+ { id: "m4", authorId: THEM, body: "nothing to do with me", threadRootId: "r3", mentions: [] },
198
+ ],
199
+ }),
200
+ });
201
+ const facts = await resolveFacts({ candidates: [msgEvent("C-pub", "PUBLIC", "m2")], me: ME, io });
202
+ assert.deepEqual([...facts.myThreadRootIds].sort(), ["r1", "r2"]);
203
+ });
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // board
207
+ // ---------------------------------------------------------------------------
208
+
209
+ test("board.context resolves assignee/reviewer roles out of the swimlane projection", async () => {
210
+ const io = fakeIo({
211
+ "read:board.context": okRead({
212
+ lanes: [
213
+ {
214
+ workstreamId: "W-1",
215
+ columns: {
216
+ doing: [{ id: "T-1", title: "mine", col: "doing", assigneeId: ME, reviewerId: null }],
217
+ review: [{ id: "T-2", title: "review", col: "review", assigneeId: THEM, reviewerId: ME }],
218
+ done: [{ id: "T-3", title: "theirs", col: "done", assigneeId: THEM, reviewerId: THEM }],
219
+ },
220
+ },
221
+ ],
222
+ }),
223
+ "board.taskComments": okFrame({ comments: [] }),
224
+ });
225
+ const cand = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
226
+ const facts = await resolveFacts({ candidates: [cand], me: ME, io });
227
+
228
+ assert.equal(facts.taskRole.get("T-1"), "assignee");
229
+ assert.equal(facts.taskRole.get("T-2"), "reviewer");
230
+ assert.equal(facts.taskRole.has("T-3"), false);
231
+ assert.equal(facts.tasks.get("T-1").title, "mine");
232
+ });
233
+
234
+ test("board.context unreadable → taskRole empty-but-present + degraded + logged", async () => {
235
+ const io = fakeIo({});
236
+ const cand = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
237
+ const facts = await resolveFacts({ candidates: [cand], me: ME, io });
238
+ assert.equal(facts.taskRole, null);
239
+ assert.deepEqual(facts.degraded, ["board"]);
240
+ assert.ok(facts.degraded.length && io.logs.some((l) => /board\.context/.test(l.message)));
241
+ });
242
+
243
+ test("collectTasks is defensive about shape changes", () => {
244
+ assert.deepEqual(collectTasks(null), []);
245
+ assert.deepEqual(collectTasks({ nope: 1 }), []);
246
+ assert.equal(collectTasks([{ id: "T-1", col: "doing" }]).length, 1);
247
+ // No infinite recursion on a deep/odd payload.
248
+ assert.doesNotThrow(() => collectTasks({ a: { b: { c: { d: { e: { f: { g: 1 } } } } } } }));
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // decision / escalation / approval / files probes
253
+ // ---------------------------------------------------------------------------
254
+
255
+ test("decision facts: list read + one listComments probe, skipped when I proposed it", async () => {
256
+ const io = fakeIo({
257
+ "read:decision.list": okRead({
258
+ decisions: [
259
+ { id: "D-mine", title: "mine", proposedBy: ME },
260
+ { id: "D-theirs", title: "theirs", proposedBy: THEM },
261
+ ],
262
+ }),
263
+ "decision.listComments": ({ decisionId }) =>
264
+ okFrame({ decisionId, comments: [{ id: "c1", authorId: ME, body: "+1" }] }),
265
+ });
266
+ const cands = [
267
+ classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-mine", payload: { authorId: THEM } })),
268
+ classifyEvent(ev({ family: "decision", kind: "decision.comment", entity_id: "D-theirs", payload: { authorId: THEM } })),
269
+ ];
270
+ const facts = await resolveFacts({ candidates: cands, me: ME, io });
271
+
272
+ const probes = io.calls.filter((c) => c.method === "decision.listComments");
273
+ assert.deepEqual(probes.map((p) => p.params.decisionId), ["D-theirs"], "no probe needed for one I proposed");
274
+ assert.ok(facts.decisionVoices.get("D-theirs").has(ME));
275
+ });
276
+
277
+ test("escalation facts come from one list read", async () => {
278
+ const io = fakeIo({
279
+ "messaging.channels": okFrame({ channels: [] }),
280
+ "read:board.context": okRead({ lanes: [] }),
281
+ "escalation.list": okFrame({ escalations: [{ id: "E-1", title: "prod", waitingOn: ME }] }),
282
+ });
283
+ const cand = classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM } }));
284
+ const facts = await resolveFacts({ candidates: [cand], me: ME, io });
285
+ assert.equal(facts.escalations.get("E-1").waitingOn, ME);
286
+ assert.equal(io.calls.filter((c) => c.method === "escalation.list").length, 1);
287
+ });
288
+
289
+ test("approval facts: the approver seat is READ BACK, because the payload never names it", async () => {
290
+ const io = fakeIo({
291
+ "approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME, status: "pending", actionClass: "spend" }),
292
+ });
293
+ const cand = classifyEvent(
294
+ ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM, actionClass: "spend" } }),
295
+ );
296
+ const facts = await resolveFacts({ candidates: [cand], me: ME, io });
297
+ assert.equal(facts.approvals.get("A-1").approver, ME);
298
+ assert.deepEqual(io.calls.filter((c) => c.method === "approval.get").map((c) => c.params.id), ["A-1"]);
299
+ });
300
+
301
+ test("approval probes are de-duplicated and capped", async () => {
302
+ const io = fakeIo({ "approval.get": ({ id }) => okFrame({ id, requester: THEM, approver: ME }) });
303
+ const cands = [
304
+ classifyEvent(ev({ seq: 1, family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM } })),
305
+ classifyEvent(ev({ seq: 2, family: "approval", kind: "approval.approved", entity_id: "A-1", payload: { requester: THEM } })),
306
+ classifyEvent(ev({ seq: 3, family: "approval", kind: "approval.requested", entity_id: "A-2", payload: { requester: THEM } })),
307
+ ];
308
+ await resolveFacts({ candidates: cands, me: ME, io, limits: { maxApprovalProbes: 1 } });
309
+ assert.equal(io.calls.filter((c) => c.method === "approval.get").length, 1);
310
+ });
311
+
312
+ test("doc facts: files.get IS the ACL — an invisible doc simply never appears", async () => {
313
+ const io = fakeIo({
314
+ "files.get": ({ fileId }) => {
315
+ if (fileId === "F-mine") return okFrame({ file: { id: "F-mine", name: "spec", ownerId: ME }, permission: "owner" });
316
+ if (fileId === "F-shared") return okFrame({ file: { id: "F-shared", name: "plan", ownerId: THEM }, permission: "comment" });
317
+ return { ok: false, error: { code: "NOT_FOUND", message: "no such file" } };
318
+ },
319
+ });
320
+ const mk = (id) => classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: id, payload: { name: "x" } }));
321
+ const facts = await resolveFacts({ candidates: [mk("F-mine"), mk("F-shared"), mk("F-hidden")], me: ME, io });
322
+
323
+ assert.equal(facts.fileAcl.get("F-mine").ownerId, ME);
324
+ assert.equal(facts.fileAcl.get("F-shared").shared, true);
325
+ assert.equal(facts.fileAcl.has("F-hidden"), false, "a doc I cannot read must not get an ACL entry");
326
+ });
327
+
328
+ test("doc facts: a prose-name probe runs only when myNames is configured", async () => {
329
+ const routes = {
330
+ "files.get": ({ fileId }) => okFrame({ file: { id: fileId, name: "spec", ownerId: THEM }, permission: "none" }),
331
+ "files.comments": () => okFrame({ comments: [{ id: "c1", authorId: THEM, body: "Isla should look at this" }] }),
332
+ };
333
+ const mk = () => classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: {} }));
334
+
335
+ const without = fakeIo(routes);
336
+ await resolveFacts({ candidates: [mk()], me: ME, io: without });
337
+ assert.equal(without.calls.filter((c) => c.method === "files.comments").length, 0);
338
+
339
+ const with_ = fakeIo(routes);
340
+ const facts = await resolveFacts({ candidates: [mk()], me: ME, io: with_, myNames: ["Isla"] });
341
+ assert.equal(with_.calls.filter((c) => c.method === "files.comments").length, 1);
342
+ assert.equal(facts.fileAcl.get("F-1").mentionsMe, true);
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // no work → no spend
347
+ // ---------------------------------------------------------------------------
348
+
349
+ test("a family absent from the batch costs nothing", async () => {
350
+ const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
351
+ await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io });
352
+ const methods = io.calls.map((c) => c.method || c.base);
353
+ assert.equal(methods.includes("board.context"), false);
354
+ assert.equal(methods.includes("escalation.list"), false);
355
+ assert.equal(methods.includes("approval.get"), false);
356
+ assert.equal(methods.includes("files.get"), false);
357
+ });
358
+
359
+ test("an empty batch, or no io, resolves to an inert fact set", async () => {
360
+ const a = await resolveFacts({ candidates: [], me: ME, io: fakeIo({}) });
361
+ assert.equal(a.channelPages.size, 0);
362
+ const b = await resolveFacts({ candidates: [msgEvent("C-dm", "DM", "m1")], me: ME, io: null });
363
+ assert.equal(b.visibleChannelIds, null);
364
+ });
365
+
366
+ test("a disabled surface is never probed for", async () => {
367
+ const io = fakeIo({ ...ROSTER, "messaging.history": () => okFrame({ messages: [] }) });
368
+ await resolveFacts({
369
+ candidates: [msgEvent("C-pub", "PUBLIC", "m1")],
370
+ me: ME,
371
+ io,
372
+ enabled: { dm: true, mention: false, thread_reply: false },
373
+ });
374
+ assert.equal(io.calls.filter((c) => c.method === "messaging.history").length, 0);
375
+ });