@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,467 @@
1
+ /**
2
+ * lib/upgrade/pinned-drift.mjs — a `.maestroignore` pin that has STRANDED an
3
+ * upstream fix, said loudly enough that somebody acts on it.
4
+ *
5
+ * ── THE FAULT THIS EXISTS TO END ────────────────────────────────────────────
6
+ * `.maestroignore` tells `maestro upgrade` never to touch a path. That is the
7
+ * entry's whole purpose and it is usually right: a seat's own `config/`,
8
+ * `CLAUDE.md`, `knowledge/` and `memory/` are the agent, not the framework,
9
+ * and nothing should ever overwrite them. (The upgrade does not ship those
10
+ * paths at all, so pinning them is belt-and-braces, not a fork.)
11
+ *
12
+ * A pin on a FRAMEWORK path is a different animal. It is a FORK: the seat's
13
+ * copy stops moving while the libraries it imports keep going, and the seat
14
+ * eventually dies at import time —
15
+ *
16
+ * SyntaxError: The requested module './deliver.mjs' does not provide an
17
+ * export named 'cohortSurfaceIsDeclared'
18
+ *
19
+ * MEASURED 2026-09-25, repairing five of sixteen seats by hand. Candace Wong
20
+ * and Layla Al-Masri were crash-looping on a pinned `deliver.mjs`. Jacob Stein
21
+ * and Isla Roselli pinned `scripts/daemon/agent-daemon.mjs`, which is why the
22
+ * front-door revive fix (2.18.11/12) and the persona fix never reached them —
23
+ * Jacob went on emitting an AI self-introduction for DAYS after it was fixed
24
+ * upstream, because the fix could not physically arrive.
25
+ *
26
+ * ── THE PART THAT STINGS ────────────────────────────────────────────────────
27
+ * Every one of those seats already KNEW. `lib/upgrade/ignored-drift.mjs` has
28
+ * computed the per-file answer for weeks and `maestro upgrade` writes it to
29
+ * `.maestro/ignored-drift.json` on every run. Nothing read it: no warning that
30
+ * distinguished a dangerous pin from a harmless one, no field on the presence
31
+ * beat, no alert. The knowledge sat on the disk of the machine it was about.
32
+ *
33
+ * This module is the reading half. It is PURE over the report
34
+ * `ignored-drift.mjs` already produces, and it answers ONE question the raw
35
+ * report does not: **is upstream carrying lines this seat cannot receive?**
36
+ *
37
+ * ── WHY `onlyUpstream`, NOT "drifts" ────────────────────────────────────────
38
+ * `drifts` is the wrong alarm and it would cry wolf on every healthy fork.
39
+ * Measured on this seat the same day: seven pinned files, all seven `drifts`,
40
+ * and TWO of them (`scripts/healthcheck.sh`, `scripts/system-verify.sh`) had
41
+ * `onlyUpstream: 0` — the local file is a strict superset of upstream, upstream
42
+ * holds nothing the seat lacks, and the pin is stranding precisely nothing.
43
+ * That is what a pin is FOR and paging about it teaches the reader to ignore
44
+ * the page.
45
+ *
46
+ * `onlyUpstream > 0` is the honest predicate: upstream has lines this file does
47
+ * not, the pin refuses them, and no release will ever change that. A file in
48
+ * that state is called STRANDED here, and it is the only thing worth waking
49
+ * somebody for.
50
+ *
51
+ * It is a LOWER BOUND, not a patch: the line counts are a multiset difference
52
+ * (see `ignored-drift.mjs`), so a moved block counts as zero. A stranded file
53
+ * is therefore certainly stranded; a non-stranded one is merely not visibly so.
54
+ * The claim this module makes is only ever the first of those.
55
+ *
56
+ * @module lib/upgrade/pinned-drift
57
+ */
58
+
59
+ "use strict";
60
+
61
+ /**
62
+ * Paths the SEAT owns. `maestro upgrade` ships none of them, so a pin here
63
+ * protects nothing and strands nothing — it is inert, and it should STAY.
64
+ *
65
+ * Listed so the warning can say so out loud. The measured failure mode of a
66
+ * loud drift warning is an operator who empties `.maestroignore` to silence it
67
+ * and loses their own agent in the process; naming these as safe is what stops
68
+ * that being the obvious next move.
69
+ */
70
+ export const SEAT_OWNED_PREFIXES = Object.freeze([
71
+ "config/",
72
+ "knowledge/",
73
+ "memory/",
74
+ "state/",
75
+ "logs/",
76
+ "notes/",
77
+ "journal/",
78
+ ".maestro/",
79
+ ]);
80
+
81
+ /** Seat-owned single files, matched exactly. */
82
+ export const SEAT_OWNED_FILES = Object.freeze([
83
+ "CLAUDE.md",
84
+ "AGENTS.md",
85
+ ".env",
86
+ ".maestroignore",
87
+ ]);
88
+
89
+ /**
90
+ * Trees `maestro upgrade` actually ships, from this package's own `files`
91
+ * list. A pin under one of these is a FORK of framework code or framework
92
+ * prompts, and upstream will move under it.
93
+ *
94
+ * Kept as data rather than read from `package.json` at runtime because this
95
+ * module is pure and is also evaluated against a report produced by a
96
+ * DIFFERENT (older) SDK than the one reading it — the seat's report names
97
+ * paths, not a manifest, and the classification must not change depending on
98
+ * which package happens to be installed where the check runs.
99
+ *
100
+ * PREFIXES ARE NOT THE WHOLE MANIFEST — see {@link SHIPPED_ROOT_FILES}. The
101
+ * hand-copied list is pinned against the real `files` array by this module's
102
+ * test, because the failure mode of a hand-copied manifest is silence: a
103
+ * shipped path this list forgets is classified `other`, strands nothing by
104
+ * construction, and prints no warning at all.
105
+ */
106
+ export const FRAMEWORK_PREFIXES = Object.freeze([
107
+ "bin/",
108
+ "lib/",
109
+ "scripts/",
110
+ "schedules/",
111
+ "workflows/",
112
+ "agents/",
113
+ "policies/",
114
+ "teams/",
115
+ "archetypes/",
116
+ "scaffold/",
117
+ "mcp/",
118
+ "ingest/",
119
+ "desktop-control/",
120
+ "plugins/",
121
+ "public/",
122
+ "docs/",
123
+ ".claude/",
124
+ ]);
125
+
126
+ /**
127
+ * Files `maestro upgrade` ships at the ROOT of the agent directory, matched
128
+ * exactly. Three of them, and every one is a live framework surface.
129
+ *
130
+ * THIS SET IS WHY THE PREFIX LIST ALONE IS A BUG. `package.json#files` carries
131
+ * `framework-features.json` (21KB of feature declarations an upgrade would
132
+ * otherwise replace wholesale), `README.md` and `.env.example` beside the
133
+ * trees. A prefix-only classifier calls all three `other`, which means
134
+ * `isStranded` is false for them, which means a seat that pins ONLY
135
+ * `framework-features.json` while upstream moves under it prints nothing —
136
+ * byte-identical to the output of a seat that pins nothing at all. Before this
137
+ * module existed that seat got a blanket drift warning; the first cut of this
138
+ * module took the warning away and gave it no replacement.
139
+ *
140
+ * `.env.example` is NOT `.env`: the seat owns the latter (see
141
+ * {@link SEAT_OWNED_FILES}) and upgrade ships the former. They are matched
142
+ * exactly, so the two never collide.
143
+ */
144
+ export const SHIPPED_ROOT_FILES = Object.freeze([
145
+ "framework-features.json",
146
+ "README.md",
147
+ ".env.example",
148
+ ]);
149
+
150
+ /** Where the report lives, quoted in the overflow line. */
151
+ const IGNORED_DRIFT_HINT = ".maestro/ignored-drift.json";
152
+
153
+ /** Extensions whose rot is an IMPORT-TIME DEATH rather than a stale sentence. */
154
+ const CODE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs", ".ts", ".sh", ".py"]);
155
+
156
+ /**
157
+ * Who owns this pinned path.
158
+ *
159
+ * "seat" the agent's own content — upgrade never ships it, pin is inert
160
+ * "framework" a tree upgrade ships — the pin is a fork and upstream moves
161
+ * "other" neither; upgrade ships nothing there, so nothing can strand
162
+ *
163
+ * Seat-owned wins over framework on a tie, and there is one real tie:
164
+ * `.claude/` is shipped AND edited by hand. It is classified framework because
165
+ * that is where the danger is; a seat that wants its own command file there
166
+ * keeps the pin and accepts the fork, which the warning says.
167
+ *
168
+ * @param {string} path repo-relative path as the report records it
169
+ * @returns {"seat"|"framework"|"other"}
170
+ */
171
+ export function pinClass(path) {
172
+ const p = String(path || "").replace(/^\.\//, "");
173
+ if (p === "") return "other";
174
+ if (SEAT_OWNED_FILES.includes(p)) return "seat";
175
+ for (const pre of SEAT_OWNED_PREFIXES) if (p.startsWith(pre)) return "seat";
176
+ if (SHIPPED_ROOT_FILES.includes(p)) return "framework";
177
+ for (const pre of FRAMEWORK_PREFIXES) if (p.startsWith(pre)) return "framework";
178
+ return "other";
179
+ }
180
+
181
+ /**
182
+ * Ownership of a REPORTED pin, with the report's own evidence outranking the
183
+ * hand-copied manifest above.
184
+ *
185
+ * ── WHY EVIDENCE BEATS THE LIST ─────────────────────────────────────────────
186
+ * {@link pinClass} knows the shipped set by NAME, and a list of names is the
187
+ * thing that goes stale — quietly, in the direction of saying nothing, which
188
+ * is exactly how `framework-features.json` fell out of the warning. The REPORT
189
+ * does not guess: `ignored-drift.mjs` only records `drifts`, `identical` or
190
+ * `upstream-only` for a path where it actually READ an upstream copy out of
191
+ * the installed package. Any of those three states is proof that upgrade ships
192
+ * that path, whatever this module's prefixes happen to say.
193
+ *
194
+ * So a path the list does not name, whose report says upstream holds a copy,
195
+ * is framework. `local-only` is the one state that proves the opposite
196
+ * (upstream ships nothing there) and stays `other`; a seat-owned path stays
197
+ * seat-owned, since upgrade shipping something under `config/` would be a bug
198
+ * upstream rather than a fork here.
199
+ *
200
+ * @param {{path?:string, state?:string}} file one entry of the report
201
+ * @returns {"seat"|"framework"|"other"}
202
+ */
203
+ export function pinClassOf(file) {
204
+ const cls = pinClass(file && file.path);
205
+ if (cls !== "other") return cls;
206
+ const state = file && file.state;
207
+ if (state === "drifts" || state === "identical" || state === "upstream-only") {
208
+ return "framework";
209
+ }
210
+ return "other";
211
+ }
212
+
213
+ /** Would rot here kill the seat at import time, rather than merely age? */
214
+ export function isCodePin(path) {
215
+ const p = String(path || "").toLowerCase();
216
+ return CODE_EXTENSIONS.some((ext) => p.endsWith(ext));
217
+ }
218
+
219
+ /**
220
+ * Is upstream carrying lines this pinned file cannot receive?
221
+ *
222
+ * TRUE only for a FRAMEWORK pin ({@link pinClassOf}, so a shipped path this
223
+ * module's prefix list forgot still counts) in state `drifts` with
224
+ * `onlyUpstream > 0`. A
225
+ * `local-only` file strands nothing (upstream ships nothing there), an
226
+ * `identical` one is an idle entry, and an `upstream-only` one was never taken
227
+ * in the first place — none of those can hide a fix.
228
+ *
229
+ * @param {{path?:string, state?:string, onlyUpstream?:number}} file
230
+ */
231
+ export function isStranded(file) {
232
+ if (!file || typeof file !== "object") return false;
233
+ if (file.state !== "drifts") return false;
234
+ if (!(Number(file.onlyUpstream) > 0)) return false;
235
+ return pinClassOf(file) === "framework";
236
+ }
237
+
238
+ /** How many `worst` entries the summary ever carries. A beat is not a log. */
239
+ export const WORST_LIMIT = 3;
240
+ /** Longest path the summary carries; a path is a path, not prose. */
241
+ export const PATH_MAX = 120;
242
+
243
+ /**
244
+ * Fold a `.maestro/ignored-drift.json` report into the bounded shape the
245
+ * presence beat and the upgrade warning both read.
246
+ *
247
+ * ── THE THREE ANSWERS, AND WHY "UNKNOWN" IS ONE OF THEM ─────────────────────
248
+ * A reader must be able to tell "this seat has no pins" from "this seat could
249
+ * not tell me". They are opposite instructions — the first is nothing to do,
250
+ * the second is go and look — and collapsing them is the failure the whole
251
+ * drift report already suffered from once. So:
252
+ *
253
+ * null the seat pins NOTHING. No field, no alert (and the
254
+ * caller is expected to drop the key entirely).
255
+ * {unknown:true} the seat HAS pins and the report could not be read.
256
+ * Never reads as clean.
257
+ * a summary counts, and the worst few paths.
258
+ *
259
+ * `pinCount` is what the caller counted in `.maestroignore` itself — the only
260
+ * evidence that pins exist when the report is missing. Pass `null` when even
261
+ * that could not be read, and the answer is `unknown` as well: an unreadable
262
+ * `.maestroignore` on a seat is not proof of an empty one.
263
+ *
264
+ * ── TWO COUNTS, TWO NAMES ───────────────────────────────────────────────────
265
+ * `patterns` and `matched` are different numbers and this shape used to call
266
+ * both of them `pins`, which made the field mean one thing on the unknown
267
+ * branch (pattern lines in `.maestroignore`) and another on the readable one
268
+ * (files the report covers). Measured: a seat with a 5-pattern `.maestroignore`
269
+ * matching 2 real files reported `pins: 2` when its report was readable and
270
+ * `pins: 5` when it was not, under one label, to a human. A glob is not a file
271
+ * and three patterns that match nothing are worth SEEING — so:
272
+ *
273
+ * patterns non-comment lines in `.maestroignore`; `null` if unreadable.
274
+ * matched entries the drift report carries; `null` under `unknown`.
275
+ *
276
+ * Both are present on both branches, so no reader has to know which branch it
277
+ * is on to know which number it is holding.
278
+ *
279
+ * @param {object|null} report parsed `.maestro/ignored-drift.json`, or null
280
+ * @param {{pinCount?:number|null, limit?:number}} [opt]
281
+ * @returns {{unknown:true, patterns:number|null, matched:null, reason:string}
282
+ * | {patterns:number|null, matched:number, framework:number,
283
+ * seatOwned:number, drifting:number, stranded:number,
284
+ * strandedCode:number, localOnly:number,
285
+ * worst:Array<{path:string, behind:number, code:boolean}>,
286
+ * at:string|null, sdkVersion:string|null}
287
+ * | null}
288
+ */
289
+ export function summarisePinnedDrift(report, opt = {}) {
290
+ const pinCount = opt.pinCount === undefined ? null : opt.pinCount;
291
+ const limit = Number.isInteger(opt.limit) && opt.limit > 0 ? opt.limit : WORST_LIMIT;
292
+
293
+ const files =
294
+ report && typeof report === "object" && Array.isArray(report.files)
295
+ ? report.files.filter((f) => f && typeof f === "object" && typeof f.path === "string")
296
+ : null;
297
+
298
+ if (files === null) {
299
+ // No readable report. Whether that is "nothing to report" or "could not
300
+ // tell" is decided by `.maestroignore`, which the caller counted.
301
+ if (pinCount === 0) return null;
302
+ return {
303
+ unknown: true,
304
+ patterns: typeof pinCount === "number" ? pinCount : null,
305
+ matched: null,
306
+ reason:
307
+ pinCount === null
308
+ ? "neither .maestroignore nor .maestro/ignored-drift.json could be read"
309
+ : ".maestro/ignored-drift.json is missing or unreadable — run `maestro upgrade` to write it",
310
+ };
311
+ }
312
+
313
+ if (files.length === 0 && (pinCount === null || pinCount === 0)) return null;
314
+
315
+ let framework = 0;
316
+ let seatOwned = 0;
317
+ let drifting = 0;
318
+ let stranded = 0;
319
+ let strandedCode = 0;
320
+ let localOnly = 0;
321
+ const worstAll = [];
322
+ for (const f of files) {
323
+ const cls = pinClassOf(f);
324
+ if (cls === "framework") framework += 1;
325
+ else if (cls === "seat") seatOwned += 1;
326
+ if (f.state === "local-only") localOnly += 1;
327
+ if (f.state === "drifts" && cls === "framework") drifting += 1;
328
+ if (isStranded(f)) {
329
+ stranded += 1;
330
+ const code = isCodePin(f.path);
331
+ if (code) strandedCode += 1;
332
+ worstAll.push({
333
+ path: String(f.path).slice(0, PATH_MAX),
334
+ behind: Math.floor(Number(f.onlyUpstream)),
335
+ code,
336
+ });
337
+ }
338
+ }
339
+
340
+ // Worst = most upstream lines refused. Code before prose on a tie, because a
341
+ // stranded `.mjs` is an import-time death and a stranded `.md` is a stale
342
+ // sentence; path last so the list is stable across runs.
343
+ worstAll.sort(
344
+ (a, b) =>
345
+ b.behind - a.behind ||
346
+ Number(b.code) - Number(a.code) ||
347
+ a.path.localeCompare(b.path)
348
+ );
349
+
350
+ return {
351
+ patterns: typeof pinCount === "number" ? pinCount : null,
352
+ matched: files.length,
353
+ framework,
354
+ seatOwned,
355
+ drifting,
356
+ stranded,
357
+ strandedCode,
358
+ localOnly,
359
+ worst: worstAll.slice(0, limit),
360
+ at: typeof report.at === "string" ? report.at : null,
361
+ sdkVersion: typeof report.sdkVersion === "string" ? report.sdkVersion : null,
362
+ };
363
+ }
364
+
365
+ /** Did this summary find a fix that cannot land? */
366
+ export function isStrandedSummary(summary) {
367
+ return Boolean(summary) && summary.unknown !== true && summary.stranded > 0;
368
+ }
369
+
370
+ /**
371
+ * The loud, specific warning `maestro upgrade` prints last.
372
+ *
373
+ * Three obligations, each of which a shorter message failed at least once:
374
+ *
375
+ * 1. NAME THE FILE AND THE DISTANCE. "14 ignored (.maestroignore protected)"
376
+ * is what the summary said for months while two seats crash-looped. A
377
+ * count is not an instruction.
378
+ * 2. GIVE THE EXACT REMEDY, runnable. The diff command against the installed
379
+ * package is the whole fix, and nobody composes it from memory.
380
+ * 3. SAY WHAT MUST NOT BE UNPINNED. The obvious response to a scary drift
381
+ * warning is to empty `.maestroignore`, which deletes the seat's own
382
+ * forks along with the rotten ones. `config/`, `CLAUDE.md`, `knowledge/`
383
+ * and `memory/` are never shipped by upgrade and must stay pinned.
384
+ *
385
+ * Returns LINES, not a printed block, so the caller owns colour and the test
386
+ * can read the words.
387
+ *
388
+ * @param {object|null} summary {@link summarisePinnedDrift}'s output
389
+ * @param {{pkg?:string}} [opt] installed package root for the diff hint
390
+ * @returns {string[]}
391
+ */
392
+ export function formatPinnedDriftWarning(summary, opt = {}) {
393
+ if (!summary) return [];
394
+ const pkg = opt.pkg || "node_modules/@cohortapp/agent-sdk";
395
+ const out = [];
396
+
397
+ if (summary.unknown === true) {
398
+ out.push(
399
+ `.maestroignore pins ${summary.patterns === null ? "files" : `${summary.patterns} pattern${summary.patterns === 1 ? "" : "s"}`} and their drift could not be read — ${summary.reason}.`
400
+ );
401
+ out.push(
402
+ " Until it can be read, treat every pinned framework file as possibly stranding an upstream fix."
403
+ );
404
+ return out;
405
+ }
406
+
407
+ if (summary.stranded === 0) {
408
+ // Not silence: the operator asked for the report by having pins at all,
409
+ // and "your pins are currently stranding nothing" is the sentence that
410
+ // makes the loud version believable when it does arrive.
411
+ if (summary.framework > 0) {
412
+ out.push(
413
+ `${summary.framework} pinned framework file${summary.framework === 1 ? "" : "s"}, none stranding an upstream line. Nothing to port.`
414
+ );
415
+ }
416
+ return out;
417
+ }
418
+
419
+ const n = summary.stranded;
420
+ out.push(
421
+ `${n} PINNED FRAMEWORK FILE${n === 1 ? "" : "S"} ${n === 1 ? "IS" : "ARE"} STRANDING UPSTREAM FIXES — .maestroignore refuses them, so no release can ever reach ${n === 1 ? "it" : "them"}:`
422
+ );
423
+ for (const f of summary.worst) {
424
+ out.push(
425
+ ` ~ ${f.path} — ${f.behind} upstream line${f.behind === 1 ? "" : "s"} refused${f.code ? " (code: this is how a seat dies at import time)" : ""}`
426
+ );
427
+ }
428
+ if (n > summary.worst.length) {
429
+ out.push(` … and ${n - summary.worst.length} more in ${IGNORED_DRIFT_HINT}`);
430
+ }
431
+ out.push(" Remedy, per file:");
432
+ out.push(` diff -u ${pkg}/<path> <path> # what this seat is refusing`);
433
+ out.push(" …port the upstream change in, then keep the pin; or delete the");
434
+ out.push(" entry from .maestroignore and let the next upgrade take the file.");
435
+ out.push(
436
+ " Do NOT empty .maestroignore to clear this. config/, CLAUDE.md, knowledge/"
437
+ );
438
+ out.push(
439
+ " and memory/ are never shipped by upgrade — pinning them costs nothing and"
440
+ );
441
+ out.push(" should stay. Only a pin on a FRAMEWORK path can rot.");
442
+ if (summary.localOnly > 0) {
443
+ out.push(
444
+ ` (${summary.localOnly} pinned path${summary.localOnly === 1 ? " is" : "s are"} local-only — upstream ships nothing there, so ${summary.localOnly === 1 ? "it strands" : "they strand"} nothing. Different thing; leave ${summary.localOnly === 1 ? "it" : "them"} alone.)`
445
+ );
446
+ }
447
+ return out;
448
+ }
449
+
450
+ /**
451
+ * Count real pattern lines in a `.maestroignore`. Comments and blanks are not
452
+ * pins; a file of nothing but comments pins nothing, which must read as `0`
453
+ * (no field) and not as `unknown`.
454
+ *
455
+ * @param {string|null} text file contents, or null when it could not be read
456
+ * @returns {number|null}
457
+ */
458
+ export function countPins(text) {
459
+ if (typeof text !== "string") return null;
460
+ let n = 0;
461
+ for (const raw of text.split("\n")) {
462
+ const line = raw.trim();
463
+ if (line === "" || line.startsWith("#")) continue;
464
+ n += 1;
465
+ }
466
+ return n;
467
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.18.13",
3
+ "version": "2.18.15",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,13 @@ alerts:
15
15
  # Cost guardrails (USD).
16
16
  costPerSessionP99USD: 2.50 # p99 session cost over the daily ledger
17
17
  dailySpendWarnUSD: 20 # soft warn before the hard budget cap
18
+ # A cadence failed for a cause that retrying cannot fix — its prompt file is
19
+ # missing or unreadable. Fires at ONE: a permanent failure means a scheduled
20
+ # obligation has stopped and will not restart until a person puts the file
21
+ # back. (Nested form, matching the built-in defaults.)
22
+ cadencePermanentFailure:
23
+ warnCount: 1
24
+ critCount: 3
18
25
  # Liveness (seconds).
19
26
  absentAgentSec: 900 # a peer with no heartbeat in 15min
20
27
  daemonHealthStaleSec: 180 # daemon health.json older than 3min
@@ -0,0 +1,96 @@
1
+ /**
2
+ * check-cadence-prompts-exist.mjs — every built-in cadence's prompt file is on disk.
3
+ *
4
+ * WHY THIS GUARD EXISTS. On 2026-09-24 Eli Rosenberg's seat scheduled the
5
+ * cadence `commitment-sweep` with `prompt: schedules/triggers/commitment-sweep.md`,
6
+ * and that file was not there. The consumer never checked: the handoff renderer
7
+ * threw ENOENT, the catch fell through to the spawn — which opens the same path
8
+ * — and the tick went back on the bus. Measured at 16 requeues per 30 seconds,
9
+ * indefinitely, until an emergency stop halted the whole seat to contain it.
10
+ *
11
+ * Two fixes, and this is the cheap half. The runtime half is
12
+ * `lib/cadence-failure-class.mjs` + the consumer's `failPermanently`: a failure
13
+ * whose cause cannot change by waiting stops, records itself and alerts,
14
+ * instead of retrying at poll speed. But a cadence the FRAMEWORK ships should
15
+ * never reach that path at all — its prompt is a file in this repo, so its
16
+ * absence is a build-time fact, and a build-time fact belongs in a check rather
17
+ * than in a seat's log at 3 a.m.
18
+ *
19
+ * Scope, stated honestly: this guard covers the hardcoded CADENCE_REGISTRY in
20
+ * `scripts/daemon/cadence-handlers.mjs` — the cadences every seat gets. It
21
+ * CANNOT cover a seat's `config/.cadence-registry.json` (written by the plan
22
+ * compiler on the seat, naming prompts the seat is meant to author), which is
23
+ * exactly where Eli's came from. That case is the runtime half's job.
24
+ *
25
+ * Usage (standalone): `node scripts/ci/check-cadence-prompts-exist.mjs`
26
+ * exit 0 → every registry prompt resolves; exit 1 → one is missing.
27
+ *
28
+ * @module scripts/ci/check-cadence-prompts-exist
29
+ */
30
+
31
+ "use strict";
32
+
33
+ import { existsSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ /** Repo root: two levels up from scripts/ci/. @type {string} */
38
+ const REPO_ROOT = path.resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
39
+
40
+ /**
41
+ * Check every hardcoded cadence definition that names a prompt.
42
+ *
43
+ * @param {object} [opts]
44
+ * @param {string} [opts.cwd=REPO_ROOT]
45
+ * @param {object} [opts.registry] pre-supplied registry (skips the import)
46
+ * @returns {Promise<{ok:boolean, checked:number, missing:Array<{cadence:string, prompt:string, resolved:string}>}>}
47
+ */
48
+ export async function checkCadencePromptsExist(opts = {}) {
49
+ const cwd = opts.cwd || REPO_ROOT;
50
+ let registry = opts.registry;
51
+ if (!registry) {
52
+ const mod = await import(path.join(cwd, "scripts/daemon/cadence-handlers.mjs"));
53
+ registry = mod.CADENCE_REGISTRY || {};
54
+ }
55
+ const missing = [];
56
+ let checked = 0;
57
+ for (const [cadence, def] of Object.entries(registry)) {
58
+ const prompt = def && typeof def.prompt === "string" ? def.prompt : null;
59
+ if (!prompt) continue; // inline/guarded-only cadences have no prompt to ship
60
+ checked++;
61
+ const resolved = path.resolve(cwd, prompt);
62
+ if (!existsSync(resolved)) missing.push({ cadence, prompt, resolved });
63
+ }
64
+ return { ok: missing.length === 0, checked, missing };
65
+ }
66
+
67
+ /**
68
+ * Run the check and print a human report.
69
+ * @param {string} [cwd=REPO_ROOT]
70
+ * @returns {Promise<number>} 0 = ok, 1 = a prompt is missing
71
+ */
72
+ export async function run(cwd = REPO_ROOT) {
73
+ const { ok, checked, missing } = await checkCadencePromptsExist({ cwd });
74
+ if (ok) {
75
+ console.log(`check-cadence-prompts-exist: OK (${checked} built-in cadence prompt(s) on disk)`);
76
+ return 0;
77
+ }
78
+ console.error("check-cadence-prompts-exist: FAIL — a shipped cadence names a prompt that is not in the repo:");
79
+ for (const m of missing) {
80
+ console.error(` ${m.cadence} → "${m.prompt}" (not found: ${m.resolved})`);
81
+ }
82
+ console.error(
83
+ `check-cadence-prompts-exist: ${missing.length} missing of ${checked} checked. ` +
84
+ "On a seat this is not a log line — it is a cadence that stops, loudly, every time it is due."
85
+ );
86
+ return 1;
87
+ }
88
+
89
+ if (import.meta.url === `file://${process.argv[1]}`) {
90
+ run()
91
+ .then((code) => process.exit(code))
92
+ .catch((err) => {
93
+ console.error("check-cadence-prompts-exist: ERROR", err && err.message ? err.message : err);
94
+ process.exit(2);
95
+ });
96
+ }
@@ -22,6 +22,7 @@
22
22
  * - check-subagent-frontmatter: every agents/*.md passes the registry validator
23
23
  * - check-durable-write-seam : durable JSON writes under lib/ go through fs-atomic
24
24
  * - check-skill-packs : vendored design skill packs match their pinned manifests
25
+ * - check-cadence-prompts-exist : every built-in cadence's prompt file is on disk
25
26
  *
26
27
  * Usage: `node scripts/ci/check.mjs`
27
28
  * exit 0 → all checks passed; exit 1 → one or more failed.
@@ -42,6 +43,7 @@ import { run as runDocsAccuracy } from "./check-docs-accuracy.mjs";
42
43
  import { run as runSubagentFrontmatter } from "./check-subagent-frontmatter.mjs";
43
44
  import { run as runDurableWriteSeam } from "./check-durable-write-seam.mjs";
44
45
  import { run as runSkillPacks } from "./check-skill-packs.mjs";
46
+ import { run as runCadencePrompts } from "./check-cadence-prompts-exist.mjs";
45
47
 
46
48
  /**
47
49
  * The ordered list of guards this aggregator runs.
@@ -60,6 +62,7 @@ export const CHECKS = [
60
62
  { name: "check-subagent-frontmatter", run: runSubagentFrontmatter },
61
63
  { name: "check-durable-write-seam", run: runDurableWriteSeam },
62
64
  { name: "check-skill-packs", run: runSkillPacks },
65
+ { name: "check-cadence-prompts-exist", run: runCadencePrompts },
63
66
  ];
64
67
 
65
68
  /**