@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.
- package/bin/maestro.mjs +305 -89
- package/bin/maestro.test.mjs +357 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/classifier.test.mjs +18 -9
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- 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
|
+
};
|