@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.
- package/bin/maestro.mjs +38 -1
- package/docs/runbooks/fleet-rollout.md +58 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/assurance/batch.mjs +353 -0
- package/lib/assurance/first-reply.mjs +423 -0
- package/lib/assurance/notice-voice.mjs +357 -0
- package/lib/assurance/plan-note.mjs +43 -0
- package/lib/assurance/room-budget.mjs +55 -6
- package/lib/cadence-failure-class.mjs +245 -0
- package/lib/claude-bin.mjs +26 -7
- package/lib/cli/doctor-checks.mjs +149 -1
- package/lib/comms/send-gate.mjs +59 -0
- package/lib/diagnostics/alerts.mjs +33 -0
- package/lib/engine/agents/usage.mjs +45 -0
- package/lib/engine/budget.mjs +293 -29
- package/lib/engine/cli.mjs +54 -5
- package/lib/engine/loop.mjs +30 -0
- package/lib/engine/output/json.mjs +26 -0
- package/lib/engine/wire/errors.mjs +179 -0
- package/lib/engine/wire/search.mjs +44 -8
- package/lib/identity/persona.mjs +31 -2
- package/lib/org/quota.mjs +27 -0
- package/lib/session/config.mjs +4 -0
- package/lib/session/identity.mjs +71 -7
- package/lib/session/launch-failure.mjs +251 -0
- package/lib/session/resume-target.mjs +86 -0
- package/lib/telemetry/alerts.mjs +94 -0
- package/lib/telemetry/collect.mjs +155 -2
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/scaffold/config/alerts.yaml +7 -0
- package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon.mjs +75 -5
- package/scripts/daemon/assurance.mjs +709 -44
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/deliver.mjs +109 -0
- package/scripts/daemon/dispatcher.mjs +21 -3
- package/scripts/daemon/inbox-deferral.mjs +102 -9
- package/scripts/daemon/session-lock.mjs +41 -1
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +256 -10
- package/scripts/healthcheck.sh +131 -33
- package/scripts/local-triggers/autoupdate.sh +144 -11
- package/scripts/resume-operations.sh +101 -6
- 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.
|
|
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
|
+
}
|
package/scripts/ci/check.mjs
CHANGED
|
@@ -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
|
/**
|