@cohortapp/agent-sdk 2.5.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/bin/maestro.mjs +305 -89
  2. package/bin/maestro.test.mjs +357 -48
  3. package/docs/runbooks/backup-restore.md +65 -33
  4. package/framework-features.json +4 -4
  5. package/lib/backup/policy.mjs +710 -0
  6. package/lib/backup/policy.test.mjs +305 -0
  7. package/lib/budget-escalate.mjs +133 -0
  8. package/lib/budget-escalate.test.mjs +232 -0
  9. package/lib/budget-guard.envelope.test.mjs +476 -0
  10. package/lib/budget-guard.mjs +853 -75
  11. package/lib/budget-guard.test.mjs +91 -42
  12. package/lib/cadences.mjs +33 -0
  13. package/lib/channels/orgmail/adapter.mjs +88 -3
  14. package/lib/channels/orgmail/adapter.test.mjs +137 -0
  15. package/lib/channels/repeat-suppressor.mjs +198 -0
  16. package/lib/channels/repeat-suppressor.test.mjs +134 -0
  17. package/lib/comms/receipts.mjs +297 -0
  18. package/lib/cost/ledger-row.mjs +333 -0
  19. package/lib/cost/ledger-row.test.mjs +183 -0
  20. package/lib/execution/drive.mjs +28 -1
  21. package/lib/execution/effects.mjs +191 -12
  22. package/lib/execution/effects.test.mjs +50 -11
  23. package/lib/goals/admission.mjs +13 -1
  24. package/lib/goals/admission.test.mjs +26 -1
  25. package/lib/goals/loop.mjs +13 -0
  26. package/lib/kpi-sensors.test.mjs +3 -0
  27. package/lib/mandate/cache.mjs +13 -5
  28. package/lib/mandate/derive.mjs +146 -21
  29. package/lib/mandate/derive.test.mjs +50 -6
  30. package/lib/mandate/model.mjs +32 -4
  31. package/lib/mandate/refresh.test.mjs +16 -2
  32. package/lib/mcp/server.test.mjs +12 -3
  33. package/lib/model-router/economics.mjs +107 -76
  34. package/lib/model-router/economics.test.mjs +64 -46
  35. package/lib/model-router/integration-coverage.test.mjs +39 -37
  36. package/lib/model-router/ledger.mjs +75 -22
  37. package/lib/model-router/ledger.test.mjs +35 -2
  38. package/lib/org/client.mjs +14 -0
  39. package/lib/org/cost-sync.mjs +16 -2
  40. package/lib/org/doctor.mjs +62 -1
  41. package/lib/org/doctor.test.mjs +36 -3
  42. package/lib/org/email-remedy.mjs +49 -0
  43. package/lib/org/engagement-ledger.mjs +376 -0
  44. package/lib/org/engagement-ledger.test.mjs +112 -0
  45. package/lib/org/engagement.mjs +1056 -0
  46. package/lib/org/engagement.test.mjs +739 -0
  47. package/lib/org/messaging.mjs +230 -3
  48. package/lib/org/messaging.test.mjs +110 -1
  49. package/lib/org/param-contract.mjs +56 -2
  50. package/lib/org/param-contract.test.mjs +26 -0
  51. package/lib/org/protocol.checksum +1 -1
  52. package/lib/org/protocol.mjs +5 -0
  53. package/lib/org/protocol.test.mjs +7 -1
  54. package/lib/org/tool-surface.mjs +506 -10
  55. package/lib/org/tool-surface.test.mjs +191 -7
  56. package/lib/org/ui-parity.mjs +333 -6
  57. package/lib/org/ui-parity.test.mjs +96 -3
  58. package/lib/org/work-ledger.mjs +241 -0
  59. package/lib/org/work-ledger.test.mjs +237 -0
  60. package/lib/plan/adoption-e2e.test.mjs +366 -0
  61. package/lib/plan/budget-enforcement.test.mjs +400 -0
  62. package/lib/plan/budget-runtime.mjs +215 -0
  63. package/lib/plan/compile.mjs +201 -5
  64. package/lib/plan/compile.test.mjs +19 -5
  65. package/lib/plan/emit.mjs +8 -0
  66. package/lib/plan/emit.test.mjs +18 -0
  67. package/lib/resource-governor.mjs +58 -12
  68. package/lib/resource-governor.test.mjs +41 -1
  69. package/lib/security/audit-engine.mjs +45 -8
  70. package/lib/security/audit-engine.test.mjs +35 -0
  71. package/lib/setup/enroll-from-cohort.mjs +14 -1
  72. package/lib/setup/sections/mandate.mjs +48 -7
  73. package/lib/setup/sections/mandate.test.mjs +17 -2
  74. package/lib/setup/sections/orgmail.mjs +10 -2
  75. package/lib/setup/state.mjs +83 -2
  76. package/lib/telemetry/collect.mjs +360 -20
  77. package/lib/telemetry/collect.test.mjs +266 -0
  78. package/package.json +1 -1
  79. package/scripts/cost/track-claude-usage.mjs +207 -48
  80. package/scripts/cost/track-claude-usage.test.mjs +148 -0
  81. package/scripts/daemon/agent-daemon.mjs +315 -17
  82. package/scripts/daemon/assurance-e2e.test.mjs +421 -0
  83. package/scripts/daemon/assurance.mjs +944 -0
  84. package/scripts/daemon/assurance.test.mjs +668 -0
  85. package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
  86. package/scripts/daemon/cadence-consumer.mjs +147 -9
  87. package/scripts/daemon/cadence-consumer.test.mjs +6 -0
  88. package/scripts/daemon/cadence-handlers.mjs +158 -0
  89. package/scripts/daemon/cadence-handlers.test.mjs +64 -0
  90. package/scripts/daemon/classifier.test.mjs +18 -9
  91. package/scripts/daemon/deliver.mjs +314 -0
  92. package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
  93. package/scripts/daemon/dispatcher.mjs +64 -6
  94. package/scripts/daemon/responder-cost.test.mjs +68 -0
  95. package/scripts/daemon/responder.mjs +351 -298
  96. package/scripts/local-triggers/generate-plists.test.mjs +7 -4
  97. package/scripts/maintenance/backup-run.mjs +415 -0
  98. package/scripts/maintenance/backup-to-cloud.sh +16 -116
  99. package/scripts/org/send-orgmail.mjs +16 -0
  100. package/scripts/record-receipt.sh +63 -0
  101. package/scripts/restore-from-backup.sh +14 -3
  102. package/scripts/restore-from-backup.test.mjs +8 -5
  103. package/scripts/send-email-threaded.py +47 -0
  104. package/scripts/send-sms.sh +4 -0
  105. package/scripts/send-whatsapp.sh +4 -0
  106. package/scripts/setup/init-backup.mjs +93 -38
  107. package/scripts/slack-send.sh +12 -0
@@ -0,0 +1,1056 @@
1
+ /**
2
+ * lib/org/engagement.mjs — the judgement to bring the right people in, and the
3
+ * means to do it.
4
+ *
5
+ * ── THE PROBLEM THIS SOLVES ──
6
+ * An agent that never engages anyone dies quietly: it hits a blocker only a
7
+ * human can clear, records the fact in a journal nobody reads, and the requester
8
+ * hears nothing until they chase. That is the observed failure — the execution
9
+ * ladder's own journal shows nine `escalate` decisions, every one with
10
+ * `spoke: false`, because raising an OpenQuestion row is not the same as
11
+ * speaking to a person.
12
+ *
13
+ * An agent that engages everyone is worse. It is trivially easy to build: any
14
+ * "who might care about this?" heuristic will happily produce five names for
15
+ * every task, and within a week every human in the org filters it. A filtered
16
+ * channel cannot be unfiltered.
17
+ *
18
+ * So the whole difficulty is the THRESHOLD, and this module's job is to state it
19
+ * explicitly, apply it mechanically, and record the answer either way.
20
+ *
21
+ * ── THE THRESHOLD ──
22
+ *
23
+ * Engage exactly when a SPECIFIC NAMED PERSON'S action or consent is a
24
+ * PRECONDITION for the work to proceed or to be safe, and the agent cannot
25
+ * supply it itself.
26
+ *
27
+ * Three words do the work:
28
+ *
29
+ * PRECONDITION — not "useful input", not "they'd want to know". If the work
30
+ * can move without them, it moves without them. This is the clause that
31
+ * excludes the entire FYI class, which is where the flood comes from.
32
+ * SPECIFIC NAMED — if the judgement cannot name who, it has not identified a
33
+ * blocker, it has identified an anxiety. Broadcasting an anxiety to a
34
+ * channel is how "engage the right people" becomes "engage everyone".
35
+ * CANNOT SUPPLY ITSELF — an agent that asks a human for something it could
36
+ * have looked up has outsourced its own job. `selfServeExhausted` is a
37
+ * required input for the blocked trigger precisely so this cannot be
38
+ * skipped by accident.
39
+ *
40
+ * Everything the threshold excludes has somewhere else to go. FYI belongs on the
41
+ * board (a comment, an attachment, a status change) — PULL, not push. That is
42
+ * the trade being made: the board absorbs "should know", messaging is reserved
43
+ * for "must act". If the board is doing its job, the engagement bar can stay
44
+ * this high without anybody being left in the dark.
45
+ *
46
+ * ── FOUR TRIGGERS, EACH A NECESSITY TEST ──
47
+ *
48
+ * blocked_on_other the work has stopped and the thing unblocking it is
49
+ * owned by someone else (a decision, an access grant,
50
+ * information only they hold). Requires
51
+ * `selfServeExhausted`.
52
+ * irreversible_or_external the next action leaves the org's boundary, spends
53
+ * money, or cannot be undone. Consent is required
54
+ * BEFORE, because "ask forgiveness" is not available
55
+ * for irreversible acts.
56
+ * mandatory_review a policy or role requires a second pair of eyes
57
+ * before the artefact lands.
58
+ * commitment_at_risk a promise made to a named person will be missed, or
59
+ * the run failed. They are about to act on stale
60
+ * information. This is the owner's actual complaint —
61
+ * silence after a failure — and it is a trigger, not a
62
+ * courtesy.
63
+ *
64
+ * ── AND AN EXPLICIT "NOBODY" ──
65
+ * Every non-engagement is an OUTCOME with a reason code, appended to the ledger:
66
+ * `self_contained`, `self_serve_available`, `fyi_only`, `already_engaged`,
67
+ * `rate_limited_*`, `unroutable`, `send_blocked`. Silence is never the default
68
+ * branch; it is a decision that has to be written down like any other. Engaging
69
+ * nobody when someone is blocked is the failure being fixed — so it must be
70
+ * possible to point at a row and say the agent considered it and declined.
71
+ *
72
+ * ── SHAPE ──
73
+ * `decideEngagement` is PURE: work + context in, decision out, no clock of its
74
+ * own, no disk, no network. `runEngagement` is the only effectful half, and it
75
+ * routes through `messaging.sendMessage`, which runs the shared send-gate — the
76
+ * gate is never bypassed, and a gate refusal is recorded as `send_blocked`
77
+ * rather than dropped.
78
+ *
79
+ * The message body is a TEMPLATE, deliberately. The holding-message path in the
80
+ * daemon spawns a `claude --print` child to write one courtesy sentence and
81
+ * loses that race two times in three; an engagement that only fires when an LLM
82
+ * spawn wins a 60s race is an engagement that does not fire. A template cannot
83
+ * time out.
84
+ *
85
+ * @module lib/org/engagement
86
+ */
87
+
88
+ "use strict";
89
+
90
+ import {
91
+ DEFAULT_LIMITS,
92
+ dedupeKeyFor,
93
+ loadEngagements,
94
+ appendEngagement,
95
+ checkGuardrails,
96
+ } from "./engagement-ledger.mjs";
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Vocabulary
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** The four things that make another person a precondition. */
103
+ export const TRIGGERS = Object.freeze([
104
+ "irreversible_or_external",
105
+ "blocked_on_other",
106
+ "mandatory_review",
107
+ "commitment_at_risk",
108
+ ]);
109
+
110
+ /**
111
+ * Trigger priority, ordered by the COST OF NOT ENGAGING:
112
+ * 1. an unsafe act happens that cannot be taken back
113
+ * 2. the work stops dead
114
+ * 3. the artefact cannot land
115
+ * 4. someone acts on information they don't know is stale
116
+ * The primary trigger decides the framing and the surface; the others still ride
117
+ * along in `why`, so a decision never hides the fact that several things fired.
118
+ */
119
+ const TRIGGER_RANK = Object.freeze({
120
+ irreversible_or_external: 0,
121
+ blocked_on_other: 1,
122
+ mandatory_review: 2,
123
+ commitment_at_risk: 3,
124
+ });
125
+
126
+ /**
127
+ * Blocker kinds that are, by definition, owned by someone else.
128
+ * `capacity` is NOT here: "I am busy" is the agent's own queue problem, and
129
+ * treating it as a blocker is how an agent starts asking humans to do its work.
130
+ * A capacity blocker only qualifies when it names an explicit `ownerId`.
131
+ */
132
+ const OTHER_OWNED_BLOCKERS = Object.freeze(new Set(["decision", "access", "information", "approval"]));
133
+
134
+ /** Surfaces, cheapest first. Cost = people newly exposed + persistence created. */
135
+ export const SURFACES = Object.freeze(["reply_in_place", "dm", "existing_channel", "new_room"]);
136
+
137
+ /** How many people one ask may name. Beyond this you have called a meeting. */
138
+ const MAX_TARGETS = 3;
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Small helpers (pure)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const lc = (v) => (v == null ? "" : String(v).toLowerCase());
145
+
146
+ /** Split a scope phrase into meaningful tokens (stopwords out, ≥3 chars). */
147
+ const STOPWORDS = new Set([
148
+ "the", "and", "for", "with", "from", "that", "this", "into", "our", "are", "was",
149
+ "who", "what", "when", "how", "why", "can", "not", "but", "all", "any", "its",
150
+ ]);
151
+ function tokens(text) {
152
+ return Array.from(
153
+ new Set(
154
+ lc(text)
155
+ .split(/[^a-z0-9]+/)
156
+ .filter((t) => t.length >= 3 && !STOPWORDS.has(t)),
157
+ ),
158
+ );
159
+ }
160
+
161
+ function memberId(m) {
162
+ return String((m && (m.id || m.memberId || m.slug)) || "");
163
+ }
164
+ function memberName(m) {
165
+ return String((m && (m.displayName || m.name || m.slug || m.id)) || "");
166
+ }
167
+
168
+ /** Active, real, not me. A DM to an archived seat is a message to a void. */
169
+ function engageable(m, me) {
170
+ if (!m) return false;
171
+ const id = memberId(m);
172
+ if (!id || id === String(me)) return false;
173
+ const status = lc(m.status);
174
+ if (status && status !== "active" && status !== "online" && status !== "busy") return false;
175
+ return true;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // 1. WHETHER — which necessity test, if any, fires
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Evaluate every trigger against a piece of work. PURE.
184
+ *
185
+ * Returns both what fired and — just as importantly — what nearly fired and
186
+ * why it didn't, so a "nobody" decision can explain itself in the ledger.
187
+ *
188
+ * @param {object} work see module docs / `decideEngagement`
189
+ * @returns {{fired:object[], suppressed:object[]}}
190
+ */
191
+ export function evaluateTriggers(work = {}) {
192
+ const fired = [];
193
+ const suppressed = [];
194
+ const state = lc(work.state) || "in_progress";
195
+ const risk = work.risk || {};
196
+ const blocker = work.blocker || null;
197
+ const review = work.review || null;
198
+ const commitment = work.commitment || null;
199
+
200
+ // ── irreversible / external / financial ──
201
+ // Gated on the act being IMMINENT. Classifying finished work as needing
202
+ // consent is how an agent asks permission for something it already did.
203
+ if (risk.irreversible || risk.external || risk.financial) {
204
+ if (state === "done") {
205
+ suppressed.push({
206
+ trigger: "irreversible_or_external",
207
+ code: "act_already_taken",
208
+ detail: "the risky action is already complete — consent after the fact is theatre; this belongs in the record, not in someone's DMs",
209
+ });
210
+ } else {
211
+ const kinds = ["irreversible", "external", "financial"].filter((k) => risk[k]);
212
+ fired.push({
213
+ trigger: "irreversible_or_external",
214
+ code: `consent_required:${kinds.join("+")}`,
215
+ scope: work.scope || (blocker && blocker.scope) || work.title || "",
216
+ detail: `the next action is ${kinds.join(" and ")} and cannot be taken back`,
217
+ });
218
+ }
219
+ }
220
+
221
+ // ── blocked on another party ──
222
+ if (state === "blocked" || state === "failed") {
223
+ if (!blocker) {
224
+ suppressed.push({
225
+ trigger: "blocked_on_other",
226
+ code: "blocker_unnamed",
227
+ detail: "the work reports blocked but names no blocker — nobody can be asked for an unnamed thing",
228
+ });
229
+ } else {
230
+ const kind = lc(blocker.kind);
231
+ const otherOwned = OTHER_OWNED_BLOCKERS.has(kind) || !!blocker.ownerId;
232
+ if (!otherOwned) {
233
+ suppressed.push({
234
+ trigger: "blocked_on_other",
235
+ code: "blocker_is_mine",
236
+ detail: `blocker kind "${kind || "unknown"}" is the agent's own to clear — asking a human to clear it is outsourcing the work`,
237
+ });
238
+ } else if (work.selfServeExhausted !== true) {
239
+ // THE anti-outsourcing gate. Explicitly requires a positive assertion:
240
+ // an absent flag reads as "hasn't tried", which is the safe reading.
241
+ suppressed.push({
242
+ trigger: "blocked_on_other",
243
+ code: "self_serve_available",
244
+ detail: "the agent has not exhausted what it can do itself — engaging now would hand a human the agent's own next step",
245
+ });
246
+ } else {
247
+ fired.push({
248
+ trigger: "blocked_on_other",
249
+ code: `blocked:${kind || "unspecified"}`,
250
+ scope: blocker.scope || work.scope || work.title || "",
251
+ detail: blocker.detail || `blocked on a ${kind} only another party can give`,
252
+ ownerId: blocker.ownerId || null,
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ // ── mandatory review ──
259
+ if (review && review.required) {
260
+ fired.push({
261
+ trigger: "mandatory_review",
262
+ code: `review_required:${lc(review.kind) || "unspecified"}`,
263
+ scope: review.scope || work.scope || work.title || "",
264
+ detail: review.detail || "a second pair of eyes is required before this lands",
265
+ ownerId: review.reviewerId || null,
266
+ });
267
+ } else if (review && review.requested) {
268
+ // "It'd be nice to have someone look" is not a precondition.
269
+ suppressed.push({
270
+ trigger: "mandatory_review",
271
+ code: "review_optional",
272
+ detail: "review was wanted but is not required — the artefact can land without it",
273
+ });
274
+ }
275
+
276
+ // ── commitment at risk ──
277
+ if (commitment && commitment.toMemberId) {
278
+ if (commitment.atRisk || state === "failed") {
279
+ fired.push({
280
+ trigger: "commitment_at_risk",
281
+ code: state === "failed" ? "commitment_failed" : "commitment_slipping",
282
+ scope: work.scope || work.title || "",
283
+ detail:
284
+ state === "failed"
285
+ ? "the run failed and the person who asked is still waiting — silence here is the exact failure being fixed"
286
+ : "a promise made to a named person will be missed",
287
+ ownerId: commitment.toMemberId,
288
+ });
289
+ } else {
290
+ suppressed.push({
291
+ trigger: "commitment_at_risk",
292
+ code: "commitment_on_track",
293
+ detail: "the commitment is on track — a progress ping nobody asked for is noise",
294
+ });
295
+ }
296
+ }
297
+
298
+ fired.sort((a, b) => (TRIGGER_RANK[a.trigger] ?? 9) - (TRIGGER_RANK[b.trigger] ?? 9));
299
+ return { fired, suppressed };
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // 2. WHO — resolved from the directory, by a ladder, never by guesswork
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Resolve who to engage. PURE over the injected directory.
308
+ *
309
+ * Four rungs, first hit wins, and the rung is recorded on every target so a
310
+ * reader can tell "the work named them" from "we matched two keywords against a
311
+ * job title". They are not the same claim and must not look the same.
312
+ *
313
+ * explicit — the work names them (blocker owner, named reviewer, the person
314
+ * owed the commitment, the requester). Highest confidence: this
315
+ * is not inference at all.
316
+ * ownership — the directory says they own the scope (`owns[]`, or an
317
+ * escalation path / team that names it exactly).
318
+ * expertise — role title / tagline / team overlaps the scope. Requires TWO
319
+ * distinct token matches, or one token of ≥5 characters. One
320
+ * short keyword ("api", "ops") matching a job title is a
321
+ * coincidence, and coincidence is not a reason to interrupt
322
+ * someone.
323
+ * supervisory — my own supervisor. The escalation path of last resort, used
324
+ * only when nothing above matched: an unanswerable question is
325
+ * better sent up the line than sprayed sideways.
326
+ *
327
+ * Nothing at all ⇒ `unroutable`, which is an outcome, not a fallback to
328
+ * broadcast. Broadcasting to a channel because we could not name a person is
329
+ * exactly the behaviour the threshold exists to prevent.
330
+ *
331
+ * @param {object} o { work, trigger, directory, me, maxTargets? }
332
+ * @returns {{targets:object[], why:string[]}}
333
+ */
334
+ export function resolveTargets(o = {}) {
335
+ const { work = {}, trigger = {}, directory = [], me = "" } = o;
336
+ const max = Number.isFinite(o.maxTargets) ? o.maxTargets : MAX_TARGETS;
337
+ const byId = new Map();
338
+ for (const m of Array.isArray(directory) ? directory : []) {
339
+ const id = memberId(m);
340
+ if (id) byId.set(id, m);
341
+ }
342
+ const why = [];
343
+ const picked = new Map();
344
+
345
+ const add = (id, rung, reason) => {
346
+ const key = String(id || "").trim();
347
+ if (!key || picked.has(key) || picked.size >= max) return false;
348
+ const m = byId.get(key) || null;
349
+ if (m && !engageable(m, me)) {
350
+ why.push(`skipped ${key}: not an engageable seat (status=${lc(m.status) || "unknown"})`);
351
+ return false;
352
+ }
353
+ if (!m && key === String(me)) return false;
354
+ picked.set(key, {
355
+ memberId: key,
356
+ name: m ? memberName(m) : key,
357
+ role: m ? String(m.roleTitle || m.title || "") : "",
358
+ team: m ? String(m.teamName || "") : "",
359
+ rung,
360
+ why: reason,
361
+ });
362
+ why.push(`${key} (${rung}): ${reason}`);
363
+ return true;
364
+ };
365
+
366
+ // ── rung 1: explicit ──
367
+ const explicit = [];
368
+ if (trigger.ownerId) explicit.push([trigger.ownerId, "the work names them as the party that must act"]);
369
+ if (work.blocker && work.blocker.ownerId) explicit.push([work.blocker.ownerId, "named as the owner of the blocker"]);
370
+ if (work.review && work.review.reviewerId) explicit.push([work.review.reviewerId, "named as the required reviewer"]);
371
+ if (work.approverId) explicit.push([work.approverId, "named as the approver for this class of action"]);
372
+ if (work.commitment && work.commitment.toMemberId && (work.commitment.atRisk || lc(work.state) === "failed")) {
373
+ explicit.push([work.commitment.toMemberId, "they are the person waiting on this and it is not going to arrive as promised"]);
374
+ }
375
+ // The requester is explicit ONLY for the trigger they are actually party to.
376
+ // Copying the requester onto an unrelated internal blocker is CC-culture.
377
+ if (
378
+ work.origin &&
379
+ work.origin.requesterId &&
380
+ (trigger.trigger === "commitment_at_risk" || lc(work.state) === "failed")
381
+ ) {
382
+ explicit.push([work.origin.requesterId, "they asked for this and are owed the outcome"]);
383
+ }
384
+ for (const [id, reason] of explicit) add(id, "explicit", reason);
385
+ if (picked.size) return { targets: [...picked.values()], why };
386
+
387
+ const scopeTokens = tokens(trigger.scope || work.scope || work.title || "");
388
+
389
+ // ── rung 2: ownership ──
390
+ if (scopeTokens.length) {
391
+ for (const m of byId.values()) {
392
+ if (!engageable(m, me)) continue;
393
+ const owns = Array.isArray(m.owns) ? m.owns.map(lc) : [];
394
+ const hitOwns = owns.find((ownScope) => scopeTokens.some((t) => ownScope.includes(t)));
395
+ if (hitOwns) {
396
+ add(memberId(m), "ownership", `the directory records them as owning "${hitOwns}"`);
397
+ continue;
398
+ }
399
+ const path = lc(m.escalationPath);
400
+ if (path && scopeTokens.some((t) => path.includes(t))) {
401
+ add(memberId(m), "ownership", `their escalation path names this scope`);
402
+ }
403
+ }
404
+ }
405
+ if (picked.size) return { targets: [...picked.values()], why };
406
+
407
+ // ── rung 3: expertise ──
408
+ if (scopeTokens.length) {
409
+ const scored = [];
410
+ for (const m of byId.values()) {
411
+ if (!engageable(m, me)) continue;
412
+ const hay = tokens([m.roleTitle, m.title, m.tagline, m.teamName].filter(Boolean).join(" "));
413
+ const hits = scopeTokens.filter((t) => hay.includes(t));
414
+ const strong = hits.some((t) => t.length >= 5);
415
+ // Two distinct tokens, or one substantial one. Below that it is noise.
416
+ if (hits.length >= 2 || strong) scored.push({ m, hits, score: hits.length + (strong ? 1 : 0) });
417
+ }
418
+ scored.sort((a, b) => b.score - a.score);
419
+ for (const s of scored) {
420
+ add(memberId(s.m), "expertise", `their role/team matches this scope on ${s.hits.map((h) => `"${h}"`).join(", ")}`);
421
+ }
422
+ }
423
+ if (picked.size) return { targets: [...picked.values()], why };
424
+
425
+ // ── rung 4: supervisory ──
426
+ // Reached when nothing above named a REACHABLE person — including the case
427
+ // where the work named someone explicitly and that seat turned out to be
428
+ // archived. An addressee is still owed, and up the line is the defensible
429
+ // choice; sideways-to-a-guess is not.
430
+ const self = byId.get(String(me));
431
+ const supervisorId = (self && self.supervisorId) || o.supervisorId || null;
432
+ if (supervisorId) {
433
+ add(
434
+ supervisorId,
435
+ "supervisory",
436
+ "nobody reachable in the directory owns this scope, so it goes up the escalation path rather than sideways to a guess",
437
+ );
438
+ }
439
+
440
+ if (!picked.size) {
441
+ why.push(
442
+ `no target: the directory (${byId.size} entries) has nobody who owns "${(trigger.scope || work.title || "").slice(0, 80)}", and this seat has no supervisor edge`,
443
+ );
444
+ }
445
+ return { targets: [...picked.values()], why };
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // 3. WHERE — the cheapest surface that reaches everyone who must act
450
+ // ---------------------------------------------------------------------------
451
+
452
+ /**
453
+ * Choose the surface. PURE.
454
+ *
455
+ * The ordering is a cost ordering, not a preference: each step exposes more
456
+ * people or creates more persistent objects than the last.
457
+ *
458
+ * reply_in_place costs nothing — the conversation already exists and everyone
459
+ * who must act is already in it. ALWAYS first when it reaches
460
+ * the targets: opening a new room to answer a question someone
461
+ * asked you in an existing one is the single most common way
462
+ * agents make a mess.
463
+ * dm costs one person's attention, nobody else's.
464
+ * existing_channel costs the attention of everyone already in that room, but
465
+ * creates nothing new. Smallest qualifying room wins.
466
+ * new_room costs everyone in it a permanent object. Requires ≥2 targets
467
+ * AND no existing room that already holds them, because a new
468
+ * room for one person is just a DM with extra ceremony.
469
+ *
470
+ * @param {object} o { work, targets, channels, me }
471
+ * @returns {{surface:string, channelId:string|null, threadRootId:string|null,
472
+ * roomName:string|null, memberIds:string[], why:string}}
473
+ */
474
+ export function chooseSurface(o = {}) {
475
+ const { work = {}, targets = [], channels = [], me = "" } = o;
476
+ const ids = targets.map((t) => t.memberId);
477
+ const origin = work.origin || {};
478
+ const index = (Array.isArray(channels) ? channels : []).map((c) => ({
479
+ id: String((c && (c.id || c.channelId)) || ""),
480
+ kind: String((c && (c.kind || c.channelKind)) || "").toUpperCase(),
481
+ name: String((c && (c.name || c.slug)) || ""),
482
+ members: new Set(
483
+ (Array.isArray(c && (c.memberIds || c.members)) ? c.memberIds || c.members : []).map((m) =>
484
+ String(m && typeof m === "object" ? m.memberId || m.id : m),
485
+ ),
486
+ ),
487
+ }));
488
+ const holdsAll = (ch) => ids.every((id) => ch.members.has(id));
489
+
490
+ // 1. The room the ask already lives in, when it holds everyone who must act.
491
+ if (origin.channelId) {
492
+ const originCh = index.find((c) => c.id === String(origin.channelId));
493
+ if (!originCh || originCh.members.size === 0 || holdsAll(originCh)) {
494
+ // Unknown membership for the origin room is treated as "it holds them" ONLY
495
+ // when the single target is the requester — that case is definitionally a
496
+ // reply, and refusing to reply because we could not read a roster would be
497
+ // absurd.
498
+ const soleRequester = ids.length === 1 && ids[0] === String(origin.requesterId || "");
499
+ if (originCh && holdsAll(originCh)) {
500
+ return {
501
+ surface: "reply_in_place",
502
+ channelId: String(origin.channelId),
503
+ threadRootId: origin.threadRootId || null,
504
+ roomName: null,
505
+ memberIds: ids,
506
+ why: `everyone who must act is already in the room this came from (${originCh.name || originCh.id}) — a new room would only move the conversation away from its context`,
507
+ };
508
+ }
509
+ if (soleRequester) {
510
+ return {
511
+ surface: "reply_in_place",
512
+ channelId: String(origin.channelId),
513
+ threadRootId: origin.threadRootId || null,
514
+ roomName: null,
515
+ memberIds: ids,
516
+ why: "the only person who must act is the person who asked, in the thread they asked in",
517
+ };
518
+ }
519
+ }
520
+ }
521
+
522
+ // 2. One person, no shared audience needed.
523
+ if (ids.length === 1) {
524
+ return {
525
+ surface: "dm",
526
+ channelId: null,
527
+ threadRootId: null,
528
+ roomName: null,
529
+ memberIds: ids,
530
+ why: "one person must act and nobody else needs to watch — a 1:1 costs exactly one person's attention",
531
+ };
532
+ }
533
+
534
+ // 3. An existing room that already holds all of them. Smallest wins: the
535
+ // fewest bystanders. A tie prefers a private/group room over a public one.
536
+ const candidates = index
537
+ .filter((c) => c.id && c.members.size > 0 && holdsAll(c) && (!me || c.members.has(String(me))))
538
+ .sort((a, b) => a.members.size - b.members.size);
539
+ if (candidates.length) {
540
+ const ch = candidates[0];
541
+ return {
542
+ surface: "existing_channel",
543
+ channelId: ch.id,
544
+ threadRootId: null,
545
+ roomName: ch.name || null,
546
+ memberIds: ids,
547
+ why: `${ch.name || ch.id} already holds all ${ids.length} of them (${ch.members.size} members — the smallest room that does), so nothing new needs creating`,
548
+ };
549
+ }
550
+
551
+ // 4. A new room. The expensive option, and it says so.
552
+ const durable = work.durable === true;
553
+ return {
554
+ surface: "new_room",
555
+ channelId: null,
556
+ threadRootId: null,
557
+ roomName: (work.title || "").slice(0, 100) || "Needs a decision",
558
+ memberIds: ids,
559
+ durableChannel: durable,
560
+ why: `${ids.length} people must act together and no room already holds them${durable ? "; the work is durable, so a named channel rather than an ad-hoc group" : ""}`,
561
+ };
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // 4. WHAT IT SAYS — a template, not an LLM call
566
+ // ---------------------------------------------------------------------------
567
+
568
+ /**
569
+ * Compose the ask. Deterministic and cheap ON PURPOSE.
570
+ *
571
+ * The daemon's holding-message path proves the alternative: generating one
572
+ * courtesy sentence through a `claude --print` child under a 60s cap failed 20
573
+ * times out of 32 on a memory-pressured machine, and every failure was silent.
574
+ * An engagement that only happens when an LLM spawn wins a race is not an
575
+ * engagement mechanism. This cannot time out, cannot cost anything, and cannot
576
+ * hallucinate a name.
577
+ *
578
+ * The shape is fixed because the reader's job is fixed: what stopped, what I
579
+ * need from you, what I already tried, and — always last — the single question.
580
+ *
581
+ * @param {object} o { work, trigger, targets, surface }
582
+ * @returns {string}
583
+ */
584
+ export function composeAsk(o = {}) {
585
+ const { work = {}, trigger = {}, targets = [] } = o;
586
+ const title = String(work.title || work.id || "this piece of work").trim();
587
+ const lines = [];
588
+
589
+ const heads = {
590
+ blocked_on_other: `**Blocked:** ${title}`,
591
+ irreversible_or_external: `**Needs your go-ahead:** ${title}`,
592
+ mandatory_review: `**Ready for review:** ${title}`,
593
+ commitment_at_risk: `**Heads-up on ${title}**`,
594
+ };
595
+ lines.push(heads[trigger.trigger] || `**${title}**`);
596
+ lines.push("");
597
+
598
+ if (work.summary) lines.push(String(work.summary).trim());
599
+ if (trigger.detail) lines.push(`Where it stopped: ${String(trigger.detail).trim()}`);
600
+
601
+ if (Array.isArray(work.tried) && work.tried.length) {
602
+ lines.push("");
603
+ lines.push("What I've already tried:");
604
+ for (const t of work.tried.slice(0, 5)) lines.push(`- ${String(t).trim()}`);
605
+ }
606
+
607
+ const asks = {
608
+ blocked_on_other: "I can't clear this myself.",
609
+ irreversible_or_external: "I'm not taking this step without a yes — it can't be undone.",
610
+ mandatory_review: "It needs your sign-off before it lands.",
611
+ commitment_at_risk: "You asked for this and it isn't going to land as promised, so you're hearing it from me rather than finding out.",
612
+ };
613
+ lines.push("");
614
+ lines.push(asks[trigger.trigger] || "");
615
+
616
+ const question = String(work.question || "").trim();
617
+ const options = (Array.isArray(work.options) ? work.options : []).map((x) => String(x).trim()).filter(Boolean);
618
+ if (question) {
619
+ lines.push("");
620
+ lines.push(question);
621
+ if (options.length >= 2) for (const opt of options.slice(0, 5)) lines.push(`- ${opt}`);
622
+ } else if (targets.length) {
623
+ lines.push("");
624
+ lines.push(`@${targets.map((t) => t.name).join(" @")} — what would you like me to do?`);
625
+ }
626
+
627
+ if (work.link) {
628
+ lines.push("");
629
+ lines.push(String(work.link));
630
+ }
631
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // 5. THE DECISION
636
+ // ---------------------------------------------------------------------------
637
+
638
+ /**
639
+ * Decide whether to engage anyone, who, and where. PURE — no clock of its own,
640
+ * no disk, no network. Everything it needs is injected.
641
+ *
642
+ * @param {object} work {
643
+ * id, title, summary, scope?, question?, options?, link?, tried?[],
644
+ * state: "in_progress"|"blocked"|"failed"|"done",
645
+ * origin?: { surface?, channelId?, threadRootId?, requesterId? },
646
+ * blocker?: { kind:"decision"|"access"|"information"|"approval"|"capacity",
647
+ * scope?, detail?, ownerId? },
648
+ * selfServeExhausted?: boolean, // REQUIRED true for the blocked trigger
649
+ * risk?: { irreversible?, external?, financial? },
650
+ * review?: { required?, requested?, kind?, reviewerId? },
651
+ * commitment?: { toMemberId, dueAtMs?, atRisk? },
652
+ * interestedParties?: [memberId], // considered, deliberately not pinged
653
+ * durable?: boolean // a new room should be a named channel
654
+ * }
655
+ * @param {object} ctx {
656
+ * me, directory:[member], channels:[channel], ledger:[row], nowMs, limits?,
657
+ * supervisorId?, maxTargets?
658
+ * }
659
+ * @returns {object} decision
660
+ */
661
+ export function decideEngagement(work = {}, ctx = {}) {
662
+ const nowMs = Number.isFinite(ctx.nowMs) ? ctx.nowMs : Date.now();
663
+ const workId = String(work.id || work.key || "").trim();
664
+ const why = [];
665
+ const degraded = [].concat(ctx.degraded || []);
666
+
667
+ const base = {
668
+ engage: false,
669
+ trigger: null,
670
+ reasonCode: null,
671
+ reason: null,
672
+ targets: [],
673
+ surface: "none",
674
+ channelId: null,
675
+ threadRootId: null,
676
+ roomName: null,
677
+ mentions: [],
678
+ body: null,
679
+ workId,
680
+ dedupeKey: null,
681
+ guardrail: null,
682
+ why,
683
+ degraded,
684
+ consideredNotEngaged: [],
685
+ nowMs,
686
+ };
687
+
688
+ // ── whether ──
689
+ const { fired, suppressed } = evaluateTriggers(work);
690
+ for (const s of suppressed) why.push(`trigger ${s.trigger} did not fire — ${s.code}: ${s.detail}`);
691
+
692
+ // The FYI class, named explicitly. These people were CONSIDERED and declined,
693
+ // which is a different and much more defensible statement than never having
694
+ // thought about them.
695
+ const fyi = (Array.isArray(work.interestedParties) ? work.interestedParties : []).map(String).filter(Boolean);
696
+ if (fyi.length) {
697
+ base.consideredNotEngaged = fyi;
698
+ why.push(
699
+ `${fyi.length} interested part${fyi.length === 1 ? "y" : "ies"} (${fyi.join(", ")}) considered and NOT messaged: nothing they do changes because of this, so it belongs on the board where they can pull it, not in their DMs`,
700
+ );
701
+ }
702
+
703
+ if (!fired.length) {
704
+ const reasonCode = suppressed.length ? suppressed[0].code : "self_contained";
705
+ if (!suppressed.length) {
706
+ // A `self_contained` row with an empty `why` is indistinguishable from a
707
+ // judgement that never ran. Say what was examined, so the row proves the
708
+ // threshold was applied rather than merely asserting an outcome.
709
+ why.push(
710
+ `no trigger fired: state=${lc(work.state) || "in_progress"}, ` +
711
+ `blocker=${work.blocker ? lc(work.blocker.kind) || "unspecified" : "none"}, ` +
712
+ `risk=${["irreversible", "external", "financial"].filter((k) => (work.risk || {})[k]).join("+") || "none"}, ` +
713
+ `review=${work.review && work.review.required ? "required" : "none"}, ` +
714
+ `commitment=${work.commitment && work.commitment.atRisk ? "at risk" : "none"} — ` +
715
+ "nobody's action or consent is a precondition, so nobody is interrupted",
716
+ );
717
+ }
718
+ return {
719
+ ...base,
720
+ reasonCode,
721
+ reason:
722
+ reasonCode === "self_contained"
723
+ ? "nobody's action is a precondition here — the work proceeds on its own"
724
+ : suppressed[0].detail,
725
+ };
726
+ }
727
+
728
+ const trigger = fired[0];
729
+ base.trigger = trigger.trigger;
730
+ why.push(`trigger ${trigger.trigger} (${trigger.code}): ${trigger.detail}`);
731
+ for (const extra of fired.slice(1)) why.push(`also firing: ${extra.trigger} (${extra.code})`);
732
+
733
+ // ── who ──
734
+ const resolved = resolveTargets({
735
+ work,
736
+ trigger,
737
+ directory: ctx.directory || [],
738
+ me: ctx.me || "",
739
+ supervisorId: ctx.supervisorId,
740
+ maxTargets: ctx.maxTargets,
741
+ });
742
+ why.push(...resolved.why);
743
+ if (!resolved.targets.length) {
744
+ // Loud, not silent. A blocked piece of work with nobody to ask is a
745
+ // provisioning gap (no owner, no supervisor edge) and it must read as one.
746
+ return {
747
+ ...base,
748
+ reasonCode: "unroutable",
749
+ reason: `${trigger.trigger} fired but nobody in the directory owns this and this seat has no supervisor — the ask has no addressee, which is an org gap, not a quiet no`,
750
+ degraded: degraded.concat(["engagement_unroutable"]),
751
+ };
752
+ }
753
+
754
+ // ── where ──
755
+ const place = chooseSurface({ work, targets: resolved.targets, channels: ctx.channels || [], me: ctx.me });
756
+ why.push(`surface ${place.surface}: ${place.why}`);
757
+
758
+ const dedupeKey = dedupeKeyFor({ workId, reasonCode: trigger.code, targets: resolved.targets });
759
+
760
+ // ── guardrails ──
761
+ const guard = checkGuardrails({
762
+ rows: ctx.ledger || [],
763
+ nowMs,
764
+ dedupeKey,
765
+ workId,
766
+ targets: resolved.targets,
767
+ createsRoom: place.surface === "new_room",
768
+ limits: ctx.limits || DEFAULT_LIMITS,
769
+ });
770
+ if (!guard.allowed) {
771
+ why.push(`guardrail ${guard.code}: ${guard.detail}`);
772
+ return {
773
+ ...base,
774
+ trigger: trigger.trigger,
775
+ reasonCode: guard.code,
776
+ reason: guard.detail,
777
+ targets: resolved.targets,
778
+ surface: place.surface,
779
+ dedupeKey,
780
+ guardrail: { code: guard.code, detail: guard.detail, counts: guard.counts },
781
+ };
782
+ }
783
+
784
+ const body = composeAsk({ work, trigger, targets: resolved.targets, surface: place.surface });
785
+
786
+ return {
787
+ ...base,
788
+ engage: true,
789
+ trigger: trigger.trigger,
790
+ reasonCode: trigger.code,
791
+ reason: trigger.detail,
792
+ targets: resolved.targets,
793
+ surface: place.surface,
794
+ channelId: place.channelId,
795
+ threadRootId: place.threadRootId,
796
+ roomName: place.roomName,
797
+ durableChannel: place.durableChannel === true,
798
+ memberIds: place.memberIds,
799
+ // Everyone who must act gets tagged. This is the whole point of the mention
800
+ // fix in messaging.mjs: an unmentioned name in a busy room is not an ask.
801
+ mentions: resolved.targets.map((t) => ({ memberId: t.memberId })),
802
+ body,
803
+ dedupeKey,
804
+ // Which send this is for this ask. The dedupe key is timeless by design —
805
+ // that is what makes the re-engage window enforceable — so the ordinal is
806
+ // what keeps a re-ask from colliding with the original on hq's server-side
807
+ // message dedupe and posting nothing.
808
+ attempt: guard.attempt || 1,
809
+ };
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // 6. DOING IT
814
+ // ---------------------------------------------------------------------------
815
+
816
+ /**
817
+ * Load the context `decideEngagement` needs. Every leg fails open INDEPENDENTLY
818
+ * and says so — a directory we could not read must not look like an empty org,
819
+ * because an empty org routes everything to `unroutable` and the operator would
820
+ * read that as "nobody owns this" rather than "the read failed".
821
+ *
822
+ * @param {object} o { cfg, agentRoot, me, fetchImpl?, nowMs?, limits? }
823
+ * @returns {Promise<object>} ctx for decideEngagement
824
+ */
825
+ export async function loadEngagementContext(o = {}) {
826
+ const degraded = [];
827
+ let directory = [];
828
+ let channels = [];
829
+
830
+ try {
831
+ const { call, configFromAgent } = await import("./client.mjs");
832
+ const c = configFromAgent(o.cfg || {});
833
+ if (c.base) {
834
+ // The WHOLE roster, deliberately unfiltered. hq's `status` filter takes the
835
+ // Prisma enum (`ACTIVE`/`BENCH`/`INACTIVE`, uppercase) and rejects anything
836
+ // else, and filtering server-side would also hide the fact that the person
837
+ // who owns a scope is benched. `engageable()` makes that call locally and
838
+ // RECORDS the skip, which is the difference between "we chose not to ping
839
+ // an inactive seat" and "nobody owns this".
840
+ const frame = await call("member.list", {}, { base: c.base, token: c.token, fetchImpl: o.fetchImpl });
841
+ if (frame && frame.ok && frame.result) {
842
+ const rows = Array.isArray(frame.result) ? frame.result : frame.result.members;
843
+ if (Array.isArray(rows)) directory = rows;
844
+ else degraded.push("directory_unexpected_shape");
845
+ } else {
846
+ degraded.push(`directory_read_failed:${(frame && frame.error && frame.error.code) || "unknown"}`);
847
+ }
848
+ } else {
849
+ degraded.push("directory_unavailable:no_org_base");
850
+ }
851
+ } catch (err) {
852
+ degraded.push(`directory_read_threw:${(err && err.message) || err}`);
853
+ }
854
+
855
+ try {
856
+ const { listChannels } = await import("./messaging.mjs");
857
+ channels = await listChannels({ cfg: o.cfg, fetchImpl: o.fetchImpl });
858
+ if (!Array.isArray(channels)) { channels = []; degraded.push("channels_unexpected_shape"); }
859
+ } catch (err) {
860
+ degraded.push(`channels_read_threw:${(err && err.message) || err}`);
861
+ }
862
+
863
+ const led = loadEngagements({ agentRoot: o.agentRoot, path: o.ledgerPath });
864
+ degraded.push(...led.degraded);
865
+
866
+ return {
867
+ me: o.me || "",
868
+ directory,
869
+ channels,
870
+ ledger: led.rows,
871
+ nowMs: Number.isFinite(o.nowMs) ? o.nowMs : Date.now(),
872
+ limits: o.limits || DEFAULT_LIMITS,
873
+ supervisorId: o.supervisorId,
874
+ degraded,
875
+ };
876
+ }
877
+
878
+ /**
879
+ * Execute a decision — including the decisions that engage nobody, which are
880
+ * recorded exactly as loudly as the ones that do.
881
+ *
882
+ * The send always goes through `messaging.sendMessage`, which runs the shared
883
+ * outbound send-gate before the wire. That is not bypassable here and should not
884
+ * be: an engagement is a real message to a real person. A gate refusal is
885
+ * recorded as `send_blocked` with the policy reason attached — the one thing it
886
+ * must never be is a quiet no-op, because the whole point of this module is that
887
+ * an unspoken ask is a bug.
888
+ *
889
+ * @param {object} decision from `decideEngagement`
890
+ * @param {object} o { cfg, agentRoot, fetchImpl?, nowMs?, ledgerPath?, deps? }
891
+ * @returns {Promise<{ok:boolean, engaged:boolean, surface:string,
892
+ * channelId:string|null, outcome:string, error:string|null,
893
+ * decision:object}>}
894
+ */
895
+ export async function runEngagement(decision, o = {}) {
896
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : (decision && decision.nowMs) || Date.now();
897
+ const deps = o.deps || {};
898
+ const record = (outcome, extra = {}) => {
899
+ appendEngagement(
900
+ {
901
+ workId: decision.workId,
902
+ trigger: decision.trigger,
903
+ reasonCode: decision.reasonCode,
904
+ reason: decision.reason,
905
+ dedupeKey: decision.dedupeKey,
906
+ engaged: outcome === "engaged",
907
+ outcome,
908
+ surface: extra.surface || decision.surface,
909
+ channelId: extra.channelId || decision.channelId,
910
+ roomCreated: !!extra.roomCreated,
911
+ targets: decision.targets,
912
+ why: decision.why,
913
+ },
914
+ { agentRoot: o.agentRoot, path: o.ledgerPath, nowMs },
915
+ );
916
+ };
917
+
918
+ if (!decision || typeof decision !== "object") {
919
+ return { ok: false, engaged: false, surface: "none", channelId: null, outcome: "none", error: "no decision", decision: null };
920
+ }
921
+
922
+ // A considered non-engagement. Recorded, then done.
923
+ if (!decision.engage) {
924
+ record(
925
+ decision.guardrail
926
+ ? decision.guardrail.code
927
+ : decision.reasonCode === "unroutable"
928
+ ? "unroutable"
929
+ : "none",
930
+ );
931
+ return {
932
+ ok: true,
933
+ engaged: false,
934
+ surface: "none",
935
+ channelId: null,
936
+ outcome: decision.reasonCode || "none",
937
+ error: null,
938
+ decision,
939
+ };
940
+ }
941
+
942
+ const messaging = deps.messaging || (await import("./messaging.mjs"));
943
+ const sendOpts = { cfg: o.cfg, agentRoot: o.agentRoot, fetchImpl: o.fetchImpl, ...(o.sendOpts || {}) };
944
+
945
+ // Resolve the room the ask lands in.
946
+ let channelId = decision.channelId;
947
+ let roomCreated = false;
948
+ if (decision.surface === "dm") {
949
+ const r = await messaging.openDm({ memberId: decision.targets[0].memberId }, sendOpts);
950
+ if (!r.ok) {
951
+ const err = (r.frame && r.frame.error && (r.frame.error.message || r.frame.error.code)) || "could not open a DM";
952
+ record("send_failed");
953
+ return { ok: false, engaged: false, surface: decision.surface, channelId: null, outcome: "send_failed", error: String(err), decision };
954
+ }
955
+ channelId = r.channelId;
956
+ roomCreated = r.created;
957
+ } else if (decision.surface === "new_room") {
958
+ const memberIds = decision.memberIds || decision.targets.map((t) => t.memberId);
959
+ const r = decision.durableChannel
960
+ ? await messaging.createChannel(
961
+ {
962
+ slug: slugify(decision.roomName || decision.workId || "engagement"),
963
+ name: decision.roomName || "Needs a decision",
964
+ kind: "PRIVATE",
965
+ topic: decision.reason || undefined,
966
+ members: memberIds,
967
+ },
968
+ sendOpts,
969
+ )
970
+ : await messaging.openConversation({ memberIds, name: decision.roomName || undefined }, sendOpts);
971
+ if (!r.ok) {
972
+ const err = (r.frame && r.frame.error && (r.frame.error.message || r.frame.error.code)) || "could not open a room";
973
+ record("send_failed");
974
+ return { ok: false, engaged: false, surface: decision.surface, channelId: null, outcome: "send_failed", error: String(err), decision };
975
+ }
976
+ channelId = r.channelId;
977
+ roomCreated = true;
978
+ }
979
+
980
+ if (!channelId) {
981
+ record("send_failed");
982
+ return { ok: false, engaged: false, surface: decision.surface, channelId: null, outcome: "send_failed", error: "no channel resolved for the engagement", decision };
983
+ }
984
+
985
+ const res = await messaging.sendMessage(
986
+ {
987
+ channel: channelId,
988
+ body: decision.body,
989
+ mentions: decision.mentions,
990
+ ...(decision.threadRootId ? { threadId: decision.threadRootId } : {}),
991
+ // Stable on the ask's identity AND on which send this is.
992
+ //
993
+ // hq de-dupes on (org, channel, clientMsgId) and returns the EXISTING
994
+ // message verbatim rather than erroring, so an id keyed on the ask alone
995
+ // makes the re-ask — the whole reason `reengageAfterMs` exists, fired six
996
+ // hours later when silence has started to look like the agent forgot —
997
+ // post absolutely nothing, while `frame.ok === true` reports it delivered
998
+ // and the ledger records an engagement that never happened. The attempt
999
+ // ordinal separates distinct asks; a genuine transport RETRY within one
1000
+ // attempt reuses the id, which is the dedupe doing its job.
1001
+ idempotencyId: `engage-${decision.dedupeKey}-${decision.attempt || 1}`,
1002
+ },
1003
+ sendOpts,
1004
+ );
1005
+
1006
+ if (!res || res.ok === false) {
1007
+ const blocked = !!(res && res.blocked);
1008
+ const err = (res && res.error && (res.error.message || res.error.code)) || "messaging.send failed";
1009
+ record(blocked ? "send_blocked" : "send_failed", { channelId, roomCreated });
1010
+ return {
1011
+ ok: false,
1012
+ engaged: false,
1013
+ surface: decision.surface,
1014
+ channelId,
1015
+ outcome: blocked ? "send_blocked" : "send_failed",
1016
+ error: String(err),
1017
+ decision,
1018
+ };
1019
+ }
1020
+
1021
+ record("engaged", { channelId, roomCreated });
1022
+ return { ok: true, engaged: true, surface: decision.surface, channelId, outcome: "engaged", error: null, decision };
1023
+ }
1024
+
1025
+ /** hq's channel slug grammar: lowercase alphanumerics + hyphens, ≤80. */
1026
+ function slugify(s) {
1027
+ const base = lc(s).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
1028
+ return base ? `x-${base}`.slice(0, 80) : `x-${Date.now().toString(36)}`;
1029
+ }
1030
+
1031
+ /**
1032
+ * Decide and, if warranted, act — the one call a caller should need.
1033
+ * Loads the context, decides, records, executes.
1034
+ *
1035
+ * @param {object} work
1036
+ * @param {object} o { cfg, agentRoot, me, fetchImpl?, nowMs?, ctx?, limits? }
1037
+ * @returns {Promise<object>} the run result, with `.decision` attached
1038
+ */
1039
+ export async function engage(work, o = {}) {
1040
+ const ctx = o.ctx || (await loadEngagementContext(o));
1041
+ const decision = decideEngagement(work, ctx);
1042
+ return runEngagement(decision, o);
1043
+ }
1044
+
1045
+ export default {
1046
+ TRIGGERS,
1047
+ SURFACES,
1048
+ evaluateTriggers,
1049
+ resolveTargets,
1050
+ chooseSurface,
1051
+ composeAsk,
1052
+ decideEngagement,
1053
+ loadEngagementContext,
1054
+ runEngagement,
1055
+ engage,
1056
+ };