@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 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/");
@@ -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.0",
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 { 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
  });