@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,720 @@
1
+ /**
2
+ * lib/org/inbound/directedness.mjs — "is this org event for ME, and why?"
3
+ *
4
+ * PURE. No IO, no network, no clock. Two halves, deliberately split so the
5
+ * interesting logic is testable with plain objects:
6
+ *
7
+ * 1. {@link classifyEvent} — a raw `/v1/events` row → a {@link Candidate}:
8
+ * which surface family it belongs to, which ids it names, which member ids
9
+ * the payload named OUTRIGHT (`directTo`), and which relational PROBES
10
+ * would have to hit for it to be mine.
11
+ * 2. {@link resolveDirected} — a Candidate + the agent's own resolved
12
+ * {@link module:lib/org/inbound/facts Facts} → a typed verdict
13
+ * `{ directed, surface, reason }`.
14
+ *
15
+ * ── WHY IT HAS TO BE TWO HALVES ──
16
+ * The org event feed is ORG-WIDE and REDACTED. `/v1/events` is scoped with
17
+ * `scopeWhere(orgId, {seq:{gt:cursor}})` (hq `src/server/rpc/reads.ts`), so an
18
+ * agent receives EVERY family's rows, and hq deliberately strips identity from
19
+ * the payloads: `messaging.send` stores `{actor, channelId, channelKind,
20
+ * recipientCount, mentionCount, attachmentCount, threaded, ts}` — a COUNT of
21
+ * mentions, never the list (hq `src/server/actions/messages.ts`,
22
+ * `src/server/methods/messaging/send.ts`). That redaction is correct — an
23
+ * org-wide feed must not leak who DMs whom — but it means "is this mine?" is
24
+ * STRUCTURALLY unanswerable from the event alone for most families. The missing
25
+ * half is the agent's OWN membership/ownership, which the agent can read for
26
+ * itself over its OWN entitlements (`messaging.channels`, `board.context`,
27
+ * `decision.list`, `approval.get`, …). `facts.mjs` fetches exactly that; this
28
+ * module joins the two.
29
+ *
30
+ * ── THE SAFETY INVARIANT (the one that must never regress) ──
31
+ * An event in a room I am not in must NEVER be directed at me, and must never
32
+ * even cause a probe. Enforced here as a hard gate, not as an emergent property:
33
+ *
34
+ * - PRIVATE ROOMS (`DM` / `GROUP_DM` / `HUDDLE`): directed ONLY when the room
35
+ * id is in `facts.memberChannelIds`. `messaging.channels` returns a private
36
+ * room ONLY to its members (hq `methods/messaging/channels.ts` — the OR is
37
+ * `kind:"PUBLIC"` OR `members.some({memberId})`), so presence in that set IS
38
+ * proof of membership. There is no fallback, no heuristic, no "kind contains
39
+ * dm" guess.
40
+ * - PRIVATE SPACES (`kind:"PRIVATE"`): same rule — membership required.
41
+ * - PUBLIC spaces: an @mention or a thread I am in makes it mine. Mere traffic
42
+ * does not.
43
+ * - Any channel id NOT in `facts.visibleChannelIds` is INVISIBLE: no verdict,
44
+ * no hydration, no history fetch. `facts.mjs` refuses to page a channel that
45
+ * is not in that set, so a private room I am not in is never even probed.
46
+ *
47
+ * When membership is UNKNOWN (the roster read failed) the resolver stays
48
+ * conservative for private rooms and answers `false` — the failure mode of
49
+ * guessing "yes" is an agent barging into other people's private conversation.
50
+ *
51
+ * @module lib/org/inbound/directedness
52
+ */
53
+
54
+ "use strict";
55
+
56
+ import { SURFACES } from "./surfaces.mjs";
57
+
58
+ /** Channel kinds where every message is addressed to the room. */
59
+ export const PRIVATE_ROOM_KINDS = Object.freeze(["DM", "GROUP_DM", "HUDDLE"]);
60
+
61
+ /** Channel kinds that require explicit membership to be readable at all. */
62
+ export const MEMBERSHIP_REQUIRED_KINDS = Object.freeze([
63
+ ...PRIVATE_ROOM_KINDS,
64
+ "PRIVATE",
65
+ ]);
66
+
67
+ /**
68
+ * The typed reasons a verdict can carry. A daemon switches on `reason` for
69
+ * triage; it is a closed set so a new value is a deliberate, reviewable change.
70
+ *
71
+ * @typedef {"dm"|"mention"|"thread"|"channel"|"participant"|"assignee"
72
+ * |"reviewer"|"direct"|"requester"|"approver"|"proposer"|"commenter"
73
+ * |"owner"|"shared"|"waiting_on"|"task"|"named"} Reason
74
+ */
75
+
76
+ /** Narrow an unknown payload to a readable record (never throws). */
77
+ function obj(v) {
78
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
79
+ }
80
+
81
+ /** Read a non-empty string field, else undefined. */
82
+ function str(p, ...keys) {
83
+ for (const k of keys) {
84
+ const v = p[k];
85
+ if (typeof v === "string" && v.length > 0) return v;
86
+ if (typeof v === "number" && Number.isFinite(v)) return String(v);
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /** Coerce a Set / array / null into a Set, or null for "unknown". */
92
+ export function asSet(v) {
93
+ if (v instanceof Set) return v;
94
+ if (Array.isArray(v)) return new Set(v.map((x) => String(x)));
95
+ return null;
96
+ }
97
+
98
+ /** Does `set` (possibly null == unknown) contain `id`? Unknown → false. */
99
+ function has(set, id) {
100
+ return !!(set && id && set.has(String(id)));
101
+ }
102
+
103
+ /**
104
+ * The family/kind coordinates of a raw event, tolerating both the modern shape
105
+ * (`{family, kind}`) and the legacy one (`{kind: "messaging.send"}`).
106
+ * @param {object} ev
107
+ * @returns {{family:string, kind:string}}
108
+ */
109
+ export function coordsOf(ev) {
110
+ const rawKind = String((ev && ev.kind) || "");
111
+ const family = String((ev && ev.family) || (rawKind.includes(".") ? rawKind.split(".")[0] : ""));
112
+ let kind = rawKind;
113
+ if (!ev?.family && rawKind.includes(".")) kind = rawKind.slice(rawKind.indexOf(".") + 1);
114
+ return { family, kind };
115
+ }
116
+
117
+ /**
118
+ * @typedef {Object} Candidate
119
+ * @property {number|string|null} seq the per-org ledger cursor position
120
+ * @property {string} family hq event family (chain coordinate)
121
+ * @property {string} kind hq event kind (chain coordinate)
122
+ * @property {string|null} entityId
123
+ * @property {string} actor the acting principal's member id
124
+ * @property {string} at ISO-8601 append time
125
+ * @property {string} topic coarse bucket (see surfaces.mjs)
126
+ * @property {string[]} surfaces candidate surface names, most specific first
127
+ * @property {Record<string,string|undefined>} ids ids the payload named
128
+ * @property {string[]} directTo member ids the PAYLOAD named outright
129
+ * @property {object} payload the raw (redacted) payload, passed through
130
+ */
131
+
132
+ /**
133
+ * Classify one raw event row into a Candidate, or null for the many families a
134
+ * daemon has no business being woken for (presence beats, cost rollups, registry
135
+ * churn, org admin). The cursor still advances past them — they simply produce
136
+ * no candidate.
137
+ *
138
+ * Read DEFENSIVELY throughout: chain payloads are free-form JSON and historical
139
+ * rows predate several of these fields. A missing id degrades to "no probe"
140
+ * (→ dropped) rather than throwing; a classifier crash on one odd historical row
141
+ * must never kill the pull.
142
+ *
143
+ * @param {object} ev raw `/v1/events` row
144
+ * @returns {Candidate|null}
145
+ */
146
+ export function classifyEvent(ev) {
147
+ if (!ev || typeof ev !== "object") return null;
148
+ const { family, kind } = coordsOf(ev);
149
+ if (!family) return null;
150
+ // Prefer the nested payload; tolerate producers that inline the fields.
151
+ const p = ev.payload && typeof ev.payload === "object" ? obj(ev.payload) : obj(ev);
152
+ const base = {
153
+ seq: ev.seq ?? null,
154
+ family,
155
+ kind,
156
+ entityId: ev.entity_id ?? ev.entityId ?? null,
157
+ actor: String(p.actor || ev.actor || ""),
158
+ at: ev.at || p.ts || p.at || "",
159
+ payload: p,
160
+ };
161
+
162
+ // ── messaging: a new message. The BODY never entered the chain; `channelKind`
163
+ // is what distinguishes a DM from a space, and the mention LIST is absent —
164
+ // it has to come from `messaging.history` (facts.channelPages).
165
+ if (family === "messaging" && kind.startsWith("send")) {
166
+ const channelId = str(p, "channelId", "channel");
167
+ if (!channelId) return null;
168
+ return {
169
+ ...base,
170
+ topic: "message",
171
+ surfaces: ["dm", "mention", "thread_reply"],
172
+ ids: {
173
+ channelId,
174
+ channelKind: String(p.channelKind || p.channel_kind || "").toUpperCase() || undefined,
175
+ messageId: base.entityId ?? str(p, "messageId", "id"),
176
+ },
177
+ // The redaction leaves two COUNTS behind, and they are load-bearing: a
178
+ // space message with `mentionCount: 0` and `threaded: false` cannot be a
179
+ // mention or a thread reply, so it can be dropped WITHOUT paging the
180
+ // channel at all. That single test is what keeps mention-detection from
181
+ // costing one `messaging.history` per busy public channel per pull.
182
+ hints: {
183
+ mentionCount: Number.isFinite(Number(p.mentionCount)) ? Number(p.mentionCount) : undefined,
184
+ threaded: typeof p.threaded === "boolean" ? p.threaded : undefined,
185
+ attachmentCount: Number.isFinite(Number(p.attachmentCount)) ? Number(p.attachmentCount) : undefined,
186
+ },
187
+ directTo: [],
188
+ };
189
+ }
190
+
191
+ // ── calling: an invite/start on a call. Mine when it is in one of my rooms,
192
+ // or when the payload named me as an invitee.
193
+ if (family === "calling") {
194
+ const callId = str(p, "callId") ?? base.entityId ?? undefined;
195
+ if (!callId) return null;
196
+ const invitees = []
197
+ .concat(Array.isArray(p.invitees) ? p.invitees : [])
198
+ .concat(Array.isArray(p.participantIds) ? p.participantIds : [])
199
+ .map(String);
200
+ return {
201
+ ...base,
202
+ topic: "call",
203
+ surfaces: ["call"],
204
+ ids: { callId, channelId: str(p, "channelId", "channel"), topic: str(p, "topic") },
205
+ directTo: invitees,
206
+ };
207
+ }
208
+
209
+ // ── board: `entityId` is the Task id. Several kinds name the seat outright
210
+ // (`assignee` on assign/claim/complete/heartbeat) — prefer that over a
211
+ // join, since an assignment IMMEDIATELY reassigned would otherwise be
212
+ // missed by a probe against the task's current state.
213
+ if (family === "board") {
214
+ const taskId = base.entityId ?? str(p, "itemId", "taskId");
215
+ if (!taskId) return null;
216
+ const assignee = str(p, "assignee");
217
+ const isAssignment = kind === "item.assigned" || kind === "item.claimed";
218
+ return {
219
+ ...base,
220
+ topic: "task",
221
+ surfaces: isAssignment ? ["task_assigned", "task_comment"] : ["task_comment", "task_assigned"],
222
+ ids: { taskId, commentId: str(p, "commentId"), channelId: str(p, "channelId") },
223
+ // `assignee` names the seat only when the event is ABOUT that seat.
224
+ directTo: assignee && isAssignment ? [assignee] : [],
225
+ };
226
+ }
227
+
228
+ // ── file (chat attachments): the payload names the room, so channel
229
+ // membership is the join. `fileKey` is the opaque handle the read needs.
230
+ if (family === "file") {
231
+ const fileKey = str(p, "fileKey");
232
+ if (!fileKey) return null;
233
+ return {
234
+ ...base,
235
+ topic: "file",
236
+ surfaces: ["file_comment"],
237
+ ids: {
238
+ fileKey,
239
+ channelId: str(p, "channelId"),
240
+ commentId: base.entityId ?? undefined,
241
+ authorId: str(p, "authorId", "pinnedBy", "unpinnedBy"),
242
+ },
243
+ directTo: [],
244
+ };
245
+ }
246
+
247
+ // ── files (workspace docs): `entityId` is the File id and the payload carries
248
+ // NOTHING identifying — ownership/shares must be read with `files.get`,
249
+ // which is itself ACL'd, so a doc I cannot see is NOT_FOUND rather than a
250
+ // leak.
251
+ if (family === "files") {
252
+ const fileId = base.entityId ?? str(p, "fileId");
253
+ if (!fileId) return null;
254
+ return {
255
+ ...base,
256
+ topic: "file",
257
+ surfaces: ["doc_comment"],
258
+ ids: { fileId, commentId: str(p, "commentId"), name: str(p, "name") },
259
+ directTo: [],
260
+ };
261
+ }
262
+
263
+ // ── decision: the payload names the COMMENT author, never the audience. Mine
264
+ // when I proposed it, decided it, or have spoken in its thread.
265
+ if (family === "decision") {
266
+ const decisionId = base.entityId ?? str(p, "decisionId");
267
+ if (!decisionId) return null;
268
+ return {
269
+ ...base,
270
+ topic: "decision",
271
+ surfaces: ["decision"],
272
+ ids: {
273
+ decisionId,
274
+ commentId: str(p, "commentId"),
275
+ authorId: str(p, "authorId", "proposedBy", "signedBy"),
276
+ title: str(p, "title"),
277
+ },
278
+ directTo: [],
279
+ };
280
+ }
281
+
282
+ // ── escalation: `taskId` / `channelId` are the join keys; `waitingOn` (read
283
+ // back via escalation.list) can name a seat outright.
284
+ if (family === "escalation") {
285
+ const escalationId = base.entityId ?? str(p, "escalationId");
286
+ if (!escalationId) return null;
287
+ return {
288
+ ...base,
289
+ topic: "escalation",
290
+ surfaces: ["escalation"],
291
+ ids: {
292
+ escalationId,
293
+ taskId: str(p, "taskId"),
294
+ channelId: str(p, "channelId"),
295
+ severity: str(p, "severity"),
296
+ },
297
+ directTo: [],
298
+ };
299
+ }
300
+
301
+ // ── approval: `approval.requested` names ONLY the requester (hq
302
+ // `methods/approval/request.ts`) — the approver is on the row, not in the
303
+ // payload, so it takes an `approval.get` probe. `approval.approved` /
304
+ // `.rejected` name both ends.
305
+ if (family === "approval") {
306
+ const approvalId = base.entityId ?? str(p, "approvalId", "id");
307
+ if (!approvalId) return null;
308
+ const directTo = [str(p, "requester"), str(p, "approver")].filter(Boolean);
309
+ return {
310
+ ...base,
311
+ topic: "approval",
312
+ surfaces: ["approval"],
313
+ ids: { approvalId, taskId: str(p, "itemId"), actionClass: str(p, "actionClass") },
314
+ directTo,
315
+ };
316
+ }
317
+
318
+ // ── handoff: the payload names BOTH ends. Zero-join delivery.
319
+ if (family === "handoff") {
320
+ const directTo = [str(p, "to"), str(p, "from")].filter(Boolean);
321
+ if (directTo.length === 0) return null;
322
+ return {
323
+ ...base,
324
+ topic: "handoff",
325
+ surfaces: ["handoff"],
326
+ ids: {
327
+ runId: base.entityId ?? undefined,
328
+ taskId: str(p, "itemId"),
329
+ handoffId: str(p, "handoffId"),
330
+ intent: str(p, "intent"),
331
+ to: str(p, "to"),
332
+ from: str(p, "from"),
333
+ },
334
+ directTo,
335
+ };
336
+ }
337
+
338
+ // ── email: the one family whose redacted payload already names its seat
339
+ // (`memberId` = the owning mailbox's member). Unrouted mail
340
+ // (`memberId: null`) belongs to nobody and is dropped.
341
+ if (family === "email" && kind === "received") {
342
+ const memberId = str(p, "memberId");
343
+ if (!memberId) return null;
344
+ if (p.autoSubmitted === true) return null; // bounces / vacation autoreplies
345
+ return {
346
+ ...base,
347
+ topic: "email",
348
+ surfaces: ["email"],
349
+ ids: {
350
+ emailMessageId: str(p, "emailMessageId") ?? base.entityId ?? undefined,
351
+ threadId: str(p, "threadId"),
352
+ mailboxId: str(p, "mailboxId"),
353
+ },
354
+ directTo: [memberId],
355
+ };
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ /** A verdict with no delivery. */
362
+ function no(reason) {
363
+ return { directed: false, surface: null, reason, why: reason };
364
+ }
365
+
366
+ /** A verdict that delivers. */
367
+ function yes(surface, reason, extra = {}) {
368
+ return { directed: true, surface, reason, why: reason, ...extra };
369
+ }
370
+
371
+ /**
372
+ * Join a Candidate against the agent's own facts and return a typed verdict.
373
+ * PURE — every fact it needs is on `facts`, which `facts.mjs` resolved from the
374
+ * agent's own entitled reads.
375
+ *
376
+ * @param {Candidate} cand
377
+ * @param {string} me the agent's member id
378
+ * @param {import("./facts.mjs").Facts} facts
379
+ * @param {{enabled?: Record<string,boolean>}} [opts]
380
+ * @returns {{directed:boolean, surface:string|null, reason:string, why:string}}
381
+ */
382
+ export function resolveDirected(cand, me, facts = {}, opts = {}) {
383
+ if (!cand) return no("not_classified");
384
+ const enabled = opts.enabled || null;
385
+ const on = (s) => (enabled ? !!enabled[s] : true);
386
+ const meId = me ? String(me) : "";
387
+
388
+ // My OWN echo is never inbound. This is the single most important drop: without
389
+ // it an agent re-ingests its own replies and holds a conversation with itself.
390
+ if (meId && cand.actor && cand.actor === meId) return no("own_echo");
391
+
392
+ // A payload that named me outright needs no join at all.
393
+ if (meId && cand.directTo.map(String).includes(meId)) {
394
+ const surface = cand.surfaces.find(on);
395
+ if (!surface) return no("surface_disabled");
396
+ switch (cand.family) {
397
+ case "approval":
398
+ // `approval.requested` names only the requester; `approval.approved` /
399
+ // `.rejected` name both ends. Report which end I am.
400
+ if (String(cand.payload.approver || "") === meId) return yes(surface, "approver");
401
+ return yes(surface, "requester");
402
+ case "handoff":
403
+ // Offered TO me = work landing on my desk; offered BY me = outcome news.
404
+ return yes(surface, String(cand.ids.to || "") === meId ? "direct" : "requester");
405
+ case "board":
406
+ return yes(surface, "assignee");
407
+ case "calling":
408
+ return yes(surface, "participant");
409
+ default:
410
+ return yes(surface, "direct");
411
+ }
412
+ }
413
+
414
+ switch (cand.family) {
415
+ case "messaging":
416
+ return resolveMessaging(cand, meId, facts, on);
417
+ case "calling":
418
+ return resolveCalling(cand, meId, facts, on);
419
+ case "board":
420
+ return resolveBoard(cand, meId, facts, on);
421
+ case "file":
422
+ return resolveChatFile(cand, meId, facts, on);
423
+ case "files":
424
+ return resolveDoc(cand, meId, facts, on);
425
+ case "decision":
426
+ return resolveDecision(cand, meId, facts, on);
427
+ case "escalation":
428
+ return resolveEscalation(cand, meId, facts, on);
429
+ case "approval":
430
+ return resolveApproval(cand, meId, facts, on);
431
+ default:
432
+ return no("no_rule");
433
+ }
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Per-family resolvers
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Messaging. THE safety-critical one.
442
+ *
443
+ * Order of decision:
444
+ * 1. channel not visible to me → invisible (no verdict, no probe)
445
+ * 2. private room + I am a member → `dm`
446
+ * 3. private room + I am NOT a member → NOT directed (hard stop)
447
+ * 4. private SPACE + I am not a member → NOT directed (hard stop)
448
+ * 5. mentions[] (from history) names me → `mention`
449
+ * 6. thread root I have spoken in / been named in → `thread_reply`
450
+ * 7. otherwise → ambient chatter, not directed
451
+ */
452
+ function resolveMessaging(cand, me, facts, on) {
453
+ const channelId = cand.ids.channelId;
454
+ const visible = asSet(facts.visibleChannelIds);
455
+ const members = asSet(facts.memberChannelIds);
456
+
457
+ // The channel kind on the event is authoritative for the ROOM CLASS; the
458
+ // roster's kind is authoritative when the event omitted it.
459
+ const chan = facts.channels instanceof Map ? facts.channels.get(String(channelId)) : undefined;
460
+ const channelKind = String(cand.ids.channelKind || (chan && chan.kind) || "").toUpperCase();
461
+
462
+ // (1) A channel that is not in my visible roster is a room I cannot read. It
463
+ // is never mine, and facts.mjs will never have paged it.
464
+ if (visible && channelId && !visible.has(String(channelId))) return no("channel_not_visible");
465
+
466
+ const isPrivateRoom = PRIVATE_ROOM_KINDS.includes(channelKind);
467
+ const needsMembership = MEMBERSHIP_REQUIRED_KINDS.includes(channelKind);
468
+
469
+ // (2)/(3) Private rooms: membership IS the address.
470
+ if (isPrivateRoom) {
471
+ if (!members) return no("membership_unknown");
472
+ if (!has(members, channelId)) return no("not_a_member");
473
+ return on("dm") ? yes("dm", "dm") : no("surface_disabled");
474
+ }
475
+ // (4) A private SPACE I am not in: hard stop before any mention probe.
476
+ if (needsMembership && !has(members, channelId)) {
477
+ return no(members ? "not_a_member" : "membership_unknown");
478
+ }
479
+ // An UNKNOWN channel kind (older row, roster miss) is treated as
480
+ // membership-required — the conservative branch.
481
+ if (!channelKind && !has(members, channelId)) return no("channel_kind_unknown");
482
+
483
+ // Cheap structural drop from the redacted counts, BEFORE any hydration: a
484
+ // space message that mentions nobody and is not threaded cannot be mine.
485
+ if (needsSpaceProbe(cand, facts) === false) return no("no_mentions_not_threaded");
486
+
487
+ const msg = lookupMessage(facts, channelId, cand.ids.messageId);
488
+
489
+ // (5) An explicit @mention of my member id. The mention list is NOT in the
490
+ // chain payload; it comes from `messaging.history`, which hq ACLs to rooms
491
+ // I may read.
492
+ if (msg && Array.isArray(msg.mentions) && me && msg.mentions.map(String).includes(me)) {
493
+ return on("mention") ? yes("mention", "mention") : no("surface_disabled");
494
+ }
495
+
496
+ // (6) A reply in a thread I am in. "In" = I authored a message under that
497
+ // root, or I was named in one.
498
+ const root = msg && (msg.threadRootId || msg.thread_root_id);
499
+ if (root && facts.myThreadRootIds instanceof Set && facts.myThreadRootIds.has(String(root))) {
500
+ return on("thread_reply") ? yes("thread_reply", "thread") : no("surface_disabled");
501
+ }
502
+
503
+ // A mention by DISPLAY NAME, when the org has no structured mention row (an
504
+ // agent addressed as "@Isla" in prose). Only ever consulted for a space I can
505
+ // already read, and only when the caller supplied its own names.
506
+ if (msg && matchesMyName(facts, msg.body ?? msg.text)) {
507
+ return on("mention") ? yes("mention", "named") : no("surface_disabled");
508
+ }
509
+
510
+ if (!msg) return no("message_not_hydrated");
511
+ return no("ambient_channel_traffic");
512
+ }
513
+
514
+ /** Calls: an invite in a room I belong to, else not mine. */
515
+ function resolveCalling(cand, me, facts, on) {
516
+ if (!on("call")) return no("surface_disabled");
517
+ const members = asSet(facts.memberChannelIds);
518
+ const visible = asSet(facts.visibleChannelIds);
519
+ const channelId = cand.ids.channelId;
520
+ if (channelId) {
521
+ if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
522
+ if (has(members, channelId)) return yes("call", "channel");
523
+ return no("not_a_member");
524
+ }
525
+ // A bare call with no room context: only when the payload named me, which the
526
+ // directTo branch already handled.
527
+ return no("call_without_room");
528
+ }
529
+
530
+ /** Board: assignee/reviewer of the item, resolved from `board.context`. */
531
+ function resolveBoard(cand, me, facts, on) {
532
+ const role = facts.taskRole instanceof Map ? facts.taskRole.get(String(cand.ids.taskId)) : undefined;
533
+ if (!role) return no(facts.taskRole ? "not_my_task" : "board_unknown");
534
+ const isAssignment = cand.kind === "item.assigned" || cand.kind === "item.claimed";
535
+ const surface = isAssignment ? "task_assigned" : "task_comment";
536
+ if (!on(surface)) return no("surface_disabled");
537
+ return yes(surface, role);
538
+ }
539
+
540
+ /** Chat-attached file: the comment landed in a room, so membership is the join. */
541
+ function resolveChatFile(cand, me, facts, on) {
542
+ if (!on("file_comment")) return no("surface_disabled");
543
+ const channelId = cand.ids.channelId;
544
+ const visible = asSet(facts.visibleChannelIds);
545
+ const members = asSet(facts.memberChannelIds);
546
+ if (!channelId) {
547
+ // No room on the payload: fall back to the task-attachment key, which names
548
+ // a task whose ownership I already know.
549
+ const taskId = taskIdFromFileKey(cand.ids.fileKey);
550
+ const role = taskId && facts.taskRole instanceof Map ? facts.taskRole.get(taskId) : undefined;
551
+ if (role) return yes("file_comment", role);
552
+ return no("file_without_room");
553
+ }
554
+ if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
555
+ if (!has(members, channelId)) return no("not_a_member");
556
+ return yes("file_comment", "channel");
557
+ }
558
+
559
+ /** Workspace doc: ownership or an explicit share, read back via `files.get`. */
560
+ function resolveDoc(cand, me, facts, on) {
561
+ if (!on("doc_comment")) return no("surface_disabled");
562
+ const acl = facts.fileAcl instanceof Map ? facts.fileAcl.get(String(cand.ids.fileId)) : undefined;
563
+ if (!acl) return no(facts.fileAcl ? "file_not_visible" : "files_unknown");
564
+ if (acl.ownerId && me && String(acl.ownerId) === me) return yes("doc_comment", "owner");
565
+ if (acl.shared) return yes("doc_comment", "shared");
566
+ if (acl.mentionsMe) return yes("doc_comment", "named");
567
+ return no("not_my_doc");
568
+ }
569
+
570
+ /** Decision: proposer, decider, or a voice already in the thread. */
571
+ function resolveDecision(cand, me, facts, on) {
572
+ if (!on("decision")) return no("surface_disabled");
573
+ const d = facts.decisions instanceof Map ? facts.decisions.get(String(cand.ids.decisionId)) : undefined;
574
+ if (d && me) {
575
+ if (String(d.proposedBy || "") === me) return yes("decision", "proposer");
576
+ if (String(d.decidedBy || "") === me) return yes("decision", "direct");
577
+ }
578
+ const voices =
579
+ facts.decisionVoices instanceof Map
580
+ ? facts.decisionVoices.get(String(cand.ids.decisionId))
581
+ : undefined;
582
+ if (voices && me && voices.has(me)) return yes("decision", "commenter");
583
+ if (!d && !voices) return no("decision_unknown");
584
+ return no("not_my_decision");
585
+ }
586
+
587
+ /** Escalation: on my task, in my room, or explicitly waiting on me. */
588
+ function resolveEscalation(cand, me, facts, on) {
589
+ if (!on("escalation")) return no("surface_disabled");
590
+ const e = facts.escalations instanceof Map ? facts.escalations.get(String(cand.ids.escalationId)) : undefined;
591
+ const waitingOn = e && e.waitingOn ? String(e.waitingOn) : "";
592
+ if (me && waitingOn && waitingOn === me) return yes("escalation", "waiting_on");
593
+
594
+ const taskId = cand.ids.taskId || (e && e.taskId) || "";
595
+ const role = taskId && facts.taskRole instanceof Map ? facts.taskRole.get(String(taskId)) : undefined;
596
+ if (role) return yes("escalation", role);
597
+
598
+ const channelId = cand.ids.channelId || (e && e.channelId) || "";
599
+ const members = asSet(facts.memberChannelIds);
600
+ const visible = asSet(facts.visibleChannelIds);
601
+ if (channelId) {
602
+ if (visible && !visible.has(String(channelId))) return no("channel_not_visible");
603
+ if (has(members, channelId)) return yes("escalation", "channel");
604
+ }
605
+ return no("not_my_escalation");
606
+ }
607
+
608
+ /** Approval: the approver seat is on the row, not in the payload — read it back. */
609
+ function resolveApproval(cand, me, facts, on) {
610
+ if (!on("approval")) return no("surface_disabled");
611
+ const a = facts.approvals instanceof Map ? facts.approvals.get(String(cand.ids.approvalId)) : undefined;
612
+ if (!a) return no(facts.approvals ? "approval_not_visible" : "approval_unknown");
613
+ if (me && String(a.approver || "") === me) return yes("approval", "approver");
614
+ if (me && String(a.requester || "") === me) return yes("approval", "requester");
615
+ return no("not_my_approval");
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Small pure helpers (exported so the tests can pin them)
620
+ // ---------------------------------------------------------------------------
621
+
622
+ /**
623
+ * Would a SPACE message need its channel paged to decide directedness?
624
+ *
625
+ * `true` — it might mention me or be threaded; page the channel.
626
+ * `false` — the redacted counts PROVE it cannot be mine (0 mentions, not
627
+ * threaded); drop it without a single RPC.
628
+ * `null` — the counts are absent (older row / other producer); page to be safe.
629
+ *
630
+ * Prose-name matching, when the caller enabled it, always needs the body — so a
631
+ * caller with `myNames` gets `null` rather than `false`.
632
+ *
633
+ * @param {Candidate} cand
634
+ * @param {{myNames?: string[]}} [facts]
635
+ * @returns {boolean|null}
636
+ */
637
+ export function needsSpaceProbe(cand, facts = {}) {
638
+ const h = (cand && cand.hints) || {};
639
+ if (Array.isArray(facts.myNames) && facts.myNames.length > 0) return null;
640
+ if (h.mentionCount === undefined || h.threaded === undefined) return null;
641
+ if (h.mentionCount > 0) return true;
642
+ if (h.threaded) return true;
643
+ return false;
644
+ }
645
+
646
+ /** Find a hydrated message row in `facts.channelPages`. */
647
+ export function lookupMessage(facts, channelId, messageId) {
648
+ if (!(facts.channelPages instanceof Map) || !channelId || !messageId) return null;
649
+ const page = facts.channelPages.get(String(channelId));
650
+ if (!page || !(page.byId instanceof Map)) return null;
651
+ return page.byId.get(String(messageId)) || null;
652
+ }
653
+
654
+ /**
655
+ * Does this prose name me? Used ONLY as a last resort, for surfaces where hq
656
+ * stores no structured mention row (decision/file comment prose). Matches on a
657
+ * word boundary, case-insensitively, with or without a leading `@`.
658
+ *
659
+ * @param {{myNames?: string[]}} facts
660
+ * @param {unknown} text
661
+ * @returns {boolean}
662
+ */
663
+ export function matchesMyName(facts, text) {
664
+ const names = Array.isArray(facts && facts.myNames) ? facts.myNames : [];
665
+ if (names.length === 0) return false;
666
+ const body = typeof text === "string" ? text : "";
667
+ if (!body) return false;
668
+ const lower = body.toLowerCase();
669
+ for (const raw of names) {
670
+ const n = String(raw || "").trim().toLowerCase();
671
+ if (n.length < 2) continue;
672
+ let from = 0;
673
+ for (;;) {
674
+ const i = lower.indexOf(n, from);
675
+ if (i === -1) break;
676
+ const before = i > 0 ? lower[i - 1] : "";
677
+ const after = lower[i + n.length] || "";
678
+ const boundaryBefore = !before || before === "@" || !/[a-z0-9_]/.test(before);
679
+ const boundaryAfter = !after || !/[a-z0-9_]/.test(after);
680
+ if (boundaryBefore && boundaryAfter) return true;
681
+ from = i + 1;
682
+ }
683
+ }
684
+ return false;
685
+ }
686
+
687
+ /**
688
+ * hq encodes a chat attachment's surface in the opaque fileKey
689
+ * (`message-attachment:` / `task-attachment:` / `pinned-file:` — see
690
+ * `src/server/llm-responder/comments.ts`). Recover the task id from a
691
+ * task-attachment key so a comment on my task's attachment is still mine when
692
+ * the payload carried no channel.
693
+ *
694
+ * @param {string|undefined} fileKey
695
+ * @returns {string} the task id, or "" when the key is a different surface
696
+ */
697
+ export function taskIdFromFileKey(fileKey) {
698
+ const k = typeof fileKey === "string" ? fileKey : "";
699
+ const m = /^task-attachment:([^:]+)/.exec(k);
700
+ return m ? m[1] : "";
701
+ }
702
+
703
+ /** The surface definition for a verdict (or null). */
704
+ export function surfaceDef(name) {
705
+ return (name && SURFACES[name]) || null;
706
+ }
707
+
708
+ export default {
709
+ PRIVATE_ROOM_KINDS,
710
+ MEMBERSHIP_REQUIRED_KINDS,
711
+ classifyEvent,
712
+ resolveDirected,
713
+ coordsOf,
714
+ asSet,
715
+ needsSpaceProbe,
716
+ lookupMessage,
717
+ matchesMyName,
718
+ taskIdFromFileKey,
719
+ surfaceDef,
720
+ };