@cohortapp/agent-sdk 2.18.13 → 2.18.15

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 (46) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +58 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/assurance/batch.mjs +353 -0
  5. package/lib/assurance/first-reply.mjs +423 -0
  6. package/lib/assurance/notice-voice.mjs +357 -0
  7. package/lib/assurance/plan-note.mjs +43 -0
  8. package/lib/assurance/room-budget.mjs +55 -6
  9. package/lib/cadence-failure-class.mjs +245 -0
  10. package/lib/claude-bin.mjs +26 -7
  11. package/lib/cli/doctor-checks.mjs +149 -1
  12. package/lib/comms/send-gate.mjs +59 -0
  13. package/lib/diagnostics/alerts.mjs +33 -0
  14. package/lib/engine/agents/usage.mjs +45 -0
  15. package/lib/engine/budget.mjs +293 -29
  16. package/lib/engine/cli.mjs +54 -5
  17. package/lib/engine/loop.mjs +30 -0
  18. package/lib/engine/output/json.mjs +26 -0
  19. package/lib/engine/wire/errors.mjs +179 -0
  20. package/lib/engine/wire/search.mjs +44 -8
  21. package/lib/identity/persona.mjs +31 -2
  22. package/lib/org/quota.mjs +27 -0
  23. package/lib/session/config.mjs +4 -0
  24. package/lib/session/identity.mjs +71 -7
  25. package/lib/session/launch-failure.mjs +251 -0
  26. package/lib/session/resume-target.mjs +86 -0
  27. package/lib/telemetry/alerts.mjs +94 -0
  28. package/lib/telemetry/collect.mjs +155 -2
  29. package/lib/upgrade/pinned-drift.mjs +467 -0
  30. package/package.json +1 -1
  31. package/scaffold/config/alerts.yaml +7 -0
  32. package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
  33. package/scripts/ci/check.mjs +3 -0
  34. package/scripts/daemon/agent-daemon.mjs +75 -5
  35. package/scripts/daemon/assurance.mjs +709 -44
  36. package/scripts/daemon/cadence-consumer.mjs +281 -34
  37. package/scripts/daemon/deliver.mjs +109 -0
  38. package/scripts/daemon/dispatcher.mjs +21 -3
  39. package/scripts/daemon/inbox-deferral.mjs +102 -9
  40. package/scripts/daemon/session-lock.mjs +41 -1
  41. package/scripts/emergency-stop.sh +114 -13
  42. package/scripts/fleet/rollout.mjs +256 -10
  43. package/scripts/healthcheck.sh +131 -33
  44. package/scripts/local-triggers/autoupdate.sh +144 -11
  45. package/scripts/resume-operations.sh +101 -6
  46. package/scripts/session/supervisor.mjs +198 -5
@@ -0,0 +1,423 @@
1
+ /**
2
+ * lib/assurance/first-reply.mjs — what an agent says FIRST, and whether it says
3
+ * anything at all.
4
+ *
5
+ * THE DIRECTIVE THIS IMPLEMENTS (owner, 2026-09-25, with four screenshots of
6
+ * the org's own channels attached)
7
+ *
8
+ * "Get rid of the use of these kind of generic placeholder replies. Instead,
9
+ * the first reply should come as part of the actual LLM's initial
10
+ * consideration of the question, and then replying with an appropriate
11
+ * initial short reply that uses a natural tone of voice that is contextually
12
+ * relevant… So for example, if there's a flurry of 4 messages in one go by
13
+ * others, instead of having a generic reply per message, it would be
14
+ * responding to the batch of messages. And then also, sometimes a reply isn't
15
+ * needed (only do this when it truly doesn't need any reply), and then also
16
+ * sometimes the initial response can be an emoji response."
17
+ *
18
+ * Three decisions fall out of that, and all three are made HERE, as pure
19
+ * functions, rather than as sentences in a system prompt. A rule that lives
20
+ * only in a prompt is a rule nothing checks: the ack prompt has said "never a
21
+ * generic 'on it'" since it was written, and 3,069 generic acks shipped anyway
22
+ * (see `tier.mjs`). Prompts state intent; functions are the enforcement.
23
+ *
24
+ * SHAPE — none / react / line. `firstReplyShape` below.
25
+ * BATCH — a flurry in one room is ONE first reply, not one per message.
26
+ * `groupForFirstReply` below.
27
+ * VOICE — the line itself is generated from the actual thread
28
+ * (`scripts/daemon/assurance.generateAck`); this module never
29
+ * composes prose, it only decides whether prose is owed.
30
+ *
31
+ * THE REGISTER, AND WHY IT IS A LIST OF EXACT SENTENCES
32
+ *
33
+ * `RETIRED_FIRST_REPLIES` holds the four sentences the owner screenshotted, in
34
+ * the exact bytes they shipped in. Two separate defects put them on screen and
35
+ * the register answers both:
36
+ *
37
+ * (1) Two seats were still running a preserved local copy of the daemon that
38
+ * predates the 2026-08-30 removal of the canned rotation, and went on
39
+ * emitting "On it." / "Looking now." / "On it — digging in now." while
40
+ * reporting a current SDK version. `isRetiredFirstReply` is the check at
41
+ * the SEND chokepoint that makes a stale composer upstream unable to put
42
+ * one on screen anyway.
43
+ * (2) The failure notices were deterministic strings by design, so every
44
+ * dead session in the fleet said the same two sentences. Those two are
45
+ * in the register too, which is what makes it impossible to reintroduce
46
+ * them by editing the composer back.
47
+ *
48
+ * PURE. Nothing here reads the clock, the filesystem, the environment or the
49
+ * network; every input arrives on the argument. That is what lets the whole
50
+ * matrix be pinned by a test rather than sampled off a live daemon.
51
+ *
52
+ * @module lib/assurance/first-reply
53
+ */
54
+
55
+ "use strict";
56
+
57
+ /**
58
+ * The exact sentences the owner screenshotted on 2026-09-25, byte for byte.
59
+ *
60
+ * FROZEN LITERALS, deliberately — the opposite convention to
61
+ * `assurance.narratorPatterns()`, which derives its templates from the live
62
+ * composers so the list cannot drift from the emitter. Here drift is the POINT:
63
+ * these must stay recognisable after the composers that produced them are gone,
64
+ * and a derived list would delete them at exactly the moment they stop being
65
+ * produced locally and start arriving from a seat that has not updated yet.
66
+ *
67
+ * @type {readonly string[]}
68
+ */
69
+ export const RETIRED_FIRST_REPLIES = Object.freeze([
70
+ "On it.",
71
+ "Looking now.",
72
+ "On it — digging in now.",
73
+ "Hit a problem — the working session ended with an error. Retrying now; if it fails again I'll come straight back rather than leave you waiting.",
74
+ "Couldn't finish this — the work never came back with a result and has now outrun its time limit. I've stopped retrying and flagged it so it isn't lost. Want me to try a narrower version, hand it off, or leave it with you?",
75
+ ]);
76
+
77
+ /**
78
+ * The same five with their variable middle replaced by a wildcard, so the
79
+ * register catches the whole FAMILY and not only the one cause that happened
80
+ * to be screenshotted. `composeFailure`'s cause is interpolated; "the working
81
+ * session ended with an error" and "the work never came back with a result and
82
+ * has now outrun its time limit" are two of roughly a dozen `classifyFailure`
83
+ * can produce, and pinning only those two would leave ten siblings live.
84
+ */
85
+ const RETIRED_PATTERNS = Object.freeze([
86
+ /^on it[.!]?$/i,
87
+ /^looking now[.!]?$/i,
88
+ /^on it\s*[—–-]\s*digging in now[.!]?$/i,
89
+ /^hit a problem\s*[—–-]\s*.+\.\s*retrying now;\s*if it fails again i'?ll come straight back rather than leave you waiting\.?$/i,
90
+ /^couldn'?t finish this\s*[—–-]\s*.+\.\s*i'?ve stopped retrying and flagged it so it isn'?t lost\.\s*want me to try a narrower version,\s*hand it off,\s*or leave it with you\?$/i,
91
+ ]);
92
+
93
+ /**
94
+ * One line, in the shape the register compares. Collapses whitespace, unifies
95
+ * the three dashes and the two apostrophes, and trims — so a copy that survived
96
+ * a round trip through a chat client's smart-quote substitution still matches.
97
+ *
98
+ * @param {unknown} text
99
+ * @returns {string}
100
+ */
101
+ export function normaliseLine(text) {
102
+ return String(text == null ? "" : text)
103
+ .replace(/[‘’ʼ]/g, "'")
104
+ .replace(/[“”]/g, '"')
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ }
108
+
109
+ /**
110
+ * Split a body into the units the register judges: paragraphs, then lines.
111
+ *
112
+ * WHY PARAGRAPHS AND NOT THE WHOLE BODY. The owner's 15:04 screenshot showed
113
+ * the same failure sentence twice under one timestamp. Whatever produced that —
114
+ * two notices concatenated, or a client rendering two sends as one block — a
115
+ * check that only tests the WHOLE string against the register would pass it,
116
+ * because "<sentence>\n\n<sentence>" is not equal to "<sentence>". Judging each
117
+ * paragraph closes that.
118
+ *
119
+ * @param {unknown} body
120
+ * @returns {string[]}
121
+ */
122
+ export function bodySegments(body) {
123
+ const t = String(body == null ? "" : body).trim();
124
+ if (!t) return [];
125
+ const out = [];
126
+ for (const para of t.split(/\n\s*\n/)) {
127
+ const p = normaliseLine(para);
128
+ if (p) out.push(p);
129
+ for (const line of para.split("\n")) {
130
+ const l = normaliseLine(line);
131
+ if (l && l !== p) out.push(l);
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Is this text — or any paragraph of it — one of the retired placeholder
139
+ * replies?
140
+ *
141
+ * Used as the last gate before a send, so it answers the only question that
142
+ * matters there: would a reader see one of these sentences on screen. It does
143
+ * NOT try to judge whether a line is "generic" in general; `GENERIC_OPENER` in
144
+ * `plan-note.mjs` owns that, and this is the narrower, exact, un-arguable half.
145
+ *
146
+ * @param {unknown} text
147
+ * @returns {boolean}
148
+ */
149
+ export function isRetiredFirstReply(text) {
150
+ const segs = bodySegments(text);
151
+ if (!segs.length) return false;
152
+ return segs.some((s) => RETIRED_PATTERNS.some((re) => re.test(s)));
153
+ }
154
+
155
+ /**
156
+ * Does a body repeat one of its own paragraphs verbatim?
157
+ *
158
+ * The 15:04 defect, made checkable independently of WHICH sentence repeated.
159
+ * A body that says the same paragraph twice is telling the reader nothing the
160
+ * second time; whatever composed it has double-counted.
161
+ *
162
+ * @param {unknown} body
163
+ * @returns {boolean}
164
+ */
165
+ export function repeatsItself(body) {
166
+ const paras = String(body == null ? "" : body)
167
+ .trim()
168
+ .split(/\n\s*\n/)
169
+ .map(normaliseLine)
170
+ .filter((p) => p.length >= 12); // a bare "Yes." or a bullet marker may legitimately recur
171
+ if (paras.length < 2) return false;
172
+ return new Set(paras).size !== paras.length;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // THE SHAPE DECISION
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** The three things a first response can be. */
180
+ export const SHAPES = Object.freeze(["none", "react", "line"]);
181
+
182
+ /**
183
+ * Reactions, by what the message was. Deliberately tiny and deliberately dull:
184
+ * a reaction is an acknowledgement, not a performance, and a vocabulary big
185
+ * enough to be expressive is a vocabulary big enough to be wrong.
186
+ *
187
+ * `seen` is the default because it is the only one that is always honest — it
188
+ * claims nothing beyond "this reached me".
189
+ */
190
+ export const REACTIONS = Object.freeze({
191
+ seen: "👀",
192
+ agreed: "👍",
193
+ thanks: "🙏",
194
+ done: "✅",
195
+ });
196
+
197
+ /**
198
+ * Classifier actions that are, by their own verdict, nothing to answer.
199
+ * `archive` and `ignore` are the classifier saying so in as many words.
200
+ */
201
+ const NO_REPLY_ACTIONS = Object.freeze(["archive", "ignore"]);
202
+
203
+ /**
204
+ * Does this inbound ASK something of the agent?
205
+ *
206
+ * A question mark is the strong signal but not the only one: "let me know what
207
+ * you think" and "can you take this" are questions without one. Kept narrow on
208
+ * purpose — the cost of a false negative here is an emoji where a sentence was
209
+ * owed, which is the failure the owner explicitly warned about ("an emoji is
210
+ * wrong for a question").
211
+ *
212
+ * @param {unknown} text
213
+ * @returns {boolean}
214
+ */
215
+ export function asksSomething(text) {
216
+ const t = normaliseLine(text);
217
+ if (!t) return false;
218
+ if (t.includes("?")) return true;
219
+ if (/\b(let me know|tell me|send me|can you|could you|would you|please|pls|need you to|thoughts\?*$|any update|update on|when can|how soon)\b/i.test(t)) return true;
220
+ // An interrogative opener is a question whatever the punctuation. Chat drops
221
+ // the question mark constantly ("what's the status on the raise", "when does
222
+ // the window close"), and a dropped mark must not downgrade a question to an
223
+ // emoji — that is the one outcome the owner named as wrong. Erring toward
224
+ // `true` costs a sentence where a reaction would have done; erring toward
225
+ // `false` leaves a question answered with a thumb.
226
+ if (/^(what|when|where|who|whose|which|why|how|is|are|was|were|do|does|did|can|could|would|should|will|have|has|any)\b/i.test(t)) return true;
227
+ return false;
228
+ }
229
+
230
+ /**
231
+ * THE DECISION: what is the first thing said about this inbound, if anything?
232
+ *
233
+ * The rules, in the order they are applied, because the order IS the policy:
234
+ *
235
+ * 1. Already covered by a batch ⇒ `none`. One flurry, one first reply. This
236
+ * is first because it outranks every other reason to speak: the batch's
237
+ * OWN first reply has already said whatever was owed.
238
+ * 2. Tier `answer` ⇒ `none`. Unchanged and load-bearing — the reply arrives
239
+ * in this turn, so anything in front of it is the content-free traffic
240
+ * `tier.mjs` was written to delete.
241
+ * 3. The classifier says archive/ignore ⇒ `none`.
242
+ * 4. Not addressed to this agent and asking nothing ⇒ `none`. This is the
243
+ * owner's "sometimes a reply isn't needed", scoped as narrowly as he
244
+ * scoped it ("only do this when it truly doesn't need any reply"): the
245
+ * agent must be neither the addressee NOR asked anything.
246
+ * 5. Addressed, but asking nothing, on a surface that has reactions ⇒
247
+ * `react`. The FYI case. An acknowledgement is owed — someone deliberately
248
+ * put this in front of this agent — but a sentence would be noise.
249
+ * 6. Otherwise ⇒ `line`, and the line is generated, never canned.
250
+ *
251
+ * Note rule 5's guard: `asksSomething` must be FALSE. A question never gets an
252
+ * emoji, whatever else is true of it. That is the distinction the owner drew
253
+ * and it is the one this function exists to make mechanical.
254
+ *
255
+ * @param {object} [o]
256
+ * @param {"answer"|"work"|"plan"} [o.tier] from `replyTier`
257
+ * @param {string} [o.action] classifier action
258
+ * @param {boolean} [o.directed] was this agent addressed (mention, DM, assignment)
259
+ * @param {string} [o.text] the inbound body
260
+ * @param {boolean} [o.coveredByBatch] an earlier message in this flurry already owns the first reply
261
+ * @param {boolean} [o.reactionsAvailable] does this surface support a reaction
262
+ * @param {string} [o.reaction] which REACTIONS key, when one is right
263
+ * @returns {{shape:"none"|"react"|"line", emoji:string|null, reason:string}}
264
+ */
265
+ export function firstReplyShape(o = {}) {
266
+ const a = o && typeof o === "object" ? o : {};
267
+ const none = (reason) => ({ shape: "none", emoji: null, reason });
268
+
269
+ if (a.coveredByBatch === true) return none("covered-by-batch");
270
+ if (a.tier === "answer") return none("tier-answer");
271
+ if (NO_REPLY_ACTIONS.includes(String(a.action || ""))) return none(`action-${a.action}`);
272
+
273
+ const asks = asksSomething(a.text);
274
+ if (a.directed !== true && !asks) return none("not-directed-nothing-asked");
275
+
276
+ if (!asks && a.reactionsAvailable === true) {
277
+ const key = Object.prototype.hasOwnProperty.call(REACTIONS, String(a.reaction || "")) ? String(a.reaction) : "seen";
278
+ return { shape: "react", emoji: REACTIONS[key], reason: "acknowledged-not-answered" };
279
+ }
280
+
281
+ return { shape: "line", emoji: null, reason: asks ? "question-owed-an-answer" : "no-reaction-surface" };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // THE BATCH
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /** How close together messages must be to count as one flurry. */
289
+ export const DEFAULT_BATCH_WINDOW_MS = 3 * 60_000;
290
+
291
+ /** The room a first reply would land in. Thread-aware: two threads in one
292
+ * channel are two conversations and each is owed its own first reply. */
293
+ function conversationKey(item) {
294
+ const i = item && typeof item === "object" ? item : {};
295
+ const svc = String(i.service || "").trim().toLowerCase() || "unknown";
296
+ const ch = String(i.channel_id || "").trim().toLowerCase() || "unknown";
297
+ const th = String(i.thread_id || "").trim().toLowerCase();
298
+ return th ? `${svc}|${ch}|t:${th}` : `${svc}|${ch}`;
299
+ }
300
+
301
+ const atOf = (item) => {
302
+ const v = item && (item.received_at ?? item.ts ?? item.createdAt ?? item.at);
303
+ const n = typeof v === "number" ? v : Date.parse(String(v || ""));
304
+ return Number.isFinite(n) ? n : null;
305
+ };
306
+
307
+ /**
308
+ * Group a poll's worth of inbound into the flurries a human would recognise.
309
+ *
310
+ * "If there's a flurry of 4 messages in one go by others, instead of having a
311
+ * generic reply per message, it would be responding to the batch." So: same
312
+ * conversation, within `windowMs` of the previous message in that conversation,
313
+ * is one batch. The batch's FIRST message carries the reply; every later member
314
+ * is returned with `coveredByBatch`, which rule 1 of `firstReplyShape` turns
315
+ * into silence.
316
+ *
317
+ * The leader is the first message, not the last, because the first is the one
318
+ * the generated line is composed against — and every other member of the batch
319
+ * is handed to that composer as context, so the one line answers all four.
320
+ *
321
+ * PURE: the clock arrives as a field on each item, never from `Date.now()`.
322
+ * An item with no readable timestamp starts its own batch rather than being
323
+ * folded into whatever came before it — guessing a time is how four unrelated
324
+ * asks become one reply.
325
+ *
326
+ * @param {Array<object>} items
327
+ * @param {object} [o] {windowMs}
328
+ * @returns {Array<{key:string, leader:object, members:object[]}>}
329
+ */
330
+ export function groupForFirstReply(items, o = {}) {
331
+ const windowMs = Number.isFinite(o.windowMs) ? o.windowMs : DEFAULT_BATCH_WINDOW_MS;
332
+ const list = Array.isArray(items) ? items.filter((i) => i && typeof i === "object") : [];
333
+ const ordered = list
334
+ .map((item, idx) => ({ item, idx, at: atOf(item) }))
335
+ .sort((x, y) => (x.at ?? Number.POSITIVE_INFINITY) - (y.at ?? Number.POSITIVE_INFINITY) || x.idx - y.idx);
336
+
337
+ const open = new Map(); // conversationKey → {batch, lastAt}
338
+ const batches = [];
339
+ for (const { item, at } of ordered) {
340
+ const key = conversationKey(item);
341
+ const cur = open.get(key);
342
+ const joinable = cur && at != null && cur.lastAt != null && at - cur.lastAt <= windowMs;
343
+ if (joinable) {
344
+ cur.batch.members.push(item);
345
+ cur.lastAt = at;
346
+ continue;
347
+ }
348
+ const batch = { key, leader: item, members: [item] };
349
+ batches.push(batch);
350
+ open.set(key, { batch, lastAt: at });
351
+ }
352
+ return batches;
353
+ }
354
+
355
+ /**
356
+ * The per-item verdict for a whole poll: every item, with the batch it belongs
357
+ * to and whether it is the one that speaks.
358
+ *
359
+ * @param {Array<object>} items
360
+ * @param {object} [o] {windowMs}
361
+ * @returns {Array<{item:object, key:string, leader:boolean, batchSize:number, coveredByBatch:boolean}>}
362
+ */
363
+ export function firstReplyPlan(items, o = {}) {
364
+ const out = [];
365
+ for (const b of groupForFirstReply(items, o)) {
366
+ b.members.forEach((item, i) => {
367
+ out.push({
368
+ item,
369
+ key: b.key,
370
+ leader: i === 0,
371
+ batchSize: b.members.length,
372
+ coveredByBatch: i !== 0,
373
+ });
374
+ });
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /**
380
+ * Stamp a poll's worth of inbound with its flurry verdict, in place.
381
+ *
382
+ * WHY THIS IS A FUNCTION AND NOT FOUR LINES IN THE DAEMON. It used to be four
383
+ * lines inside `pollService`, and that made the wiring untestable: the only
384
+ * call site was inside a module-internal function, so deleting it left every
385
+ * test — including one named "wired, not just available" — green. A defect that
386
+ * no test can detect is the exact shape that let the previous generation of
387
+ * this fix rot unnoticed, so the loop moved here, where a test can call it, and
388
+ * `scripts/daemon/agent-daemon.pollService` calls this.
389
+ *
390
+ * MUTATES ITS INPUT, deliberately. `processItem` is called per item further
391
+ * down the same function and reads the stamp off the item it is handed; a
392
+ * returned copy would be thrown away. The mutation is confined to two fields
393
+ * this module owns.
394
+ *
395
+ * @param {Array<object>} items the poll's surviving items, in arrival order
396
+ * @param {object} [o] {windowMs}
397
+ * @returns {Array<{item:object, key:string, leader:boolean, batchSize:number, coveredByBatch:boolean}>}
398
+ * the plan, for a caller that wants to log or assert on it
399
+ */
400
+ export function stampFirstReplyPlan(items, o = {}) {
401
+ const plan = firstReplyPlan(items, o);
402
+ for (const p of plan) {
403
+ p.item.covered_by_batch = p.coveredByBatch;
404
+ p.item.batch_size = p.batchSize;
405
+ }
406
+ return plan;
407
+ }
408
+
409
+ export default {
410
+ RETIRED_FIRST_REPLIES,
411
+ SHAPES,
412
+ REACTIONS,
413
+ DEFAULT_BATCH_WINDOW_MS,
414
+ normaliseLine,
415
+ bodySegments,
416
+ isRetiredFirstReply,
417
+ repeatsItself,
418
+ asksSomething,
419
+ firstReplyShape,
420
+ groupForFirstReply,
421
+ firstReplyPlan,
422
+ stampFirstReplyPlan,
423
+ };