@cohortapp/agent-sdk 2.6.0 → 2.6.1

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 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,23 @@ 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
+
770
788
  function sha256File(p) {
771
789
  return createHash("sha256").update(readFileSync(p)).digest("hex");
772
790
  }
@@ -1074,11 +1092,13 @@ function parseUpgradeFlags(args) {
1074
1092
  forceOverwrite: false,
1075
1093
  noIncoming: false,
1076
1094
  verbose: false,
1095
+ noPrune: false,
1077
1096
  };
1078
1097
  for (const a of args) {
1079
1098
  if (a === "--dry-run" || a === "-n") flags.dryRun = true;
1080
1099
  else if (a === "--force-overwrite" || a === "--force") flags.forceOverwrite = true;
1081
1100
  else if (a === "--no-incoming") flags.noIncoming = true;
1101
+ else if (a === "--no-prune") flags.noPrune = true;
1082
1102
  else if (a === "--verbose" || a === "-v") flags.verbose = true;
1083
1103
  else if (a === "--help" || a === "-h") return null;
1084
1104
  else { fail(`Unknown flag: ${a}`); process.exit(1); }
@@ -1096,6 +1116,7 @@ Flags:
1096
1116
  --dry-run, -n Preview changes without writing
1097
1117
  --force-overwrite Overwrite even locally-modified files (backs them up)
1098
1118
  --no-incoming Don't write .maestro/incoming/ shadows for preserved files
1119
+ --no-prune Keep framework files that upstream has deleted
1099
1120
  --verbose, -v Print classification for every file
1100
1121
  --help, -h Show this help
1101
1122
 
@@ -1108,6 +1129,9 @@ Per-file behaviour:
1108
1129
  .maestro/incoming/<path> for manual diff
1109
1130
  mergeKept — under agents/ → never overwrite (custom agents preserved)
1110
1131
  forced — overwritten by --force-overwrite; backup at .maestro/backup/<path>
1132
+ pruned — deleted upstream and pristine here → removed; backup at
1133
+ .maestro/backup/<path>. Only ever applies to files git reports as
1134
+ tracked-and-clean, so your own files are never at risk.
1111
1135
 
1112
1136
  .maestroignore format (gitignore-style, top-down, last match wins):
1113
1137
  scripts/slack-send.sh exact file
@@ -1145,9 +1169,11 @@ Per-file behaviour:
1145
1169
  const banner = flags.dryRun ? "DRY RUN — " : "";
1146
1170
  log(`${banner}Upgrading framework files from @cohortapp/agent-sdk...`);
1147
1171
 
1148
- const counts = { added: 0, updated: 0, same: 0, ignored: 0, preserved: 0, mergeKept: 0, forced: 0 };
1172
+ const counts = { added: 0, updated: 0, same: 0, ignored: 0, preserved: 0, mergeKept: 0, forced: 0, pruned: 0, pruneKept: 0 };
1149
1173
  const preservedFiles = [];
1150
1174
  const ignoredFiles = [];
1175
+ const prunedFiles = [];
1176
+ const pruneKeptFiles = [];
1151
1177
 
1152
1178
  for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1153
1179
  const srcRoot = join(MAESTRO_ROOT, relRoot);
@@ -1235,6 +1261,86 @@ Per-file behaviour:
1235
1261
  }
1236
1262
  }
1237
1263
 
1264
+ // ── Prune: framework files this agent still holds but upstream deleted ─────
1265
+ //
1266
+ // The copy loop above is one-directional: it adds and overwrites, but never
1267
+ // removes. So every file the SDK has ever shipped stays resident on an agent
1268
+ // machine forever. That is not merely untidy — an orphan is a *stale module*
1269
+ // that keeps importing symbols its collaborators no longer export, so it
1270
+ // fails permanently and drags the agent's own test suite red. (Found in the
1271
+ // field: scripts/daemon/responder.test.mjs survived the transport refactor
1272
+ // and kept importing a deleted `loadConversationHistory`.)
1273
+ //
1274
+ // Prune asks the converse of the copy loop's question — "does the agent hold
1275
+ // a file upstream deleted?" — and reuses the identical safety rules:
1276
+ //
1277
+ // * .maestroignore still wins; an ignored path is never touched.
1278
+ // * "merge" roots (agents/) are skipped wholesale — files there are
1279
+ // *supposed* to exist only locally; that is the mode's entire purpose.
1280
+ // * Only files git reports as tracked-and-clean are removed. dirtyPathSet()
1281
+ // is built with --untracked-files=all, so a path absent from it is
1282
+ // provably a pristine framework file the operator never touched.
1283
+ // Anything else — edited, untracked, operator-authored — is kept and
1284
+ // reported, never deleted.
1285
+ // * Outside a git repo we cannot prove any of the above, so prune does
1286
+ // nothing at all rather than guess.
1287
+ //
1288
+ // Removals are backed up under .maestro/backup/ exactly like a forced
1289
+ // overwrite, so a prune is always reversible.
1290
+ if (flags.noPrune) {
1291
+ log("Prune skipped (--no-prune): upstream-deleted files left in place.");
1292
+ } else if (!inGit) {
1293
+ warn("Prune skipped: not a git repo, so pristine framework files can't be told from yours.");
1294
+ } else {
1295
+ for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1296
+ // merge-mode roots hold deliberately-local files — nothing to reconcile.
1297
+ if (mode === "merge") continue;
1298
+ const srcRoot = join(MAESTRO_ROOT, relRoot);
1299
+ const dstRoot = join(cwd, relRoot);
1300
+ // If upstream dropped the whole root, treat it as out of scope rather
1301
+ // than deleting an entire directory tree on the agent's machine.
1302
+ if (!existsSync(srcRoot) || !existsSync(dstRoot)) continue;
1303
+
1304
+ for (const dstFile of walkFiles(dstRoot)) {
1305
+ const relFromRoot = relative(dstRoot, dstFile);
1306
+ if (existsSync(join(srcRoot, relFromRoot))) continue; // still shipped
1307
+
1308
+ const repoRel = relative(cwd, dstFile).split(sep).join("/");
1309
+
1310
+ // Machine-generated, not framework-shipped — see PRUNE_EXCLUDE_PREFIXES.
1311
+ if (isPruneExcluded(repoRel)) {
1312
+ if (flags.verbose) console.log(` · ${repoRel} (machine-generated, never pruned)`);
1313
+ continue;
1314
+ }
1315
+
1316
+ if (matchesIgnore(repoRel, ignorePatterns)) {
1317
+ counts.ignored++;
1318
+ ignoredFiles.push(repoRel);
1319
+ if (flags.verbose) console.log(` · ${repoRel} (orphan, ignored via .maestroignore)`);
1320
+ continue;
1321
+ }
1322
+
1323
+ // Dirty covers edited AND untracked — either way it isn't ours to delete.
1324
+ if (dirty.has(repoRel)) {
1325
+ counts.pruneKept++;
1326
+ pruneKeptFiles.push(repoRel);
1327
+ if (flags.verbose) console.log(` ~ ${repoRel} (deleted upstream, but yours — kept)`);
1328
+ continue;
1329
+ }
1330
+
1331
+ if (!flags.dryRun) {
1332
+ const backup = join(cwd, ".maestro", "backup", repoRel);
1333
+ mkdirSync(dirname(backup), { recursive: true });
1334
+ copyFileSync(dstFile, backup);
1335
+ unlinkSync(dstFile);
1336
+ }
1337
+ counts.pruned++;
1338
+ prunedFiles.push(repoRel);
1339
+ if (flags.verbose) warn(`- ${repoRel} (deleted upstream; backup written)`);
1340
+ }
1341
+ }
1342
+ }
1343
+
1238
1344
  // Ensure standard runtime directories exist.
1239
1345
  const ensureDirs = [
1240
1346
  "state/handoffs", "state/huddle", "state/indexes", "state/rag",
@@ -1383,8 +1489,21 @@ Per-file behaviour:
1383
1489
  if (counts.mergeKept) console.log(` ~ ${counts.mergeKept} merge-mode kept (agents/ custom files preserved)`);
1384
1490
  if (counts.preserved) warn(`${counts.preserved} preserved (local edits — kept your version)`);
1385
1491
  if (counts.forced) warn(`${counts.forced} force-overwritten (backups in .maestro/backup/)`);
1492
+ if (counts.pruned) ok(`${counts.pruned} pruned (deleted upstream; backups in .maestro/backup/)`);
1493
+ if (counts.pruneKept) warn(`${counts.pruneKept} deleted upstream but kept (yours — edited or untracked)`);
1386
1494
  if (newDirs) ok(`${newDirs} new directories created`);
1387
1495
 
1496
+ if (prunedFiles.length && flags.verbose === false) {
1497
+ for (const p of prunedFiles.slice(0, 5)) console.log(` - ${p}`);
1498
+ if (prunedFiles.length > 5) console.log(` … and ${prunedFiles.length - 5} more`);
1499
+ }
1500
+ if (pruneKeptFiles.length) {
1501
+ console.log();
1502
+ log("These are gone from the framework but still present here — delete if you no longer want them:");
1503
+ for (const p of pruneKeptFiles.slice(0, 5)) console.log(` ${p}`);
1504
+ if (pruneKeptFiles.length > 5) console.log(` … and ${pruneKeptFiles.length - 5} more`);
1505
+ }
1506
+
1388
1507
  if (preservedFiles.length && !flags.noIncoming && !flags.dryRun) {
1389
1508
  console.log();
1390
1509
  log("Upstream versions of your locally-modified files saved to .maestro/incoming/");
@@ -1140,3 +1140,185 @@ 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) {
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
+ execFileSync("git", ["init", "-q"], { cwd: root });
1174
+ execFileSync("git", ["add", "-A"], { cwd: root });
1175
+ execFileSync(
1176
+ "git",
1177
+ ["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "init"],
1178
+ { cwd: root },
1179
+ );
1180
+ return root;
1181
+ }
1182
+
1183
+ const ORPHAN = "lib/zz-deleted-upstream-fixture.mjs";
1184
+
1185
+ test("upgrade prunes a pristine framework file deleted upstream, with a backup", async () => {
1186
+ const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
1187
+ try {
1188
+ const r = runCli(["upgrade"], root);
1189
+ assert.equal(r.status, 0, r.stderr);
1190
+ assert.ok(!existsSync(join(root, ORPHAN)), "orphan should have been pruned");
1191
+ assert.ok(
1192
+ existsSync(join(root, ".maestro/backup", ORPHAN)),
1193
+ "prune must be reversible — a backup is required",
1194
+ );
1195
+ assert.equal(
1196
+ readFileSync(join(root, ".maestro/backup", ORPHAN), "utf-8"),
1197
+ "export const gone = 1;\n",
1198
+ "backup must hold the original bytes",
1199
+ );
1200
+ } finally {
1201
+ await fsp.rm(root, { recursive: true, force: true });
1202
+ }
1203
+ });
1204
+
1205
+ test("upgrade never prunes a file the operator edited", async () => {
1206
+ const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
1207
+ try {
1208
+ writeFileSync(join(root, ORPHAN), "export const gone = 2; // my change\n");
1209
+ const r = runCli(["upgrade"], root);
1210
+ assert.equal(r.status, 0, r.stderr);
1211
+ assert.ok(existsSync(join(root, ORPHAN)), "an edited orphan is the operator's, not ours to delete");
1212
+ assert.match(
1213
+ readFileSync(join(root, ORPHAN), "utf-8"),
1214
+ /my change/,
1215
+ "the operator's bytes must survive verbatim",
1216
+ );
1217
+ assert.match(r.stdout, /kept/i, "the kept orphan must be reported, not silently left");
1218
+ } finally {
1219
+ await fsp.rm(root, { recursive: true, force: true });
1220
+ }
1221
+ });
1222
+
1223
+ test("upgrade never prunes an untracked file", async () => {
1224
+ const root = await makeAgentWithOrphans({});
1225
+ try {
1226
+ // Written after the commit → untracked → indistinguishable from operator work.
1227
+ mkdirSync(join(root, "lib"), { recursive: true });
1228
+ writeFileSync(join(root, ORPHAN), "export const mine = 1;\n");
1229
+ const r = runCli(["upgrade"], root);
1230
+ assert.equal(r.status, 0, r.stderr);
1231
+ assert.ok(existsSync(join(root, ORPHAN)), "untracked files are never prune candidates");
1232
+ } finally {
1233
+ await fsp.rm(root, { recursive: true, force: true });
1234
+ }
1235
+ });
1236
+
1237
+ test("upgrade --no-prune leaves upstream-deleted files in place", async () => {
1238
+ const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
1239
+ try {
1240
+ const r = runCli(["upgrade", "--no-prune"], root);
1241
+ assert.equal(r.status, 0, r.stderr);
1242
+ assert.ok(existsSync(join(root, ORPHAN)), "--no-prune must suppress removal entirely");
1243
+ } finally {
1244
+ await fsp.rm(root, { recursive: true, force: true });
1245
+ }
1246
+ });
1247
+
1248
+ test("upgrade --dry-run reports a prune without performing it", async () => {
1249
+ const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" });
1250
+ try {
1251
+ const r = runCli(["upgrade", "--dry-run"], root);
1252
+ assert.equal(r.status, 0, r.stderr);
1253
+ assert.ok(existsSync(join(root, ORPHAN)), "dry run must not delete");
1254
+ assert.ok(!existsSync(join(root, ".maestro/backup", ORPHAN)), "dry run must not write backups");
1255
+ assert.match(r.stdout, /prune/i, "dry run must still tell the operator what it would remove");
1256
+ } finally {
1257
+ await fsp.rm(root, { recursive: true, force: true });
1258
+ }
1259
+ });
1260
+
1261
+ test("upgrade honours .maestroignore over prune", async () => {
1262
+ const root = await makeAgentWithOrphans({
1263
+ [ORPHAN]: "export const gone = 1;\n",
1264
+ ".maestroignore": `${ORPHAN}\n`,
1265
+ });
1266
+ try {
1267
+ const r = runCli(["upgrade"], root);
1268
+ assert.equal(r.status, 0, r.stderr);
1269
+ assert.ok(existsSync(join(root, ORPHAN)), ".maestroignore must win over prune, as it does over overwrite");
1270
+ } finally {
1271
+ await fsp.rm(root, { recursive: true, force: true });
1272
+ }
1273
+ });
1274
+
1275
+ test("upgrade prune leaves files that upstream still ships", async () => {
1276
+ const root = await makeAgentWithOrphans({});
1277
+ try {
1278
+ const r = runCli(["upgrade"], root);
1279
+ assert.equal(r.status, 0, r.stderr);
1280
+ // A file the SDK genuinely ships must survive — the guard against a prune
1281
+ // that mistakes "present upstream" for "absent" and empties the repo.
1282
+ assert.ok(
1283
+ existsSync(join(root, "lib/identity/persona.mjs")),
1284
+ "currently-shipped framework files must never be pruned",
1285
+ );
1286
+ assert.ok(existsSync(join(root, "scripts/daemon/responder.mjs")));
1287
+ } finally {
1288
+ await fsp.rm(root, { recursive: true, force: true });
1289
+ }
1290
+ });
1291
+
1292
+ test("upgrade prune never touches merge-mode agents/", async () => {
1293
+ const custom = "agents/my-private-agent.md";
1294
+ const root = await makeAgentWithOrphans({ [custom]: "# mine\n" });
1295
+ try {
1296
+ const r = runCli(["upgrade"], root);
1297
+ assert.equal(r.status, 0, r.stderr);
1298
+ assert.ok(
1299
+ existsSync(join(root, custom)),
1300
+ "agents/ is merge-mode: local-only files there are the point, not orphans",
1301
+ );
1302
+ } finally {
1303
+ await fsp.rm(root, { recursive: true, force: true });
1304
+ }
1305
+ });
1306
+
1307
+ test("upgrade prune never removes machine-generated launchd plists", async () => {
1308
+ // A committed plist is tracked, clean, and absent upstream — it satisfies
1309
+ // every safety check prune has, yet it is this machine's schedule, not
1310
+ // framework material. Regression: an early prune deleted the very plist the
1311
+ // cadence-bus migration was about to back up and regenerate.
1312
+ const plist = "scripts/local-triggers/plists/ai.adaptic.some-trigger.plist";
1313
+ const root = await makeAgentWithOrphans({ [plist]: "<?xml version=\"1.0\"?><plist/>" });
1314
+ try {
1315
+ const r = runCli(["upgrade"], root);
1316
+ assert.equal(r.status, 0, r.stderr);
1317
+ assert.ok(
1318
+ existsSync(join(root, plist)),
1319
+ "generated plists are machine config; prune must leave them alone",
1320
+ );
1321
+ } finally {
1322
+ await fsp.rm(root, { recursive: true, force: true });
1323
+ }
1324
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
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 { createRequire } from "node:module";
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 with no openai package installed", () => {
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. Double-check openai is genuinely absent so
37
- // this test fails loudly if someone reintroduces the hard dependency.
38
- const require = createRequire(import.meta.url);
39
- assert.throws(
40
- () => require.resolve("openai"),
41
- /Cannot find module 'openai'|Cannot find package 'openai'/,
42
- "openai must NOT be resolvable — the classifier must not depend on it"
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
  });