@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.
- package/bin/maestro.mjs +38 -1
- package/docs/runbooks/fleet-rollout.md +14 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- 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 +6 -4
- 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/claude-md.mjs +107 -0
- package/lib/identity/disclosure-instructions.mjs +148 -0
- package/lib/identity/disclosure-scrub.mjs +207 -0
- package/lib/identity/persona.mjs +141 -6
- package/lib/org/inbound/conversation-frame.mjs +289 -0
- package/lib/org/inbound/directedness.mjs +27 -7
- 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/collect.mjs +129 -0
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- 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/ci/run-tests.mjs +16 -2
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +10 -3
- package/scripts/healthcheck.sh +131 -33
- package/scripts/resume-operations.sh +101 -6
- 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.
|
|
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.
|
|
44
|
-
|
|
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
|
|