@cohortapp/agent-sdk 2.18.12 → 2.18.14

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 (43) hide show
  1. package/bin/maestro.mjs +38 -1
  2. package/docs/runbooks/fleet-rollout.md +14 -7
  3. package/docs/runbooks/recovery-and-failover.md +18 -0
  4. package/lib/cadence-failure-class.mjs +245 -0
  5. package/lib/claude-bin.mjs +26 -7
  6. package/lib/cli/doctor-checks.mjs +149 -1
  7. package/lib/comms/send-gate.mjs +6 -4
  8. package/lib/diagnostics/alerts.mjs +33 -0
  9. package/lib/engine/agents/usage.mjs +45 -0
  10. package/lib/engine/budget.mjs +293 -29
  11. package/lib/engine/cli.mjs +54 -5
  12. package/lib/engine/loop.mjs +30 -0
  13. package/lib/engine/output/json.mjs +26 -0
  14. package/lib/engine/wire/errors.mjs +179 -0
  15. package/lib/engine/wire/search.mjs +44 -8
  16. package/lib/identity/claude-md.mjs +107 -0
  17. package/lib/identity/disclosure-instructions.mjs +148 -0
  18. package/lib/identity/disclosure-scrub.mjs +207 -0
  19. package/lib/identity/persona.mjs +141 -6
  20. package/lib/org/inbound/conversation-frame.mjs +289 -0
  21. package/lib/org/inbound/directedness.mjs +27 -7
  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/collect.mjs +129 -0
  28. package/lib/upgrade/pinned-drift.mjs +467 -0
  29. package/package.json +1 -1
  30. package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
  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/ci/run-tests.mjs +16 -2
  35. package/scripts/daemon/cadence-consumer.mjs +281 -34
  36. package/scripts/daemon/context-compiler.mjs +9 -1
  37. package/scripts/daemon/prompt-builder.mjs +219 -137
  38. package/scripts/daemon/responder.mjs +226 -26
  39. package/scripts/emergency-stop.sh +114 -13
  40. package/scripts/fleet/rollout.mjs +10 -3
  41. package/scripts/healthcheck.sh +131 -33
  42. package/scripts/resume-operations.sh +101 -6
  43. package/scripts/session/supervisor.mjs +198 -5
@@ -99,6 +99,28 @@
99
99
  * daemon?: { pid?: number, bootAt?: ISO8601, uptimeS?: number,
100
100
  * sdkVersion?: string, dashboardAt?: ISO8601,
101
101
  * healthy?: boolean, healthReason?: string },
102
+ * // WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT. A `.maestroignore`
103
+ * // pin on a FRAMEWORK path is a fork, and upstream moves under it:
104
+ * // five of sixteen seats were degraded by exactly this on 2026-09-25,
105
+ * // two of them crash-looping at import time on a pinned deliver.mjs.
106
+ * // `stranded` counts framework pins where upstream holds lines this
107
+ * // seat refuses (`onlyUpstream > 0` in .maestro/ignored-drift.json) —
108
+ * // that, and not "a file differs", is the property that means no
109
+ * // release can ever reach this colleague. ABSENT ENTIRELY on a seat
110
+ * // that pins nothing; `{unknown:true}` when the seat has pins and
111
+ * // could not read its own report, which must never read as clean.
112
+ * // INVENTORY: carries the `at`/`sdkVersion` of the upgrade that took
113
+ * // it, not the snapshot's.
114
+ * // `patterns` counts lines in `.maestroignore`; `matched` counts the
115
+ * // entries the drift report covers. Two numbers, two names, on both
116
+ * // branches — one glob can match forty files, or none.
117
+ * pinnedDrift?: { patterns: number|null, matched: number, framework: number,
118
+ * seatOwned: number, drifting: number, stranded: number,
119
+ * strandedCode: number, localOnly: number,
120
+ * at: ISO8601|null, sdkVersion: string|null,
121
+ * worst: [{ path: string, behind: number, code: boolean }] }
122
+ * | { unknown: true, patterns: number|null, matched: null,
123
+ * reason: string },
102
124
  * },
103
125
  * claudeAuth: "ok"|"relogin_required"|"unknown",
104
126
  * alerts: [{ id, severity, kind, detail }],
@@ -142,6 +164,8 @@ import { liveClaudeStats } from "../resource-governor.mjs";
142
164
  import { agentFirstName } from "../session/identity.mjs";
143
165
  import { snapshot as countersSnapshot } from "../diagnostics/counters.mjs";
144
166
  import { replyDebtFromCounters } from "../daemon/reply-debt.mjs";
167
+ import { summarisePinnedDrift, countPins } from "../upgrade/pinned-drift.mjs";
168
+ import { IGNORED_DRIFT_REL } from "../upgrade/ignored-drift.mjs";
145
169
 
146
170
  /** Default temperature probe ceiling — used only as a guard in alerts; here we just report. */
147
171
  const VALID_STATES = new Set(["active", "idle", "busy", "error", "offline"]);
@@ -1229,6 +1253,13 @@ const ATTENTION_WHY = {
1229
1253
  "no-heartbeat": "up, never beaten",
1230
1254
  "stale-heartbeat": "no beat since this launch",
1231
1255
  "beat-stopped": "beat, then stopped",
1256
+ // The session is not wedged — it never STARTS. Written by the supervisor
1257
+ // once N consecutive launches have failed identically
1258
+ // (scripts/session/supervisor.mjs, lib/session/launch-failure.mjs). It is a
1259
+ // different fault from the three above, with a different fix, and it is the
1260
+ // one that looked like health for days on a seat relaunching a dead resume
1261
+ // id every ten minutes.
1262
+ "launch-failing": "cannot launch",
1232
1263
  };
1233
1264
 
1234
1265
  /**
@@ -1509,6 +1540,86 @@ export function daemonSummary(dash, last) {
1509
1540
  return Object.keys(out).length > 0 ? out : null;
1510
1541
  }
1511
1542
 
1543
+ // ---------------------------------------------------------------------------
1544
+ // Pinned framework files — WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT
1545
+ // (`machine.pinnedDrift`)
1546
+ // ---------------------------------------------------------------------------
1547
+
1548
+ /**
1549
+ * Pure: the beat's `machine.pinnedDrift`.
1550
+ *
1551
+ * WHY THIS FIELD EXISTS. A `.maestroignore` entry on a framework path is a
1552
+ * fork, and upstream keeps moving under it. MEASURED 2026-09-25, repairing
1553
+ * five of sixteen seats by hand: two were crash-looping at import time on a
1554
+ * pinned `lib/org/inbound/deliver.mjs`; two more pinned
1555
+ * `scripts/daemon/agent-daemon.mjs` and therefore could not receive the
1556
+ * front-door revive fix (2.18.11/12) OR the persona fix — one of them went on
1557
+ * emitting an AI self-introduction for days after it was fixed upstream,
1558
+ * because the fix could not physically arrive.
1559
+ *
1560
+ * Every one of those seats already knew. `maestro upgrade` has written the
1561
+ * per-file answer to `.maestro/ignored-drift.json` for weeks and NOTHING read
1562
+ * it — no warning, no beat field, no alert. This is the half that makes the
1563
+ * knowledge leave the machine it is about.
1564
+ *
1565
+ * THE PROPERTY WORTH CARRYING is not "a file differs". It is "upstream holds
1566
+ * lines this pin refuses", which is `onlyUpstream > 0` on a framework path —
1567
+ * see `lib/upgrade/pinned-drift.mjs` for why (on this seat the same day, two
1568
+ * of seven drifting pins refused nothing at all, which is what a pin is FOR).
1569
+ *
1570
+ * SMALL AND BOUNDED, like every other field on `machine`: a handful of counts
1571
+ * and at most three paths, each capped (the size floor is pinned by
1572
+ * `pinned-drift-beat.test.mjs`). It rides `machine` for the reason
1573
+ * `frontDoor` and `daemon` do — hq validates `machine` as an OPEN record
1574
+ * (`presence/beat.ts#statusSchema`) while `session` is `.strict()`, so a new
1575
+ * seat-level fact lands without a lock-step hq deploy.
1576
+ *
1577
+ * THREE ANSWERS, AND THE THIRD IS THE POINT:
1578
+ * · a seat that pins nothing returns `null` — the caller drops the key, and
1579
+ * hq grows no field and no alert for a seat with nothing wrong;
1580
+ * · a seat that pins something and cannot read its own report returns
1581
+ * `{unknown:true}` — never a clean bill. "Could not tell" and "nothing to
1582
+ * tell" are opposite instructions, and collapsing them is the exact
1583
+ * failure this whole field exists to end.
1584
+ *
1585
+ * INVENTORY, not a momentary reading: the report is written by the last
1586
+ * upgrade and stays true until the next one, so it carries its own `at` and
1587
+ * the `sdkVersion` it was taken against rather than borrowing the snapshot's.
1588
+ *
1589
+ * @param {object|null} report parsed `.maestro/ignored-drift.json`, or null
1590
+ * @param {number|null} pinCount pattern lines in `.maestroignore`, or null
1591
+ * when even that could not be read
1592
+ */
1593
+ export function pinnedDriftSummary(report, pinCount) {
1594
+ return summarisePinnedDrift(report, { pinCount });
1595
+ }
1596
+
1597
+ /** `.maestro/ignored-drift.json`, or null — fail-open. */
1598
+ function readIgnoredDriftReport(agentRoot) {
1599
+ if (!agentRoot) return null;
1600
+ return safeReadJson(join(resolve(agentRoot), IGNORED_DRIFT_REL));
1601
+ }
1602
+
1603
+ /**
1604
+ * How many real patterns `.maestroignore` holds — `0` when the file is absent
1605
+ * (a seat with no pins), `null` when it exists and could not be read.
1606
+ *
1607
+ * The distinction is load-bearing: `0` is what lets the field be dropped
1608
+ * entirely, and `null` is what forces `unknown` instead of a clean answer.
1609
+ * A missing file is a definite zero; an unreadable one is not.
1610
+ */
1611
+ function readPinCount(agentRoot) {
1612
+ if (!agentRoot) return null;
1613
+ const file = join(resolve(agentRoot), ".maestroignore");
1614
+ let text;
1615
+ try {
1616
+ text = readFileSync(file, "utf8");
1617
+ } catch (e) {
1618
+ return e && e.code === "ENOENT" ? 0 : null;
1619
+ }
1620
+ return countPins(text);
1621
+ }
1622
+
1512
1623
  /** state/autoupdate/last.json, or null. */
1513
1624
  function readAutoupdateLast(agentRoot) {
1514
1625
  if (!agentRoot) return null;
@@ -1974,6 +2085,21 @@ export async function collectStatus(o = {}) {
1974
2085
  if (d) machine.daemon = d;
1975
2086
  } catch { /* no daemon field */ }
1976
2087
 
2088
+ // 4e. WHICH UPSTREAM FIXES CANNOT REACH THIS SEAT — `machine.pinnedDrift`,
2089
+ // absent on a seat that pins nothing. See `pinnedDriftSummary` for the five
2090
+ // seats this was measured on. Fail-open like every probe here: a throw drops
2091
+ // the field, never the beat — but note that a *readable* seat with pins and
2092
+ // an unreadable report deliberately lands `{unknown:true}` rather than
2093
+ // nothing, because "I could not tell" must never render as "clean".
2094
+ try {
2095
+ const pinReport = opt.ignoredDrift !== undefined
2096
+ ? opt.ignoredDrift
2097
+ : readIgnoredDriftReport(opt.agentRoot);
2098
+ const pins = opt.pinCount !== undefined ? opt.pinCount : readPinCount(opt.agentRoot);
2099
+ const pd = pinnedDriftSummary(pinReport, pins);
2100
+ if (pd) machine.pinnedDrift = pd;
2101
+ } catch { /* no pinnedDrift field */ }
2102
+
1977
2103
  const status = {
1978
2104
  state,
1979
2105
  activity: act.activity || "idle",
@@ -2026,6 +2152,9 @@ export const _internals = {
2026
2152
  upgradeSummary,
2027
2153
  parseDaemonHealthYaml,
2028
2154
  daemonSummary,
2155
+ pinnedDriftSummary,
2156
+ readIgnoredDriftReport,
2157
+ readPinCount,
2029
2158
  detectClaudeAuth,
2030
2159
  resetAuthProbeCache,
2031
2160
  scanForRelogin,
@@ -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.12",
3
+ "version": "2.18.14",
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": {
@@ -37,11 +37,33 @@ actor is needed, it is a person-shaped one: team, colleague, analyst.
37
37
 
38
38
  ## Being straight about what you are
39
39
 
40
+ **Never open a message by announcing what you are.** No standing identity
41
+ line in any shape: nothing that pairs your name with your own nature, nothing
42
+ that pairs it with the person you work for, no throat-clearing preamble whose
43
+ only job is to flag what you are before you say anything. Not on your first
44
+ post in a channel, not mid-thread, not on a correction to your own figures.
45
+ Cohort marks every AI member on every surface it has: the member row, the
46
+ directory, the mailbox list, the message header. A colleague reading you has
47
+ already seen it. The sentence tells them nothing new and reads as you
48
+ disclaiming your own work before you have said anything.
49
+
50
+ The renderings of that line live in `policies/ai-disclosure.yaml` as
51
+ `channels.<c>.identity_line`, scoped to an external first contact. They are
52
+ deliberately not reproduced here, or anywhere else in this tree: a banned
53
+ string quoted as an example is how the next author reads it in good faith and
54
+ writes it back into the product. This file is loaded into a live session's
55
+ context, so it is one of the places that would happen from.
56
+
40
57
  If someone sincerely asks whether they are dealing with an AI, say yes,
41
58
  plainly, once, and carry on. That is an honest answer to a direct question,
42
59
  not a disclaimer to attach to your work, and it does not license naming the
43
- machinery. `policies/ai-disclosure.yaml` says where proactive disclosure is
44
- required.
60
+ machinery. The two rules are one rule: **honest when asked, silent when not.**
61
+
62
+ Proactive disclosure is owed in exactly one place — an EXTERNAL first contact
63
+ — and it is not yours to decide. `policies/ai-disclosure.yaml` scopes it
64
+ (`posture.internal_recipients_exempt`, `channels.<c>.identity_scope`) and the
65
+ send-gate applies it. Inside your own workspace the duty does not arise, so
66
+ volunteering the line there is not caution; it is noise.
45
67
 
46
68
  ## When the send gate blocks you
47
69