@cohortapp/agent-sdk 2.6.1 → 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
@@ -785,6 +785,43 @@ function isPruneExcluded(repoRel) {
785
785
  return PRUNE_EXCLUDE_PREFIXES.some((p) => repoRel.startsWith(p));
786
786
  }
787
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
+
788
825
  function sha256File(p) {
789
826
  return createHash("sha256").update(readFileSync(p)).digest("hex");
790
827
  }
@@ -1174,6 +1211,8 @@ Per-file behaviour:
1174
1211
  const ignoredFiles = [];
1175
1212
  const prunedFiles = [];
1176
1213
  const pruneKeptFiles = [];
1214
+ // What upstream ships right now — becomes this machine's provenance record.
1215
+ const shippedNow = new Set();
1177
1216
 
1178
1217
  for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1179
1218
  const srcRoot = join(MAESTRO_ROOT, relRoot);
@@ -1185,6 +1224,9 @@ Per-file behaviour:
1185
1224
  const relFromRoot = relative(srcRoot, srcFile);
1186
1225
  const dstFile = join(dstRoot, relFromRoot);
1187
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);
1188
1230
 
1189
1231
  const isIgnored = matchesIgnore(repoRel, ignorePatterns);
1190
1232
 
@@ -1287,10 +1329,15 @@ Per-file behaviour:
1287
1329
  //
1288
1330
  // Removals are backed up under .maestro/backup/ exactly like a forced
1289
1331
  // overwrite, so a prune is always reversible.
1332
+ const priorManifest = loadShippedManifest(cwd);
1333
+
1290
1334
  if (flags.noPrune) {
1291
1335
  log("Prune skipped (--no-prune): upstream-deleted files left in place.");
1292
1336
  } else if (!inGit) {
1293
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.`);
1294
1341
  } else {
1295
1342
  for (const { path: relRoot, mode } of UPGRADE_PATHS) {
1296
1343
  // merge-mode roots hold deliberately-local files — nothing to reconcile.
@@ -1313,6 +1360,15 @@ Per-file behaviour:
1313
1360
  continue;
1314
1361
  }
1315
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
+
1316
1372
  if (matchesIgnore(repoRel, ignorePatterns)) {
1317
1373
  counts.ignored++;
1318
1374
  ignoredFiles.push(repoRel);
@@ -1341,6 +1397,18 @@ Per-file behaviour:
1341
1397
  }
1342
1398
  }
1343
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
+
1344
1412
  // Ensure standard runtime directories exist.
1345
1413
  const ensureDirs = [
1346
1414
  "state/handoffs", "state/huddle", "state/indexes", "state/rag",
@@ -1160,7 +1160,7 @@ test("doctor surfaces the new framework systems (org / secrets / diagnostics)",
1160
1160
  * Agent repo carrying `orphans` — paths upstream does not ship. Written before
1161
1161
  * the commit so git sees them as tracked and clean (prune's precondition).
1162
1162
  */
1163
- async function makeAgentWithOrphans(orphans) {
1163
+ async function makeAgentWithOrphans(orphans, opts = {}) {
1164
1164
  const root = await tmpRoot("maestro-orphan");
1165
1165
  mkdirSync(join(root, "config"), { recursive: true });
1166
1166
  writeFileSync(join(root, "CLAUDE.md"), "# agent\n");
@@ -1170,6 +1170,17 @@ async function makeAgentWithOrphans(orphans) {
1170
1170
  mkdirSync(dirname(join(root, rel)), { recursive: true });
1171
1171
  writeFileSync(join(root, rel), body);
1172
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
+
1173
1184
  execFileSync("git", ["init", "-q"], { cwd: root });
1174
1185
  execFileSync("git", ["add", "-A"], { cwd: root });
1175
1186
  execFileSync(
@@ -1322,3 +1333,72 @@ test("upgrade prune never removes machine-generated launchd plists", async () =>
1322
1333
  await fsp.rm(root, { recursive: true, force: true });
1323
1334
  }
1324
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.1",
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": {