@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,581 @@
1
+ /**
2
+ * lib/execution/intake.mjs — normalise ANY inbound artefact into `{candidate, verdict}`.
3
+ *
4
+ * `disposition.decide` needs two structured inputs: a Candidate (what happened,
5
+ * in chain coordinates) and a directedness verdict (is it mine, and on which
6
+ * surface). `lib/org/inbound/` produces exactly that pair — but only for org
7
+ * chain events, and only in memory. Four other things reach an agent every day
8
+ * and none of them arrives as that pair:
9
+ *
10
+ * 1. **An inbox item on disk.** `eventToInboxItem` flattens a MessageEvent for
11
+ * the YAML writer and DROPS unknown keys, which is where the projection's
12
+ * `ev.cohort` provenance block dies. Everything the daemon reads back off
13
+ * `state/inbox/**` has therefore already lost its surface and its reason.
14
+ * They are recoverable — `raw_ref` is `cohort:<surface>:<entityId>:<seq>`
15
+ * and survives the flattening — but somebody has to do the recovering.
16
+ *
17
+ * 2. **A channel-adapter event.** Slack, Telegram, SMS, WhatsApp, voice and the
18
+ * org-mail adapter write inbox items directly. They never touch
19
+ * `directedness.resolveDirected`, so nothing has ever asked whether a
20
+ * message in a busy Slack channel was actually addressed to this agent.
21
+ * That is the "responds to everything" failure mode, on the surfaces where
22
+ * the volume actually is.
23
+ *
24
+ * 3. **A local alert.** `lib/diagnostics/alerts.mjs` and `lib/telemetry/alerts.mjs`
25
+ * derive `{id, severity, detail}` records. `emitAlerts` hands them to a sink
26
+ * and that is the end of them — an alert has never been able to become work.
27
+ *
28
+ * 4. **A raw chain event** with no join done yet (an SSE frame, a replay).
29
+ *
30
+ * This module is the one door. Every path returns the SAME shape, is TOTAL (no
31
+ * input throws, no input returns undefined), and reports what it had to guess in
32
+ * `degraded[]` — because a reconstructed verdict is a weaker fact than a
33
+ * resolved one and the journal must be able to tell them apart.
34
+ *
35
+ * PURE. No disk, no network, no clock beyond an injectable `now`. The one import
36
+ * with behaviour is `directedness.mjs`, reused rather than re-implemented — that
37
+ * module is the org join and this module must never grow a second copy of it.
38
+ *
39
+ * @module lib/execution/intake
40
+ */
41
+
42
+ "use strict";
43
+
44
+ import { classifyEvent, resolveDirected } from "../org/inbound/directedness.mjs";
45
+ import { SURFACES } from "../org/inbound/surfaces.mjs";
46
+ import { EXTENDED_SURFACE_POLICY } from "./surface-policy.mjs";
47
+
48
+ /** Where a normalised pair came from. Stamped on the result for the journal. */
49
+ export const INTAKE_ORIGINS = Object.freeze([
50
+ "pair",
51
+ "message_event",
52
+ "inbox_item",
53
+ "channel_event",
54
+ "alert",
55
+ "raw_event",
56
+ ]);
57
+
58
+ /**
59
+ * surface → hq chain family. `dedupeKey` and `threadKey` are built from
60
+ * `family`, so a surface with the wrong family silently shares a key space with
61
+ * another one. Extended surfaces use their own protocol family name where one
62
+ * exists (`calendar`) and their surface name where it does not (`alert`).
63
+ */
64
+ export const SURFACE_FAMILY = Object.freeze({
65
+ dm: "messaging",
66
+ mention: "messaging",
67
+ thread_reply: "messaging",
68
+ call: "calling",
69
+ task_assigned: "board",
70
+ task_comment: "board",
71
+ file_comment: "file",
72
+ doc_comment: "files",
73
+ approval: "approval",
74
+ decision: "decision",
75
+ escalation: "escalation",
76
+ handoff: "handoff",
77
+ email: "email",
78
+ calendar: "calendar",
79
+ alert: "alert",
80
+ mandate: "mandate",
81
+ });
82
+
83
+ /** surface → coarse topic, taken from the inbound table where it defines one. */
84
+ export const SURFACE_TOPIC = Object.freeze({
85
+ ...Object.fromEntries(Object.entries(SURFACES).map(([name, def]) => [name, def.topic])),
86
+ calendar: "calendar",
87
+ alert: "alert",
88
+ mandate: "mandate",
89
+ });
90
+
91
+ /**
92
+ * MessageEvent `kind` → surface, for items whose `raw_ref` is missing or
93
+ * unparseable. Lossy in one place and knowingly so: the projection maps BOTH
94
+ * `file_comment` and `doc_comment` onto kind `file_comment`, so a kind-only
95
+ * reconstruction lands on `file_comment`. Their policy rows are identical, so
96
+ * the disposition is the same either way; only the label in the journal differs,
97
+ * and `degraded[]` records that it was inferred.
98
+ */
99
+ export const KIND_SURFACE = Object.freeze({
100
+ message: "dm",
101
+ mention: "mention",
102
+ thread_reply: "thread_reply",
103
+ call: "call",
104
+ voice_note: "dm",
105
+ task_assigned: "task_assigned",
106
+ task_comment: "task_comment",
107
+ file_comment: "file_comment",
108
+ approval: "approval",
109
+ decision: "decision",
110
+ escalation: "escalation",
111
+ handoff: "handoff",
112
+ email: "email",
113
+ calendar: "calendar",
114
+ mandate: "mandate",
115
+ alert: "alert",
116
+ });
117
+
118
+ /**
119
+ * The directedness reason to reconstruct per surface when the real one was lost.
120
+ *
121
+ * This is only ever applied to an item that ALREADY passed the join — an org
122
+ * inbox item exists because `pullWideInbound` decided it was directed and wrote
123
+ * it. So reconstructing `directed:true` is sound; what is genuinely unknown is
124
+ * WHICH rule fired, and the reason only feeds the T0/T1 tier. The values below
125
+ * are the reason that surface is normally delivered for. `.mentions_agent` on
126
+ * the item overrides with `named` where it is set, which is the one signal the
127
+ * flattening does preserve.
128
+ */
129
+ export const RECONSTRUCTED_REASON = Object.freeze({
130
+ dm: "dm",
131
+ mention: "mention",
132
+ thread_reply: "thread",
133
+ call: "invited",
134
+ task_assigned: "assignee",
135
+ task_comment: "owner",
136
+ file_comment: "shared",
137
+ doc_comment: "shared",
138
+ approval: "approver",
139
+ decision: "proposer",
140
+ escalation: "waiting_on",
141
+ handoff: "direct",
142
+ email: "direct",
143
+ calendar: "attendee",
144
+ mandate: "direct",
145
+ alert: "direct",
146
+ });
147
+
148
+ /** Alert severities that are worth waking an agent for. Anything else is noise. */
149
+ export const ACTIONABLE_SEVERITIES = Object.freeze(["critical", "warning", "warn", "error"]);
150
+
151
+ /** Services whose inbound is mail, not chat. */
152
+ const MAIL_SERVICES = Object.freeze(["orgmail", "gmail", "email", "imap", "smtp"]);
153
+
154
+ /** Blank-safe string. */
155
+ function s(v) {
156
+ return v == null ? "" : String(v);
157
+ }
158
+
159
+ /** True for a plain object. */
160
+ function isObj(v) {
161
+ return !!v && typeof v === "object" && !Array.isArray(v);
162
+ }
163
+
164
+ /** A surface name this layer has a policy for (org table or extended table). */
165
+ export function isKnownSurface(name) {
166
+ const k = s(name);
167
+ return (
168
+ Object.prototype.hasOwnProperty.call(SURFACES, k) ||
169
+ Object.prototype.hasOwnProperty.call(EXTENDED_SURFACE_POLICY, k)
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Parse the projection's `raw_ref`: `cohort:<surface>:<entityId>:<seq>`.
175
+ *
176
+ * Tolerant of an entity id containing colons (it splits from BOTH ends), and
177
+ * returns null for the legacy `<channel>:<chatId>:<messageId>` form a channel
178
+ * adapter writes — that one is handled by `fromChannelEvent`.
179
+ *
180
+ * @param {string} raw
181
+ * @returns {{surface:string, entityId:string, seq:string|null}|null}
182
+ */
183
+ export function parseRawRef(raw) {
184
+ const str = s(raw);
185
+ if (!str.startsWith("cohort:")) return null;
186
+ const rest = str.slice("cohort:".length);
187
+ const firstColon = rest.indexOf(":");
188
+ if (firstColon < 0) return null;
189
+ const surface = rest.slice(0, firstColon);
190
+ if (!isKnownSurface(surface)) return null;
191
+ const tail = rest.slice(firstColon + 1);
192
+ const lastColon = tail.lastIndexOf(":");
193
+ if (lastColon < 0) return { surface, entityId: tail, seq: null };
194
+ return {
195
+ surface,
196
+ entityId: tail.slice(0, lastColon),
197
+ seq: tail.slice(lastColon + 1) || null,
198
+ };
199
+ }
200
+
201
+ /** Assemble a normalised intake result. Internal; every path funnels through it. */
202
+ function out(origin, candidate, verdict, degraded, why) {
203
+ return {
204
+ origin,
205
+ candidate: candidate || null,
206
+ verdict: verdict || null,
207
+ degraded: degraded.slice(),
208
+ why: why.slice(),
209
+ };
210
+ }
211
+
212
+ /** A verdict that says "not mine", with the surfaces it might plausibly have been. */
213
+ function undirected(reason) {
214
+ return { directed: false, reason: s(reason) || "not_directed", surface: null };
215
+ }
216
+
217
+ /**
218
+ * Build a Candidate from flat inbox-item fields. Shared by the org and channel
219
+ * paths so the two cannot drift into producing different key spaces for the same
220
+ * conversation.
221
+ */
222
+ function candidateFrom(o) {
223
+ return {
224
+ seq: o.seq ?? null,
225
+ family: s(o.family) || "?",
226
+ kind: s(o.kind) || "?",
227
+ entityId: o.entityId == null ? null : s(o.entityId),
228
+ actor: s(o.actor),
229
+ at: s(o.at),
230
+ topic: s(o.topic),
231
+ surfaces: Array.isArray(o.surfaces) ? o.surfaces.slice() : [],
232
+ ids: isObj(o.ids) ? { ...o.ids } : {},
233
+ directTo: Array.isArray(o.directTo) ? o.directTo.map(String) : [],
234
+ payload: isObj(o.payload) ? o.payload : {},
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Pass through a pair the inbound layer already produced. The only path with no
240
+ * reconstruction at all, and therefore the one to prefer everywhere it is
241
+ * available (i.e. in-process, at pull time).
242
+ */
243
+ export function fromPair(input) {
244
+ const why = ["pair supplied by the inbound layer — no reconstruction needed"];
245
+ return out("pair", input.candidate, input.verdict, [], why);
246
+ }
247
+
248
+ /**
249
+ * Rebuild the pair from a projected MessageEvent — the shape
250
+ * `lib/org/inbound/project.toMessageEvent` returns, BEFORE `eventToInboxItem`
251
+ * flattens it. Its `ev.cohort` block carries surface, reason, family, kind, seq
252
+ * and entity id verbatim, so this path is lossless too.
253
+ */
254
+ export function fromMessageEvent(ev, o = {}) {
255
+ const why = [];
256
+ const c = isObj(ev.cohort) ? ev.cohort : {};
257
+ const surface = s(c.surface);
258
+ if (!isKnownSurface(surface)) {
259
+ why.push(`projected event names surface "${surface || "(none)"}" which has no policy row`);
260
+ return out("message_event", null, undirected("unknown_surface"), ["unknown_surface"], why);
261
+ }
262
+ const ids = {};
263
+ if (ev.channel_id) ids.channelId = s(ev.channel_id);
264
+ if (ev.thread_id) ids.threadId = s(ev.thread_id);
265
+ if (ev.message_id) ids.messageId = s(ev.message_id);
266
+ if (c.entity_id) ids.entityId = s(c.entity_id);
267
+
268
+ why.push(`projected MessageEvent carries full provenance: ${surface} via ${s(c.reason)}`);
269
+ const candidate = candidateFrom({
270
+ seq: c.seq ?? null,
271
+ family: s(c.family) || SURFACE_FAMILY[surface],
272
+ kind: s(c.event_kind) || s(ev.kind),
273
+ entityId: c.entity_id ?? ev.message_id ?? null,
274
+ actor: s(ev.from && ev.from.id) || s(ev.sender),
275
+ at: s(ev.timestamp) || s(o.now),
276
+ topic: s(c.topic) || SURFACE_TOPIC[surface],
277
+ surfaces: [surface],
278
+ ids,
279
+ directTo: s(c.me) ? [s(c.me)] : [],
280
+ payload: {},
281
+ });
282
+ return out("message_event", candidate, { directed: true, surface, reason: s(c.reason) || RECONSTRUCTED_REASON[surface] }, [], why);
283
+ }
284
+
285
+ /**
286
+ * Rebuild the pair from an ORG inbox item read back off disk.
287
+ *
288
+ * The item exists BECAUSE the join already said it was directed — `pullWideInbound`
289
+ * drops everything else before projection. So this path reconstructs
290
+ * `directed:true` and says so in `degraded[]`; what it cannot recover is which
291
+ * rule fired, and the tier that implies.
292
+ */
293
+ export function fromInboxItem(item, o = {}) {
294
+ const why = [];
295
+ const degraded = [];
296
+ const ref = parseRawRef(item.raw_ref);
297
+ let surface = ref ? ref.surface : null;
298
+
299
+ if (surface) {
300
+ why.push(`raw_ref names surface ${surface}`);
301
+ } else {
302
+ const kind = s(item.kind) || "message";
303
+ surface = KIND_SURFACE[kind] || null;
304
+ if (surface === "dm" && item.is_dm === false) {
305
+ // kind "message" is the DM projection, but a channel item that lost its
306
+ // raw_ref would land here too. `is_dm:false` is a positive assertion that
307
+ // it was not a DM, so honour it rather than forging one.
308
+ surface = "mention";
309
+ why.push(`kind "${kind}" with is_dm:false → treating as a space mention, not a DM`);
310
+ } else {
311
+ why.push(`no parseable raw_ref — inferring surface ${surface || "(none)"} from kind "${kind}"`);
312
+ }
313
+ degraded.push("surface_inferred_from_kind");
314
+ }
315
+
316
+ if (!isKnownSurface(surface)) {
317
+ why.push("could not establish a surface for this item");
318
+ return out("inbox_item", null, undirected("unknown_surface"), degraded.concat("unknown_surface"), why);
319
+ }
320
+
321
+ const mentions = !!(item.priority_signals && item.priority_signals.mentions_agent);
322
+ let reason = RECONSTRUCTED_REASON[surface] || "direct";
323
+ if (mentions && (reason === "thread" || reason === "shared" || reason === "owner")) {
324
+ // The one signal the flattening preserves, and it upgrades a lane-tier
325
+ // reason to a structural one — being named IS the strongest reason there is.
326
+ reason = "named";
327
+ why.push("priority_signals.mentions_agent → the item names me outright (T0)");
328
+ }
329
+ degraded.push(`verdict_reconstructed:${surface}`);
330
+ why.push(`org inbox item: reconstructing directed:true (it was written because the join said so), reason ${reason}`);
331
+
332
+ const ids = {};
333
+ if (item.channel_id) ids.channelId = s(item.channel_id);
334
+ if (item.thread_id) ids.threadId = s(item.thread_id);
335
+ if (ref && ref.entityId) ids.entityId = ref.entityId;
336
+ if (item.id) ids.messageId = s(item.id);
337
+
338
+ const candidate = candidateFrom({
339
+ seq: ref ? ref.seq : null,
340
+ family: SURFACE_FAMILY[surface],
341
+ kind: s(item.kind) || surface,
342
+ entityId: ref ? ref.entityId : s(item.id) || null,
343
+ actor: s(item.sender_id) || s(item.sender),
344
+ at: s(item.timestamp) || s(o.now),
345
+ topic: SURFACE_TOPIC[surface],
346
+ surfaces: [surface],
347
+ ids,
348
+ directTo: o.me ? [s(o.me)] : [],
349
+ payload: {},
350
+ });
351
+ return out("inbox_item", candidate, { directed: true, surface, reason }, degraded, why);
352
+ }
353
+
354
+ /**
355
+ * Establish directedness for a NON-org channel item (Slack, Telegram, SMS,
356
+ * WhatsApp, voice, org-mail).
357
+ *
358
+ * This is the path with no join behind it at all, so it is the one that has to
359
+ * be honest about the negative case. A message in a shared channel that neither
360
+ * names the agent nor sits in a thread it is part of is NOT directed — it gets
361
+ * an undirected verdict and the ladder parks or drops it. That is the entire
362
+ * difference between an agent that participates and one that interrupts.
363
+ */
364
+ export function fromChannelEvent(item, o = {}) {
365
+ const why = [];
366
+ const degraded = [];
367
+ const service = s(item.service) || s(item.channel) || s(item.source) || "channel";
368
+ const kind = s(item.kind) || "message";
369
+ const mentions = !!(item.priority_signals && item.priority_signals.mentions_agent);
370
+ const isDm = item.is_dm === true || (item.is_private === true && s(item.channel_type) === "im");
371
+ const mail = MAIL_SERVICES.includes(service.toLowerCase());
372
+
373
+ let surface = null;
374
+ let reason = null;
375
+
376
+ if (mail) {
377
+ // Mail is addressed by construction: it arrived in this agent's mailbox.
378
+ surface = "email";
379
+ reason = "direct";
380
+ why.push(`${service} is a mailbox service — an item in it is addressed to me`);
381
+ } else if (kind === "call" || kind === "voice_note") {
382
+ surface = kind === "call" ? "call" : "dm";
383
+ reason = "invited";
384
+ why.push(`${service} ${kind} → ${surface}`);
385
+ } else if (isDm) {
386
+ surface = "dm";
387
+ reason = "dm";
388
+ why.push(`${service} private room → a DM is addressed to me by construction`);
389
+ } else if (mentions || kind === "mention") {
390
+ surface = "mention";
391
+ reason = "mention";
392
+ why.push(`${service} channel item names me`);
393
+ } else if (kind === "thread_reply" || (item.is_reply === true && s(item.thread_id))) {
394
+ // A reply in a thread. Whether the agent is IN that thread is a fact only
395
+ // the adapter has; without it this is a lane-tier claim at best.
396
+ surface = "thread_reply";
397
+ reason = "thread";
398
+ degraded.push("thread_membership_unverified");
399
+ why.push(`${service} thread reply — membership unverified, treating as lane tier`);
400
+ } else {
401
+ // THE ignore case, and by volume the common one on a chat platform.
402
+ why.push(
403
+ `${service} channel item does not name me, is not a DM and is not a reply in my thread → not directed`,
404
+ );
405
+ const candidate = channelCandidate(item, service, kind, ["mention", "thread_reply"], o);
406
+ return out("channel_event", candidate, undirected("ambient_channel"), degraded, why);
407
+ }
408
+
409
+ const candidate = channelCandidate(item, service, kind, [surface], o);
410
+ return out("channel_event", candidate, { directed: true, surface, reason }, degraded, why);
411
+ }
412
+
413
+ /** Candidate for a channel item. Family is the SERVICE, so key spaces stay separate. */
414
+ function channelCandidate(item, service, kind, surfaces, o) {
415
+ const ids = {};
416
+ if (item.channel_id) ids.channelId = s(item.channel_id);
417
+ if (item.thread_id) ids.threadId = s(item.thread_id);
418
+ if (item.id) ids.messageId = s(item.id);
419
+ return candidateFrom({
420
+ seq: null,
421
+ family: service,
422
+ kind,
423
+ entityId: s(item.id) || null,
424
+ actor: s(item.sender_id) || s(item.sender),
425
+ at: s(item.timestamp) || s(o.now),
426
+ topic: SURFACE_TOPIC[surfaces[0]] || "message",
427
+ surfaces,
428
+ ids,
429
+ directTo: [],
430
+ payload: {},
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Turn a derived alert (`{id, severity, detail}`) into a decidable event.
436
+ *
437
+ * Directed by construction — an alert about this agent is addressed to this
438
+ * agent — EXCEPT below the actionable severity floor. An `info` alert that woke
439
+ * a session would be the "responds to everything" failure in its purest form.
440
+ */
441
+ export function fromAlert(alert, o = {}) {
442
+ const why = [];
443
+ const id = s(alert.id) || s(alert.code) || "alert";
444
+ const severity = s(alert.severity).toLowerCase();
445
+ const candidate = candidateFrom({
446
+ seq: null,
447
+ family: "alert",
448
+ kind: id,
449
+ entityId: id,
450
+ actor: "system",
451
+ at: s(alert.at) || s(o.now),
452
+ topic: "alert",
453
+ surfaces: ["alert"],
454
+ // The alert id is the thread: repeated firings of the SAME alert must
455
+ // collapse onto one chain, or a flapping check becomes a task storm.
456
+ ids: { alertId: id },
457
+ directTo: o.me ? [s(o.me)] : [],
458
+ payload: { severity, detail: s(alert.detail) },
459
+ });
460
+
461
+ if (!ACTIONABLE_SEVERITIES.includes(severity)) {
462
+ why.push(`alert ${id} is severity "${severity || "(none)"}" — below the actionable floor, record and move on`);
463
+ return out("alert", candidate, undirected("alert_below_threshold"), [], why);
464
+ }
465
+ why.push(`alert ${id} is ${severity} and is about this agent → directed`);
466
+ return out("alert", candidate, { directed: true, surface: "alert", reason: "direct" }, [], why);
467
+ }
468
+
469
+ /**
470
+ * Classify and join a RAW chain event. Reuses `directedness.mjs` rather than
471
+ * re-implementing it; the caller supplies whatever `facts` it has resolved.
472
+ *
473
+ * With no facts, only payload-named directedness can resolve — which is honest
474
+ * and is recorded, not hidden: a raw event joined without facts under-reports
475
+ * membership-based surfaces, and `degraded[]` says so.
476
+ */
477
+ export function fromRawEvent(ev, o = {}) {
478
+ const why = [];
479
+ const degraded = [];
480
+ let candidate = null;
481
+ try {
482
+ candidate = classifyEvent(ev);
483
+ } catch (err) {
484
+ why.push(`classifier threw on this row: ${err && err.message ? err.message : String(err)}`);
485
+ return out("raw_event", null, undirected("classifier_error"), ["classifier_error"], why);
486
+ }
487
+ if (!candidate) {
488
+ why.push("raw event is not one of the families an agent is woken for");
489
+ return out("raw_event", null, undirected("not_classified"), [], why);
490
+ }
491
+ if (!isObj(o.facts)) {
492
+ degraded.push("joined_without_facts");
493
+ why.push("no facts supplied — only payload-named directedness can resolve on this event");
494
+ }
495
+ let verdict;
496
+ try {
497
+ verdict = resolveDirected(candidate, s(o.me), isObj(o.facts) ? o.facts : {}, o.opts || {});
498
+ } catch (err) {
499
+ why.push(`directedness join threw: ${err && err.message ? err.message : String(err)}`);
500
+ return out("raw_event", candidate, undirected("join_error"), degraded.concat("join_error"), why);
501
+ }
502
+ why.push(
503
+ verdict && verdict.directed
504
+ ? `join: directed on ${verdict.surface} via ${verdict.reason}`
505
+ : `join: not directed (${(verdict && verdict.reason) || "unknown"})`,
506
+ );
507
+ return out("raw_event", candidate, verdict, degraded, why);
508
+ }
509
+
510
+ /**
511
+ * Detect what `input` is and normalise it. TOTAL: every input, including null,
512
+ * a string, and a shape from a future version of some other module, produces a
513
+ * result object — never a throw and never `undefined`.
514
+ *
515
+ * Detection order is by decreasing certainty, so a richer shape is never
516
+ * demoted to a poorer reading of itself.
517
+ *
518
+ * @param {object} input
519
+ * @param {object} [o] { me, now, facts, opts }
520
+ * @returns {{origin:string, candidate:object|null, verdict:object|null, degraded:string[], why:string[]}}
521
+ */
522
+ export function intake(input, o = {}) {
523
+ if (!isObj(input)) {
524
+ return out("raw_event", null, undirected("not_an_object"), ["not_an_object"], [
525
+ `intake received ${input === null ? "null" : typeof input} — nothing to decide`,
526
+ ]);
527
+ }
528
+ try {
529
+ // 1. Already joined.
530
+ if (isObj(input.candidate) && isObj(input.verdict)) return fromPair(input);
531
+ // 2. A projected MessageEvent (pre-flattening) — carries `cohort` provenance.
532
+ if (isObj(input.cohort) && input.cohort.surface) return fromMessageEvent(input, o);
533
+ // 3. A derived alert: `{id|code, severity}` and none of the item fields.
534
+ if ((input.id || input.code) && input.severity && input.content === undefined && input.text === undefined) {
535
+ return fromAlert(input, o);
536
+ }
537
+ // 4. A raw chain row: family/kind coordinates and no inbox-item shape.
538
+ if ((input.family || (typeof input.kind === "string" && input.kind.includes("."))) && input.content === undefined) {
539
+ return fromRawEvent(input, o);
540
+ }
541
+ // 5. An inbox item. Cohort-sourced ones keep their raw_ref provenance;
542
+ // everything else is a channel adapter's and needs a real join.
543
+ const cohortSourced =
544
+ s(input.raw_ref).startsWith("cohort:") ||
545
+ s(input.service).toLowerCase() === "cohort" ||
546
+ s(input.source).toLowerCase() === "cohort" ||
547
+ s(input.ingest_source).toLowerCase() === "cohort";
548
+ if (cohortSourced) return fromInboxItem(input, o);
549
+ if (input.content !== undefined || input.text !== undefined || input.service || input.channel) {
550
+ return fromChannelEvent(input, o);
551
+ }
552
+ return out("raw_event", null, undirected("unrecognised_shape"), ["unrecognised_shape"], [
553
+ "intake could not recognise this object as an event, an item or an alert",
554
+ ]);
555
+ } catch (err) {
556
+ // NEVER SILENT, and never fatal: an intake bug must degrade one event, not
557
+ // take the sweep down. The reason string names the throw so it is greppable.
558
+ const msg = err && err.message ? err.message : String(err);
559
+ return out("raw_event", null, undirected("intake_error"), [`intake_error:${msg}`], [
560
+ `intake threw while normalising this input: ${msg}`,
561
+ ]);
562
+ }
563
+ }
564
+
565
+ export default {
566
+ intake,
567
+ fromPair,
568
+ fromMessageEvent,
569
+ fromInboxItem,
570
+ fromChannelEvent,
571
+ fromAlert,
572
+ fromRawEvent,
573
+ parseRawRef,
574
+ isKnownSurface,
575
+ INTAKE_ORIGINS,
576
+ SURFACE_FAMILY,
577
+ SURFACE_TOPIC,
578
+ KIND_SURFACE,
579
+ RECONSTRUCTED_REASON,
580
+ ACTIONABLE_SEVERITIES,
581
+ };