@cohortapp/agent-sdk 2.6.0 → 2.6.2
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 +188 -1
- package/bin/maestro.test.mjs +262 -0
- package/package.json +1 -1
- package/scripts/daemon/classifier.test.mjs +18 -9
package/bin/maestro.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
readdirSync,
|
|
22
22
|
statSync,
|
|
23
23
|
lstatSync,
|
|
24
|
+
unlinkSync,
|
|
24
25
|
openSync,
|
|
25
26
|
readSync,
|
|
26
27
|
closeSync,
|
|
@@ -767,6 +768,60 @@ const UPGRADE_PATHS = [
|
|
|
767
768
|
{ path: "archetypes", mode: "smart" },
|
|
768
769
|
];
|
|
769
770
|
|
|
771
|
+
// Directories that sit under a "smart" upgrade root but hold MACHINE-GENERATED
|
|
772
|
+
// artifacts rather than framework files — launchd plists rendered per-machine
|
|
773
|
+
// from config/agent.json. Upstream ships no copy of them by design, so to the
|
|
774
|
+
// prune pass they look exactly like orphans: tracked, clean, and absent from
|
|
775
|
+
// the SDK. Git cannot tell the difference, because there is no difference to
|
|
776
|
+
// see — the intent lives in the directory, not the file state. Deleting them
|
|
777
|
+
// would destroy this machine's schedule (and would race the cadence-bus
|
|
778
|
+
// migration, which backs these same plists up and regenerates them).
|
|
779
|
+
const PRUNE_EXCLUDE_PREFIXES = [
|
|
780
|
+
"scripts/local-triggers/plists/",
|
|
781
|
+
"scripts/poller-launchd/",
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
function isPruneExcluded(repoRel) {
|
|
785
|
+
return PRUNE_EXCLUDE_PREFIXES.some((p) => repoRel.startsWith(p));
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Record of what the framework actually delivered to THIS machine, written at
|
|
789
|
+
// the end of every non-dry upgrade. Per-machine state, hence .maestro/.
|
|
790
|
+
//
|
|
791
|
+
// Prune needs to answer "did the framework once ship this file?", and git
|
|
792
|
+
// cannot answer it. A tracked-and-clean file absent from upstream is EITHER a
|
|
793
|
+
// framework file deleted upstream OR a file the operator wrote and committed
|
|
794
|
+
// themselves — the two are byte-for-byte indistinguishable in git status.
|
|
795
|
+
// Getting that wrong destroys operator work: the first cut of this pass
|
|
796
|
+
// proposed deleting an agent's pre-commit secret-scan hook (protecting a live
|
|
797
|
+
// org credential) and its hand-authored workflows, purely because the SDK had
|
|
798
|
+
// never shipped them.
|
|
799
|
+
//
|
|
800
|
+
// So provenance is recorded explicitly. Prune considers a file only if the
|
|
801
|
+
// PREVIOUS manifest lists it — proof this machine received it from the SDK.
|
|
802
|
+
// A machine with no manifest yet prunes nothing and starts clean from the next
|
|
803
|
+
// upgrade; that is the safe direction to fail.
|
|
804
|
+
const SHIPPED_MANIFEST_REL = ".maestro/shipped-manifest.json";
|
|
805
|
+
|
|
806
|
+
function loadShippedManifest(cwd) {
|
|
807
|
+
try {
|
|
808
|
+
const raw = JSON.parse(readFileSync(join(cwd, SHIPPED_MANIFEST_REL), "utf-8"));
|
|
809
|
+
if (Array.isArray(raw?.files)) return new Set(raw.files);
|
|
810
|
+
} catch {
|
|
811
|
+
// Absent or unreadable — caller treats this as "provenance unknown".
|
|
812
|
+
}
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function writeShippedManifest(cwd, files) {
|
|
817
|
+
const dst = join(cwd, SHIPPED_MANIFEST_REL);
|
|
818
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
819
|
+
writeFileSync(
|
|
820
|
+
dst,
|
|
821
|
+
JSON.stringify({ version: 1, files: [...files].sort() }, null, 2) + "\n",
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
770
825
|
function sha256File(p) {
|
|
771
826
|
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
772
827
|
}
|
|
@@ -1074,11 +1129,13 @@ function parseUpgradeFlags(args) {
|
|
|
1074
1129
|
forceOverwrite: false,
|
|
1075
1130
|
noIncoming: false,
|
|
1076
1131
|
verbose: false,
|
|
1132
|
+
noPrune: false,
|
|
1077
1133
|
};
|
|
1078
1134
|
for (const a of args) {
|
|
1079
1135
|
if (a === "--dry-run" || a === "-n") flags.dryRun = true;
|
|
1080
1136
|
else if (a === "--force-overwrite" || a === "--force") flags.forceOverwrite = true;
|
|
1081
1137
|
else if (a === "--no-incoming") flags.noIncoming = true;
|
|
1138
|
+
else if (a === "--no-prune") flags.noPrune = true;
|
|
1082
1139
|
else if (a === "--verbose" || a === "-v") flags.verbose = true;
|
|
1083
1140
|
else if (a === "--help" || a === "-h") return null;
|
|
1084
1141
|
else { fail(`Unknown flag: ${a}`); process.exit(1); }
|
|
@@ -1096,6 +1153,7 @@ Flags:
|
|
|
1096
1153
|
--dry-run, -n Preview changes without writing
|
|
1097
1154
|
--force-overwrite Overwrite even locally-modified files (backs them up)
|
|
1098
1155
|
--no-incoming Don't write .maestro/incoming/ shadows for preserved files
|
|
1156
|
+
--no-prune Keep framework files that upstream has deleted
|
|
1099
1157
|
--verbose, -v Print classification for every file
|
|
1100
1158
|
--help, -h Show this help
|
|
1101
1159
|
|
|
@@ -1108,6 +1166,9 @@ Per-file behaviour:
|
|
|
1108
1166
|
.maestro/incoming/<path> for manual diff
|
|
1109
1167
|
mergeKept — under agents/ → never overwrite (custom agents preserved)
|
|
1110
1168
|
forced — overwritten by --force-overwrite; backup at .maestro/backup/<path>
|
|
1169
|
+
pruned — deleted upstream and pristine here → removed; backup at
|
|
1170
|
+
.maestro/backup/<path>. Only ever applies to files git reports as
|
|
1171
|
+
tracked-and-clean, so your own files are never at risk.
|
|
1111
1172
|
|
|
1112
1173
|
.maestroignore format (gitignore-style, top-down, last match wins):
|
|
1113
1174
|
scripts/slack-send.sh exact file
|
|
@@ -1145,9 +1206,13 @@ Per-file behaviour:
|
|
|
1145
1206
|
const banner = flags.dryRun ? "DRY RUN — " : "";
|
|
1146
1207
|
log(`${banner}Upgrading framework files from @cohortapp/agent-sdk...`);
|
|
1147
1208
|
|
|
1148
|
-
const counts = { added: 0, updated: 0, same: 0, ignored: 0, preserved: 0, mergeKept: 0, forced: 0 };
|
|
1209
|
+
const counts = { added: 0, updated: 0, same: 0, ignored: 0, preserved: 0, mergeKept: 0, forced: 0, pruned: 0, pruneKept: 0 };
|
|
1149
1210
|
const preservedFiles = [];
|
|
1150
1211
|
const ignoredFiles = [];
|
|
1212
|
+
const prunedFiles = [];
|
|
1213
|
+
const pruneKeptFiles = [];
|
|
1214
|
+
// What upstream ships right now — becomes this machine's provenance record.
|
|
1215
|
+
const shippedNow = new Set();
|
|
1151
1216
|
|
|
1152
1217
|
for (const { path: relRoot, mode } of UPGRADE_PATHS) {
|
|
1153
1218
|
const srcRoot = join(MAESTRO_ROOT, relRoot);
|
|
@@ -1159,6 +1224,9 @@ Per-file behaviour:
|
|
|
1159
1224
|
const relFromRoot = relative(srcRoot, srcFile);
|
|
1160
1225
|
const dstFile = join(dstRoot, relFromRoot);
|
|
1161
1226
|
const repoRel = relative(cwd, dstFile).split(sep).join("/");
|
|
1227
|
+
// Recorded regardless of what we then decide to do with the file: the
|
|
1228
|
+
// manifest answers "does the framework ship this?", not "did we write it?"
|
|
1229
|
+
shippedNow.add(repoRel);
|
|
1162
1230
|
|
|
1163
1231
|
const isIgnored = matchesIgnore(repoRel, ignorePatterns);
|
|
1164
1232
|
|
|
@@ -1235,6 +1303,112 @@ Per-file behaviour:
|
|
|
1235
1303
|
}
|
|
1236
1304
|
}
|
|
1237
1305
|
|
|
1306
|
+
// ── Prune: framework files this agent still holds but upstream deleted ─────
|
|
1307
|
+
//
|
|
1308
|
+
// The copy loop above is one-directional: it adds and overwrites, but never
|
|
1309
|
+
// removes. So every file the SDK has ever shipped stays resident on an agent
|
|
1310
|
+
// machine forever. That is not merely untidy — an orphan is a *stale module*
|
|
1311
|
+
// that keeps importing symbols its collaborators no longer export, so it
|
|
1312
|
+
// fails permanently and drags the agent's own test suite red. (Found in the
|
|
1313
|
+
// field: scripts/daemon/responder.test.mjs survived the transport refactor
|
|
1314
|
+
// and kept importing a deleted `loadConversationHistory`.)
|
|
1315
|
+
//
|
|
1316
|
+
// Prune asks the converse of the copy loop's question — "does the agent hold
|
|
1317
|
+
// a file upstream deleted?" — and reuses the identical safety rules:
|
|
1318
|
+
//
|
|
1319
|
+
// * .maestroignore still wins; an ignored path is never touched.
|
|
1320
|
+
// * "merge" roots (agents/) are skipped wholesale — files there are
|
|
1321
|
+
// *supposed* to exist only locally; that is the mode's entire purpose.
|
|
1322
|
+
// * Only files git reports as tracked-and-clean are removed. dirtyPathSet()
|
|
1323
|
+
// is built with --untracked-files=all, so a path absent from it is
|
|
1324
|
+
// provably a pristine framework file the operator never touched.
|
|
1325
|
+
// Anything else — edited, untracked, operator-authored — is kept and
|
|
1326
|
+
// reported, never deleted.
|
|
1327
|
+
// * Outside a git repo we cannot prove any of the above, so prune does
|
|
1328
|
+
// nothing at all rather than guess.
|
|
1329
|
+
//
|
|
1330
|
+
// Removals are backed up under .maestro/backup/ exactly like a forced
|
|
1331
|
+
// overwrite, so a prune is always reversible.
|
|
1332
|
+
const priorManifest = loadShippedManifest(cwd);
|
|
1333
|
+
|
|
1334
|
+
if (flags.noPrune) {
|
|
1335
|
+
log("Prune skipped (--no-prune): upstream-deleted files left in place.");
|
|
1336
|
+
} else if (!inGit) {
|
|
1337
|
+
warn("Prune skipped: not a git repo, so pristine framework files can't be told from yours.");
|
|
1338
|
+
} else if (!priorManifest) {
|
|
1339
|
+
log("Prune skipped: no shipped-manifest yet, so framework files can't be told from your own.");
|
|
1340
|
+
log(`Recording one now (${SHIPPED_MANIFEST_REL}); prune takes effect from the next upgrade.`);
|
|
1341
|
+
} else {
|
|
1342
|
+
for (const { path: relRoot, mode } of UPGRADE_PATHS) {
|
|
1343
|
+
// merge-mode roots hold deliberately-local files — nothing to reconcile.
|
|
1344
|
+
if (mode === "merge") continue;
|
|
1345
|
+
const srcRoot = join(MAESTRO_ROOT, relRoot);
|
|
1346
|
+
const dstRoot = join(cwd, relRoot);
|
|
1347
|
+
// If upstream dropped the whole root, treat it as out of scope rather
|
|
1348
|
+
// than deleting an entire directory tree on the agent's machine.
|
|
1349
|
+
if (!existsSync(srcRoot) || !existsSync(dstRoot)) continue;
|
|
1350
|
+
|
|
1351
|
+
for (const dstFile of walkFiles(dstRoot)) {
|
|
1352
|
+
const relFromRoot = relative(dstRoot, dstFile);
|
|
1353
|
+
if (existsSync(join(srcRoot, relFromRoot))) continue; // still shipped
|
|
1354
|
+
|
|
1355
|
+
const repoRel = relative(cwd, dstFile).split(sep).join("/");
|
|
1356
|
+
|
|
1357
|
+
// Machine-generated, not framework-shipped — see PRUNE_EXCLUDE_PREFIXES.
|
|
1358
|
+
if (isPruneExcluded(repoRel)) {
|
|
1359
|
+
if (flags.verbose) console.log(` · ${repoRel} (machine-generated, never pruned)`);
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Provenance gate: unless a previous upgrade recorded delivering this
|
|
1364
|
+
// file, it is the operator's until proven otherwise. See the note on
|
|
1365
|
+
// SHIPPED_MANIFEST_REL — this is what stops prune eating hand-authored
|
|
1366
|
+
// scripts, workflows and policies that merely sit under a smart root.
|
|
1367
|
+
if (!priorManifest.has(repoRel)) {
|
|
1368
|
+
if (flags.verbose) console.log(` · ${repoRel} (never shipped by the framework — yours)`);
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (matchesIgnore(repoRel, ignorePatterns)) {
|
|
1373
|
+
counts.ignored++;
|
|
1374
|
+
ignoredFiles.push(repoRel);
|
|
1375
|
+
if (flags.verbose) console.log(` · ${repoRel} (orphan, ignored via .maestroignore)`);
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Dirty covers edited AND untracked — either way it isn't ours to delete.
|
|
1380
|
+
if (dirty.has(repoRel)) {
|
|
1381
|
+
counts.pruneKept++;
|
|
1382
|
+
pruneKeptFiles.push(repoRel);
|
|
1383
|
+
if (flags.verbose) console.log(` ~ ${repoRel} (deleted upstream, but yours — kept)`);
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (!flags.dryRun) {
|
|
1388
|
+
const backup = join(cwd, ".maestro", "backup", repoRel);
|
|
1389
|
+
mkdirSync(dirname(backup), { recursive: true });
|
|
1390
|
+
copyFileSync(dstFile, backup);
|
|
1391
|
+
unlinkSync(dstFile);
|
|
1392
|
+
}
|
|
1393
|
+
counts.pruned++;
|
|
1394
|
+
prunedFiles.push(repoRel);
|
|
1395
|
+
if (flags.verbose) warn(`- ${repoRel} (deleted upstream; backup written)`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Record provenance for the next run. Written even when nothing was pruned —
|
|
1401
|
+
// it is what makes the FOLLOWING upgrade able to prune safely. Never written
|
|
1402
|
+
// on a dry run, so --dry-run stays genuinely side-effect-free.
|
|
1403
|
+
if (!flags.dryRun) {
|
|
1404
|
+
try {
|
|
1405
|
+
writeShippedManifest(cwd, shippedNow);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
// Non-fatal: a missing manifest costs pruning, not correctness.
|
|
1408
|
+
warn(`Could not write ${SHIPPED_MANIFEST_REL}: ${err.message}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1238
1412
|
// Ensure standard runtime directories exist.
|
|
1239
1413
|
const ensureDirs = [
|
|
1240
1414
|
"state/handoffs", "state/huddle", "state/indexes", "state/rag",
|
|
@@ -1383,8 +1557,21 @@ Per-file behaviour:
|
|
|
1383
1557
|
if (counts.mergeKept) console.log(` ~ ${counts.mergeKept} merge-mode kept (agents/ custom files preserved)`);
|
|
1384
1558
|
if (counts.preserved) warn(`${counts.preserved} preserved (local edits — kept your version)`);
|
|
1385
1559
|
if (counts.forced) warn(`${counts.forced} force-overwritten (backups in .maestro/backup/)`);
|
|
1560
|
+
if (counts.pruned) ok(`${counts.pruned} pruned (deleted upstream; backups in .maestro/backup/)`);
|
|
1561
|
+
if (counts.pruneKept) warn(`${counts.pruneKept} deleted upstream but kept (yours — edited or untracked)`);
|
|
1386
1562
|
if (newDirs) ok(`${newDirs} new directories created`);
|
|
1387
1563
|
|
|
1564
|
+
if (prunedFiles.length && flags.verbose === false) {
|
|
1565
|
+
for (const p of prunedFiles.slice(0, 5)) console.log(` - ${p}`);
|
|
1566
|
+
if (prunedFiles.length > 5) console.log(` … and ${prunedFiles.length - 5} more`);
|
|
1567
|
+
}
|
|
1568
|
+
if (pruneKeptFiles.length) {
|
|
1569
|
+
console.log();
|
|
1570
|
+
log("These are gone from the framework but still present here — delete if you no longer want them:");
|
|
1571
|
+
for (const p of pruneKeptFiles.slice(0, 5)) console.log(` ${p}`);
|
|
1572
|
+
if (pruneKeptFiles.length > 5) console.log(` … and ${pruneKeptFiles.length - 5} more`);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1388
1575
|
if (preservedFiles.length && !flags.noIncoming && !flags.dryRun) {
|
|
1389
1576
|
console.log();
|
|
1390
1577
|
log("Upstream versions of your locally-modified files saved to .maestro/incoming/");
|
package/bin/maestro.test.mjs
CHANGED
|
@@ -1140,3 +1140,265 @@ test("doctor surfaces the new framework systems (org / secrets / diagnostics)",
|
|
|
1140
1140
|
await rmRoot(shim);
|
|
1141
1141
|
}
|
|
1142
1142
|
});
|
|
1143
|
+
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// upgrade — prune (framework files deleted upstream)
|
|
1146
|
+
//
|
|
1147
|
+
// The copy loop only ever adds and overwrites, so before this every file the
|
|
1148
|
+
// SDK had ever shipped stayed resident on an agent machine forever. Orphaned
|
|
1149
|
+
// *modules* keep importing symbols their collaborators no longer export, so
|
|
1150
|
+
// they fail permanently and drag the agent's own suite red — found in the
|
|
1151
|
+
// field on Isla's machine, where a stale scripts/daemon/responder.test.mjs
|
|
1152
|
+
// survived the transport refactor still importing a deleted export.
|
|
1153
|
+
//
|
|
1154
|
+
// Prune's contract is narrow on purpose: it removes a file only when git
|
|
1155
|
+
// proves it is tracked-and-clean, i.e. pristine framework material the
|
|
1156
|
+
// operator never touched. Everything else is kept and reported.
|
|
1157
|
+
// ---------------------------------------------------------------------------
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Agent repo carrying `orphans` — paths upstream does not ship. Written before
|
|
1161
|
+
* the commit so git sees them as tracked and clean (prune's precondition).
|
|
1162
|
+
*/
|
|
1163
|
+
async function makeAgentWithOrphans(orphans, opts = {}) {
|
|
1164
|
+
const root = await tmpRoot("maestro-orphan");
|
|
1165
|
+
mkdirSync(join(root, "config"), { recursive: true });
|
|
1166
|
+
writeFileSync(join(root, "CLAUDE.md"), "# agent\n");
|
|
1167
|
+
writeFileSync(join(root, "config/agent.ts"), "export const AGENT = { firstName: 'orphan' };\n");
|
|
1168
|
+
writeFileSync(join(root, "package.json"), '{"name":"orphan-agent","version":"1.0.0"}\n');
|
|
1169
|
+
for (const [rel, body] of Object.entries(orphans)) {
|
|
1170
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
1171
|
+
writeFileSync(join(root, rel), body);
|
|
1172
|
+
}
|
|
1173
|
+
// Provenance: prune only touches files a PREVIOUS upgrade recorded shipping.
|
|
1174
|
+
// Without this the orphans are indistinguishable from operator-authored work
|
|
1175
|
+
// and prune correctly refuses to act — so every prune test must seed it.
|
|
1176
|
+
if (opts.seedManifest !== false) {
|
|
1177
|
+
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1178
|
+
writeFileSync(
|
|
1179
|
+
join(root, ".maestro/shipped-manifest.json"),
|
|
1180
|
+
JSON.stringify({ version: 1, files: Object.keys(orphans).sort() }, null, 2) + "\n",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
1185
|
+
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1186
|
+
execFileSync(
|
|
1187
|
+
"git",
|
|
1188
|
+
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
|
|
1189
|
+
{ cwd: root },
|
|
1190
|
+
);
|
|
1191
|
+
return root;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const ORPHAN = "lib/zz-deleted-upstream-fixture.mjs";
|
|
1195
|
+
|
|
1196
|
+
test("upgrade prunes a pristine framework file deleted upstream, with a backup", async () => {
|
|
1197
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1198
|
+
try {
|
|
1199
|
+
const r = runCli(["upgrade"], root);
|
|
1200
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1201
|
+
assert.ok(!existsSync(join(root, ORPHAN)), "orphan should have been pruned");
|
|
1202
|
+
assert.ok(
|
|
1203
|
+
existsSync(join(root, ".maestro/backup", ORPHAN)),
|
|
1204
|
+
"prune must be reversible — a backup is required",
|
|
1205
|
+
);
|
|
1206
|
+
assert.equal(
|
|
1207
|
+
readFileSync(join(root, ".maestro/backup", ORPHAN), "utf-8"),
|
|
1208
|
+
"export const gone = 1;\n",
|
|
1209
|
+
"backup must hold the original bytes",
|
|
1210
|
+
);
|
|
1211
|
+
} finally {
|
|
1212
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test("upgrade never prunes a file the operator edited", async () => {
|
|
1217
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1218
|
+
try {
|
|
1219
|
+
writeFileSync(join(root, ORPHAN), "export const gone = 2; // my change\n");
|
|
1220
|
+
const r = runCli(["upgrade"], root);
|
|
1221
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1222
|
+
assert.ok(existsSync(join(root, ORPHAN)), "an edited orphan is the operator's, not ours to delete");
|
|
1223
|
+
assert.match(
|
|
1224
|
+
readFileSync(join(root, ORPHAN), "utf-8"),
|
|
1225
|
+
/my change/,
|
|
1226
|
+
"the operator's bytes must survive verbatim",
|
|
1227
|
+
);
|
|
1228
|
+
assert.match(r.stdout, /kept/i, "the kept orphan must be reported, not silently left");
|
|
1229
|
+
} finally {
|
|
1230
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
test("upgrade never prunes an untracked file", async () => {
|
|
1235
|
+
const root = await makeAgentWithOrphans({});
|
|
1236
|
+
try {
|
|
1237
|
+
// Written after the commit → untracked → indistinguishable from operator work.
|
|
1238
|
+
mkdirSync(join(root, "lib"), { recursive: true });
|
|
1239
|
+
writeFileSync(join(root, ORPHAN), "export const mine = 1;\n");
|
|
1240
|
+
const r = runCli(["upgrade"], root);
|
|
1241
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1242
|
+
assert.ok(existsSync(join(root, ORPHAN)), "untracked files are never prune candidates");
|
|
1243
|
+
} finally {
|
|
1244
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1245
|
+
}
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("upgrade --no-prune leaves upstream-deleted files in place", async () => {
|
|
1249
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1250
|
+
try {
|
|
1251
|
+
const r = runCli(["upgrade", "--no-prune"], root);
|
|
1252
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1253
|
+
assert.ok(existsSync(join(root, ORPHAN)), "--no-prune must suppress removal entirely");
|
|
1254
|
+
} finally {
|
|
1255
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test("upgrade --dry-run reports a prune without performing it", async () => {
|
|
1260
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
|
|
1261
|
+
try {
|
|
1262
|
+
const r = runCli(["upgrade", "--dry-run"], root);
|
|
1263
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1264
|
+
assert.ok(existsSync(join(root, ORPHAN)), "dry run must not delete");
|
|
1265
|
+
assert.ok(!existsSync(join(root, ".maestro/backup", ORPHAN)), "dry run must not write backups");
|
|
1266
|
+
assert.match(r.stdout, /prune/i, "dry run must still tell the operator what it would remove");
|
|
1267
|
+
} finally {
|
|
1268
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
test("upgrade honours .maestroignore over prune", async () => {
|
|
1273
|
+
const root = await makeAgentWithOrphans({
|
|
1274
|
+
[ORPHAN]: "export const gone = 1;\n",
|
|
1275
|
+
".maestroignore": `${ORPHAN}\n`,
|
|
1276
|
+
});
|
|
1277
|
+
try {
|
|
1278
|
+
const r = runCli(["upgrade"], root);
|
|
1279
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1280
|
+
assert.ok(existsSync(join(root, ORPHAN)), ".maestroignore must win over prune, as it does over overwrite");
|
|
1281
|
+
} finally {
|
|
1282
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
test("upgrade prune leaves files that upstream still ships", async () => {
|
|
1287
|
+
const root = await makeAgentWithOrphans({});
|
|
1288
|
+
try {
|
|
1289
|
+
const r = runCli(["upgrade"], root);
|
|
1290
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1291
|
+
// A file the SDK genuinely ships must survive — the guard against a prune
|
|
1292
|
+
// that mistakes "present upstream" for "absent" and empties the repo.
|
|
1293
|
+
assert.ok(
|
|
1294
|
+
existsSync(join(root, "lib/identity/persona.mjs")),
|
|
1295
|
+
"currently-shipped framework files must never be pruned",
|
|
1296
|
+
);
|
|
1297
|
+
assert.ok(existsSync(join(root, "scripts/daemon/responder.mjs")));
|
|
1298
|
+
} finally {
|
|
1299
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test("upgrade prune never touches merge-mode agents/", async () => {
|
|
1304
|
+
const custom = "agents/my-private-agent.md";
|
|
1305
|
+
const root = await makeAgentWithOrphans({ [custom]: "# mine\n" });
|
|
1306
|
+
try {
|
|
1307
|
+
const r = runCli(["upgrade"], root);
|
|
1308
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1309
|
+
assert.ok(
|
|
1310
|
+
existsSync(join(root, custom)),
|
|
1311
|
+
"agents/ is merge-mode: local-only files there are the point, not orphans",
|
|
1312
|
+
);
|
|
1313
|
+
} finally {
|
|
1314
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
test("upgrade prune never removes machine-generated launchd plists", async () => {
|
|
1319
|
+
// A committed plist is tracked, clean, and absent upstream — it satisfies
|
|
1320
|
+
// every safety check prune has, yet it is this machine's schedule, not
|
|
1321
|
+
// framework material. Regression: an early prune deleted the very plist the
|
|
1322
|
+
// cadence-bus migration was about to back up and regenerate.
|
|
1323
|
+
const plist = "scripts/local-triggers/plists/ai.adaptic.some-trigger.plist";
|
|
1324
|
+
const root = await makeAgentWithOrphans({ [plist]: "<?xml version=\"1.0\"?><plist/>" });
|
|
1325
|
+
try {
|
|
1326
|
+
const r = runCli(["upgrade"], root);
|
|
1327
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1328
|
+
assert.ok(
|
|
1329
|
+
existsSync(join(root, plist)),
|
|
1330
|
+
"generated plists are machine config; prune must leave them alone",
|
|
1331
|
+
);
|
|
1332
|
+
} finally {
|
|
1333
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
test("upgrade prune spares operator-authored files the framework never shipped", async () => {
|
|
1338
|
+
// The hazard that forced the provenance manifest. scripts/ is a smart root,
|
|
1339
|
+
// so an operator's own committed script there is tracked, clean and absent
|
|
1340
|
+
// upstream — every signal git can offer says "orphan". On a real agent
|
|
1341
|
+
// machine this set included the pre-commit hook guarding a live org
|
|
1342
|
+
// credential. Provenance, not cleanliness, is what makes prune safe.
|
|
1343
|
+
const mine = "scripts/pre-commit-secret-scan.sh";
|
|
1344
|
+
const root = await makeAgentWithOrphans({
|
|
1345
|
+
[mine]: "#!/bin/sh\nexit 0\n",
|
|
1346
|
+
[ORPHAN]: "export const gone = 1;\n",
|
|
1347
|
+
});
|
|
1348
|
+
try {
|
|
1349
|
+
// Manifest records ONLY the framework file — the operator's script was
|
|
1350
|
+
// never delivered by the SDK, so it must not be a prune candidate.
|
|
1351
|
+
writeFileSync(
|
|
1352
|
+
join(root, ".maestro/shipped-manifest.json"),
|
|
1353
|
+
JSON.stringify({ version: 1, files: [ORPHAN] }, null, 2) + "\n",
|
|
1354
|
+
);
|
|
1355
|
+
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1356
|
+
execFileSync(
|
|
1357
|
+
"git",
|
|
1358
|
+
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "manifest"],
|
|
1359
|
+
{ cwd: root },
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const r = runCli(["upgrade"], root);
|
|
1363
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1364
|
+
assert.ok(existsSync(join(root, mine)), "a file the framework never shipped is the operator's");
|
|
1365
|
+
assert.ok(!existsSync(join(root, ORPHAN)), "the genuinely-shipped orphan should still prune");
|
|
1366
|
+
} finally {
|
|
1367
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("upgrade prunes nothing on a machine with no shipped-manifest, then records one", async () => {
|
|
1372
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" }, { seedManifest: false });
|
|
1373
|
+
try {
|
|
1374
|
+
const first = runCli(["upgrade"], root);
|
|
1375
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1376
|
+
assert.ok(
|
|
1377
|
+
existsSync(join(root, ORPHAN)),
|
|
1378
|
+
"with no provenance record, prune must do nothing rather than guess",
|
|
1379
|
+
);
|
|
1380
|
+
assert.ok(
|
|
1381
|
+
existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1382
|
+
"the first run must record provenance so the next one can prune",
|
|
1383
|
+
);
|
|
1384
|
+
const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
|
|
1385
|
+
assert.ok(manifest.files.includes("scripts/daemon/responder.mjs"), "manifest lists shipped files");
|
|
1386
|
+
assert.ok(!manifest.files.includes(ORPHAN), "manifest must not list what upstream doesn't ship");
|
|
1387
|
+
} finally {
|
|
1388
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("upgrade --dry-run writes no shipped-manifest", async () => {
|
|
1393
|
+
const root = await makeAgentWithOrphans({}, { seedManifest: false });
|
|
1394
|
+
try {
|
|
1395
|
+
const r = runCli(["upgrade", "--dry-run"], root);
|
|
1396
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1397
|
+
assert.ok(
|
|
1398
|
+
!existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1399
|
+
"--dry-run must stay side-effect-free, including provenance",
|
|
1400
|
+
);
|
|
1401
|
+
} finally {
|
|
1402
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1403
|
+
}
|
|
1404
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"description": "Cohort Agent SDK \u2014 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": {
|
|
@@ -25,21 +25,30 @@
|
|
|
25
25
|
|
|
26
26
|
import { test } from "node:test";
|
|
27
27
|
import assert from "node:assert/strict";
|
|
28
|
-
import {
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
29
|
|
|
30
30
|
import * as classifier from "./classifier.mjs";
|
|
31
31
|
|
|
32
32
|
// ── 1. Imports cleanly with no openai package present ───────────────────────
|
|
33
33
|
|
|
34
|
-
test("classifier.mjs imports cleanly
|
|
34
|
+
test("classifier.mjs imports cleanly and does not depend on openai", () => {
|
|
35
35
|
// The module-level import above already succeeded (this file loaded), which
|
|
36
|
-
// is the load-bearing assertion.
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
// is the load-bearing assertion.
|
|
37
|
+
//
|
|
38
|
+
// The invariant is about THIS MODULE's source, not the ambient node_modules
|
|
39
|
+
// tree. An earlier version asserted `require.resolve("openai")` throws, but
|
|
40
|
+
// that is a proxy that only holds inside the SDK's own checkout: every agent
|
|
41
|
+
// repo that pulls openai in transitively made this fail while the real
|
|
42
|
+
// invariant stayed true. A guard that goes red on every downstream machine
|
|
43
|
+
// just teaches operators to ignore red suites, so assert on the source.
|
|
44
|
+
const src = readFileSync(new URL("./classifier.mjs", import.meta.url), "utf-8");
|
|
45
|
+
const importsOpenai = /(?:^|\n)\s*import[^\n]*["']openai["']/.test(src)
|
|
46
|
+
|| /\bimport\(\s*["']openai["']\s*\)/.test(src)
|
|
47
|
+
|| /\brequire\(\s*["']openai["']\s*\)/.test(src);
|
|
48
|
+
assert.equal(
|
|
49
|
+
importsOpenai,
|
|
50
|
+
false,
|
|
51
|
+
"classifier.mjs must not import openai — a hard dependency here broke the whole module on agents that lack it"
|
|
43
52
|
);
|
|
44
53
|
assert.equal(typeof classifier.classifyItem, "function", "classifyItem is exported");
|
|
45
54
|
});
|