skillrepo 4.17.2 → 4.18.0

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.
@@ -25,8 +25,10 @@ import {
25
25
  rmSync,
26
26
  mkdirSync,
27
27
  writeFileSync,
28
+ appendFileSync,
28
29
  existsSync,
29
30
  readFileSync,
31
+ readdirSync,
30
32
  chmodSync,
31
33
  } from "node:fs";
32
34
  import { join } from "node:path";
@@ -41,10 +43,14 @@ import {
41
43
  placementTargetsFor,
42
44
  writeSkillDir,
43
45
  removeSkillDir,
46
+ removeManagedDir,
47
+ quarantineSkillDir,
48
+ withFsRetry,
44
49
  cleanupOrphans,
45
50
  MAX_PATH_DEPTH,
46
51
  BLOCKED_EXTENSIONS,
47
52
  } from "../../lib/file-write.mjs";
53
+ import { globalQuarantineDir } from "../../lib/paths.mjs";
48
54
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
49
55
  import {
50
56
  captureHome,
@@ -1085,6 +1091,168 @@ describe("removeSkillDir", () => {
1085
1091
  );
1086
1092
  });
1087
1093
 
1094
+ // ── withFsRetry (Windows-lock retry) ─────────────────────────────────────
1095
+
1096
+ describe("withFsRetry", () => {
1097
+ it("returns the op result without retrying on success", () => {
1098
+ let calls = 0;
1099
+ const out = withFsRetry(() => {
1100
+ calls++;
1101
+ return "ok";
1102
+ });
1103
+ assert.equal(out, "ok");
1104
+ assert.equal(calls, 1);
1105
+ });
1106
+
1107
+ it("retries a transient EBUSY, then succeeds", () => {
1108
+ let calls = 0;
1109
+ const out = withFsRetry(
1110
+ () => {
1111
+ calls++;
1112
+ if (calls < 3) {
1113
+ const err = new Error("locked");
1114
+ err.code = "EBUSY";
1115
+ throw err;
1116
+ }
1117
+ return "recovered";
1118
+ },
1119
+ [1, 1, 1, 1], // tiny real backoff — exercises the Atomics.wait sleep
1120
+ );
1121
+ assert.equal(out, "recovered");
1122
+ assert.equal(calls, 3, "failed twice, succeeded on the third attempt");
1123
+ });
1124
+
1125
+ it("rethrows a NON-transient error immediately (no retry)", () => {
1126
+ let calls = 0;
1127
+ assert.throws(
1128
+ () =>
1129
+ withFsRetry(() => {
1130
+ calls++;
1131
+ const err = new Error("denied");
1132
+ err.code = "EACCES";
1133
+ throw err;
1134
+ }, [0, 0, 0]),
1135
+ /denied/,
1136
+ );
1137
+ assert.equal(calls, 1, "EACCES is a real error — fail fast, never retried");
1138
+ });
1139
+
1140
+ it("rethrows the transient error after exhausting the retry budget", () => {
1141
+ let calls = 0;
1142
+ assert.throws(
1143
+ () =>
1144
+ withFsRetry(() => {
1145
+ calls++;
1146
+ const err = new Error("still locked");
1147
+ err.code = "EPERM";
1148
+ throw err;
1149
+ }, [0, 0]),
1150
+ /still locked/,
1151
+ );
1152
+ assert.equal(calls, 3, "initial attempt + 2 retries, then rethrow");
1153
+ });
1154
+ });
1155
+
1156
+ // ── removeManagedDir ─────────────────────────────────────────────────────
1157
+
1158
+ describe("removeManagedDir", () => {
1159
+ beforeEach(setupSandbox);
1160
+ afterEach(teardownSandbox);
1161
+
1162
+ it("removes an existing dir and returns true", () => {
1163
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1164
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
1165
+ assert.ok(existsSync(dir));
1166
+ assert.equal(removeManagedDir(dir), true);
1167
+ assert.ok(!existsSync(dir));
1168
+ });
1169
+
1170
+ it("returns false when the dir does not exist (idempotent)", () => {
1171
+ const dir = resolvePlacementDir("claudeProject", "nope");
1172
+ assert.equal(removeManagedDir(dir), false);
1173
+ });
1174
+
1175
+ it(
1176
+ "throws a typed diskError when removal fails",
1177
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
1178
+ () => {
1179
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1180
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
1181
+ const root = join(process.cwd(), ".claude", "skills");
1182
+ chmodSync(root, 0o555);
1183
+ try {
1184
+ assert.throws(
1185
+ () => removeManagedDir(dir),
1186
+ (err) => err instanceof CliError && err.exitCode === 3,
1187
+ );
1188
+ } finally {
1189
+ chmodSync(root, 0o755);
1190
+ }
1191
+ },
1192
+ );
1193
+ });
1194
+
1195
+ // ── quarantineSkillDir ───────────────────────────────────────────────────
1196
+
1197
+ describe("quarantineSkillDir", () => {
1198
+ beforeEach(setupSandbox);
1199
+ afterEach(teardownSandbox);
1200
+
1201
+ it("moves the dir out of the loadable placement into quarantine, preserving content", () => {
1202
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1203
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
1204
+ appendFileSync(join(dir, "SKILL.md"), "\nlocal edit\n");
1205
+ assert.ok(existsSync(dir));
1206
+
1207
+ const dest = quarantineSkillDir(dir, { skillName: "pdf-helper" });
1208
+
1209
+ // Loadable copy is gone; the content lives under
1210
+ // ~/.claude/skillrepo/quarantine/, which no vendor scans.
1211
+ assert.ok(!existsSync(dir), "loadable copy removed");
1212
+ assert.ok(
1213
+ dest.startsWith(globalQuarantineDir()),
1214
+ "dest is under the quarantine root",
1215
+ );
1216
+ assert.ok(existsSync(dest));
1217
+ assert.match(readFileSync(join(dest, "SKILL.md"), "utf-8"), /local edit/);
1218
+ });
1219
+
1220
+ it("gives each quarantined copy a unique dir (no collision across targets)", () => {
1221
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1222
+ const destA = quarantineSkillDir(
1223
+ resolvePlacementDir("claudeProject", "pdf-helper"),
1224
+ { skillName: "pdf-helper" },
1225
+ );
1226
+ // Recreate and quarantine the same name again.
1227
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1228
+ const destB = quarantineSkillDir(
1229
+ resolvePlacementDir("claudeProject", "pdf-helper"),
1230
+ { skillName: "pdf-helper" },
1231
+ );
1232
+ assert.notEqual(destA, destB, "two quarantines of the same name never collide");
1233
+ assert.equal(readdirSync(globalQuarantineDir()).length, 2);
1234
+ });
1235
+
1236
+ it("rejects an invalid skill name (never builds a path from untrusted input)", () => {
1237
+ assert.throws(
1238
+ () => quarantineSkillDir("/tmp/whatever", { skillName: "../evil" }),
1239
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
1240
+ );
1241
+ });
1242
+
1243
+ it("throws a typed diskError when the copy fails (source is gone)", () => {
1244
+ // cpSync of a nonexistent source throws ENOENT (not a CliError) → the
1245
+ // catch maps it to a typed disk error, never a raw OS error.
1246
+ assert.throws(
1247
+ () =>
1248
+ quarantineSkillDir(join(process.cwd(), "does-not-exist"), {
1249
+ skillName: "ghost",
1250
+ }),
1251
+ (err) => err instanceof CliError && err.exitCode === 3,
1252
+ );
1253
+ });
1254
+ });
1255
+
1088
1256
  // ── cleanupOrphans ──────────────────────────────────────────────────────
1089
1257
 
1090
1258
  describe("cleanupOrphans", () => {
@@ -487,6 +487,54 @@ describe("getLibrary", () => {
487
487
  }
488
488
  });
489
489
 
490
+ it("throws networkError on a valid-JSON 200 whose skills is not an array (#2879 over-deletion guard)", async () => {
491
+ // A 200 with valid JSON but no top-level skills[] array — an upstream
492
+ // proxy/CDN body, an API-gateway health envelope, or a future
493
+ // server-envelope refactor (e.g. {data:{skills}}). Before the guard this
494
+ // collapsed to skills:[] and a full-sync reconcile (#2804) treated it as
495
+ // "everything was removed", deleting the user's whole local library. It
496
+ // must fail loud instead. Covers null/absent AND truthy-non-array shapes
497
+ // (the old `?? []` passed the latter straight into a later `.map` crash).
498
+ for (const malformed of [
499
+ {},
500
+ { removals: [] },
501
+ { data: { skills: [] } },
502
+ { skills: null },
503
+ { skills: "nope" },
504
+ { skills: { 0: "x" } },
505
+ ]) {
506
+ const srv = await startServer((req, res) => jsonRes(res, 200, malformed));
507
+ try {
508
+ await assert.rejects(
509
+ () => getLibrary(srv.url, VALID_KEY),
510
+ (err) =>
511
+ err instanceof CliError &&
512
+ err.exitCode === EXIT_NETWORK &&
513
+ /no skills\[\] array/.test(err.message),
514
+ `malformed body ${JSON.stringify(malformed)} must reject`,
515
+ );
516
+ } finally {
517
+ await srv.close();
518
+ }
519
+ }
520
+ });
521
+
522
+ it("still returns a genuine empty library (skills: []) without throwing (#2879)", async () => {
523
+ // The guard fires ONLY on a missing/non-array skills — a real empty
524
+ // library sends skills: [] (an array), and the #2804 reconcile that
525
+ // removes a truly-departed skill depends on this legitimate 200.
526
+ const srv = await startServer((req, res) =>
527
+ jsonRes(res, 200, { skills: [], removals: [], syncedAt: "2025-01-01T00:00:00Z" }),
528
+ );
529
+ try {
530
+ const result = await getLibrary(srv.url, VALID_KEY);
531
+ assert.deepEqual(result.skills, []);
532
+ assert.equal(result.notModified, false);
533
+ } finally {
534
+ await srv.close();
535
+ }
536
+ });
537
+
490
538
  it("sends If-None-Match when ifNoneMatch is provided", async () => {
491
539
  const srv = await startServer((req, res) => {
492
540
  // Echo the conditional check
@@ -537,9 +585,14 @@ describe("getLibrary", () => {
537
585
  }
538
586
  });
539
587
 
540
- it("tolerates missing fields in the response (defaults applied)", async () => {
588
+ it("tolerates missing removals/syncedAt when skills[] is present (defaults applied)", async () => {
589
+ // Resilience for the NON-deletion fields survives the #2879 skills[]
590
+ // guard: a 200 that carries skills[] but omits removals/syncedAt still
591
+ // defaults them. (A missing/non-array skills[] now throws instead of
592
+ // defaulting to [] — see the over-deletion guard test above — because a
593
+ // full-sync reconcile would treat that empty set as authoritative.)
541
594
  const srv = await startServer((req, res) => {
542
- jsonRes(res, 200, {});
595
+ jsonRes(res, 200, { skills: [] });
543
596
  });
544
597
  try {
545
598
  const result = await getLibrary(srv.url, VALID_KEY);
@@ -48,7 +48,7 @@ import {
48
48
  repoStatePathFor,
49
49
  REPO_SYNC_GRACE_WINDOW_MS,
50
50
  } from "../../lib/repo-sync-state.mjs";
51
- import { globalLastSyncPath } from "../../lib/paths.mjs";
51
+ import { globalLastSyncPath, globalQuarantineDir } from "../../lib/paths.mjs";
52
52
  import { CliError, EXIT_NETWORK, EXIT_UNRESOLVABLE } from "../../lib/errors.mjs";
53
53
  import { getLibrary, postSyncReceipt } from "../../lib/http.mjs";
54
54
  import { createMockServer } from "../e2e/mock-server.mjs";
@@ -403,11 +403,12 @@ describe("skillset-scoped sync — 200 full-set reconcile (#2365)", () => {
403
403
  assert.deepEqual(receipt.skills, []);
404
404
  });
405
405
 
406
- it("empty skillset honors the delete guards: a modified managed dir survives the wipe", async () => {
406
+ it("empty skillset reconcile quarantines a modified managed dir (#2804): loadable copy removed, edit preserved", async () => {
407
407
  declareRepo();
408
408
  server.setSkillsetEtag(SS_ETAG_A);
409
409
  server.setSkillsetResponse(scopedResponse([makeSkill("alpha")]));
410
410
  await scopedSync();
411
+ // The CLI wrote alpha (per-repo baseline); the user then edits it.
411
412
  appendFileSync(join(repoRoot(), ".claude", "skills", "alpha", "SKILL.md"), "\nedited\n");
412
413
 
413
414
  server.setSkillsetEtag(SS_ETAG_B);
@@ -416,12 +417,156 @@ describe("skillset-scoped sync — 200 full-set reconcile (#2365)", () => {
416
417
  const stderr = createCaptureStream();
417
418
  const result = await scopedSync({ io: { stderr } });
418
419
 
419
- assert.equal(result.removed, 0);
420
- assert.ok(existsSync(join(repoRoot(), ".claude", "skills", "alpha", "SKILL.md")));
421
- assert.match(stderr.text(), /alpha\/ was removed from this repo's skillset but has local changes/);
420
+ // Compliance (#2804 owner decision): a recalled/removed skill the CLI
421
+ // wrote must leave the loadable folder even when locally edited — the
422
+ // edit is preserved in quarantine, never destroyed. This REPLACES the
423
+ // old warn-and-keep, which left the recalled skill loadable by agents.
424
+ assert.equal(result.removed, 1, "the edited managed dir is reconciled (quarantined)");
425
+ assert.equal(
426
+ existsSync(join(repoRoot(), ".claude", "skills", "alpha")),
427
+ false,
428
+ "loadable copy is gone — agents no longer load the recalled skill",
429
+ );
430
+ assert.match(
431
+ stderr.text(),
432
+ /alpha\/ was removed from this repo's skillset but has local edits — moved to quarantine/,
433
+ );
434
+
435
+ // The edit survives in the non-loaded quarantine area.
436
+ const quarantined = readdirSync(globalQuarantineDir()).filter((d) =>
437
+ d.startsWith("alpha-"),
438
+ );
439
+ assert.equal(quarantined.length, 1, "exactly one quarantined copy of alpha");
440
+ assert.match(
441
+ readFileSync(join(globalQuarantineDir(), quarantined[0], "SKILL.md"), "utf-8"),
442
+ /edited/,
443
+ "the user's edit is preserved in quarantine",
444
+ );
445
+
422
446
  assert.deepEqual(server.getLastReceipt().repo.resolved, []);
423
447
  });
424
448
 
449
+ it("never resolves a traversal-shaped manifest entry into a path — no arbitrary-dir deletion (#2879)", async () => {
450
+ // Same guard on the skillset reconcile path (reconcileDeleteGuarded): the
451
+ // per-repo manifest is local data, not trusted input. A traversal-named
452
+ // entry must be rejected before resolvePlacementDir. Plant a byte-identical
453
+ // sentinel OUTSIDE the placement root so a guard-LESS delete branch would
454
+ // rm it, inject a traversal entry reusing victim's SHAs, then re-sync.
455
+ declareRepo();
456
+ server.setSkillsetEtag(SS_ETAG_A);
457
+ server.setSkillsetResponse(scopedResponse([makeSkill("victim")]));
458
+ await scopedSync();
459
+ const victimDir = join(repoRoot(), ".claude", "skills", "victim");
460
+
461
+ const sentinelDir = join(sandbox, "project", "PWNED-SENTINEL");
462
+ mkdirSync(sentinelDir, { recursive: true });
463
+ writeFileSync(
464
+ join(sentinelDir, "SKILL.md"),
465
+ readFileSync(join(victimDir, "SKILL.md")),
466
+ );
467
+
468
+ const state = readRepoSyncState(repoRoot());
469
+ const victimEntry = state.resolved.find((e) => e.name === "victim");
470
+ state.resolved.push({ ...victimEntry, name: "../../PWNED-SENTINEL" });
471
+ writeRepoSyncState(repoRoot(), state);
472
+
473
+ // victim stays served; the traversal entry is absent → it would reconcile.
474
+ server.setSkillsetEtag(SS_ETAG_B);
475
+ server.setSkillsetResponse(scopedResponse([makeSkill("victim")]));
476
+ const result = await scopedSync();
477
+
478
+ assert.ok(
479
+ existsSync(join(sentinelDir, "SKILL.md")),
480
+ "traversal target UNTOUCHED — the guard rejected the name before any fs op",
481
+ );
482
+ assert.equal(result.removed, 0, "a traversal entry reconciles nothing");
483
+ assert.equal(result.quarantined ?? 0, 0, "and quarantines nothing");
484
+ });
485
+
486
+ it("reconcile keeps (never quarantines) an absent member the CLI never wrote — no authorship proof (#2108 preserved)", async () => {
487
+ declareRepo();
488
+ // Hand-author a dir at a member's name BEFORE the CLI ever writes it, so
489
+ // the serve is refused as unmanaged and the resolved entry carries NO
490
+ // baseline SHAs — the CLI has no proof it wrote this dir.
491
+ const dir = join(repoRoot(), ".claude", "skills", "squatter");
492
+ mkdirSync(dir, { recursive: true });
493
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: squatter\n---\nhand-authored\n");
494
+
495
+ server.setSkillsetEtag(SS_ETAG_A);
496
+ server.setSkillsetResponse(scopedResponse([makeSkill("squatter", "server body")]));
497
+ await scopedSync(); // serve-refused (unmanaged) → resolved entry, no SHAs
498
+
499
+ // squatter leaves the set. With no authorship proof the reconcile must
500
+ // NOT touch it — the owner's remove-and-quarantine covers CLI-authored
501
+ // edits ONLY, never hand-authored content. Keep it with a warning.
502
+ server.setSkillsetEtag(SS_ETAG_B);
503
+ server.setSkillsetResponse(scopedResponse([]));
504
+ const stderr = createCaptureStream();
505
+ const result = await scopedSync({ io: { stderr } });
506
+
507
+ assert.equal(result.removed, 0, "hand-authored dir is never removed by the reconcile");
508
+ assert.match(
509
+ readFileSync(join(dir, "SKILL.md"), "utf-8"),
510
+ /hand-authored/,
511
+ "the user's hand-authored content survives byte-for-byte",
512
+ );
513
+ assert.match(
514
+ stderr.text(),
515
+ /squatter\/ was removed from this repo's skillset but was not written by this CLI/,
516
+ );
517
+ // And it is NOT quarantined — quarantine is for CLI-authored edits only.
518
+ const quarantined = existsSync(globalQuarantineDir())
519
+ ? readdirSync(globalQuarantineDir()).filter((d) => d.startsWith("squatter-"))
520
+ : [];
521
+ assert.equal(quarantined.length, 0, "hand-authored content is never moved to quarantine");
522
+ });
523
+
524
+ it("reconcile does not quarantine a re-attributed member (same name, new owner) (#2804 review)", async () => {
525
+ declareRepo();
526
+ server.setSkillsetEtag(SS_ETAG_A);
527
+ server.setSkillsetResponse(scopedResponse([{ ...makeSkill("foo"), owner: "skillrepo" }]));
528
+ await scopedSync();
529
+ const dir = join(repoRoot(), ".claude", "skills", "foo");
530
+ assert.ok(existsSync(dir));
531
+ appendFileSync(join(dir, "SKILL.md"), "\nlocal edit\n");
532
+
533
+ // The member is re-attributed skillrepo/foo → acme/foo (a claim). It is
534
+ // STILL in the skillset, just re-keyed — the reconcile must NOT quarantine
535
+ // the edited copy; it defers to the apply loop, which serve-refuses the
536
+ // edit and keeps it, exactly as for any edited member.
537
+ server.setSkillsetEtag(SS_ETAG_B);
538
+ server.setSkillsetResponse(scopedResponse([{ ...makeSkill("foo"), owner: "acme" }]));
539
+ const stderr = createCaptureStream();
540
+ const result = await scopedSync({ io: { stderr } });
541
+
542
+ assert.equal(result.quarantined ?? 0, 0, "a re-attributed member is never quarantined");
543
+ assert.doesNotMatch(stderr.text(), /moved to quarantine/);
544
+ assert.match(
545
+ readFileSync(join(dir, "SKILL.md"), "utf-8"),
546
+ /local edit/,
547
+ "the edit survives (served-but-refused, not quarantined)",
548
+ );
549
+ const quarantined = existsSync(globalQuarantineDir())
550
+ ? readdirSync(globalQuarantineDir()).filter((d) => d.startsWith("foo-"))
551
+ : [];
552
+ assert.equal(quarantined.length, 0, "no quarantine copy is made");
553
+
554
+ // Classified as a local EDIT of CLI-authored content, NOT as
555
+ // hand-authored/unmanaged — the re-attribution must not lose the CLI's
556
+ // authorship record via the owner-keyed lookup (#2804 review). This is
557
+ // the durable signal that goes to state + the server receipt.
558
+ const resolved = readRepoSyncState(repoRoot()).resolved.find(
559
+ (e) => e.name === "foo",
560
+ );
561
+ assert.equal(resolved.owner, "acme", "manifest carries the new owner");
562
+ assert.equal(resolved.written, false, "served-but-refused (a local edit)");
563
+ assert.equal(
564
+ resolved.unwrittenReason,
565
+ "modified",
566
+ "labeled 'modified' (CLI wrote it, user edited), not 'unmanaged'",
567
+ );
568
+ });
569
+
425
570
  it("prints each skippedExtras message VERBATIM and stores/echoes reason only", async () => {
426
571
  declareRepo(["acme/db-migrations"]);
427
572
  server.setSkillsetEtag(SS_ETAG_A);
@@ -1124,7 +1269,7 @@ describe("skillset-scoped sync — guards compose across multiple targets (#2365
1124
1269
  ]);
1125
1270
  });
1126
1271
 
1127
- it("delete guard keeps ALL targets when one mismatches: baseline-clean copy survives alongside the modified one", async () => {
1272
+ it("reconcile removes the clean target and quarantines only the edited one (#2804, per-target)", async () => {
1128
1273
  declareRepo();
1129
1274
  server.setSkillsetEtag(SS_ETAG_A);
1130
1275
  server.setSkillsetResponse(scopedResponse([makeSkill("beta")]));
@@ -1140,16 +1285,36 @@ describe("skillset-scoped sync — guards compose across multiple targets (#2365
1140
1285
  const stderr = createCaptureStream();
1141
1286
  const result = await scopedSync({ vendors: TWO_VENDORS, io: { stderr } });
1142
1287
 
1143
- assert.equal(result.removed, 0, "one mismatch keeps every target");
1144
- assert.ok(
1288
+ // #2804: per-target, not all-or-nothing. The baseline-clean copy is
1289
+ // exactly what the CLI wrote, so it is deleted; the edited copy leaves
1290
+ // the loadable folder too but is quarantined so the edit survives. Both
1291
+ // loadable copies of the recalled skill are gone (compliance) — the old
1292
+ // all-or-nothing keep left BOTH loadable.
1293
+ assert.equal(result.removed, 1, "beta reconciled (clean deleted + edited quarantined = one entry)");
1294
+ assert.equal(
1145
1295
  existsSync(join(claudeDir("beta"), "SKILL.md")),
1146
- "baseline-clean copy survives too (all-or-nothing delete)",
1296
+ false,
1297
+ "baseline-clean copy is deleted — it is exactly what the CLI wrote",
1298
+ );
1299
+ assert.equal(
1300
+ existsSync(join(agentsDir("beta"), "SKILL.md")),
1301
+ false,
1302
+ "edited copy leaves the loadable folder (moved to quarantine)",
1303
+ );
1304
+ assert.match(
1305
+ stderr.text(),
1306
+ /beta\/ was removed from this repo's skillset but has local edits — moved to quarantine/,
1307
+ );
1308
+
1309
+ // Only the edited target is quarantined; its content is preserved.
1310
+ const quarantined = readdirSync(globalQuarantineDir()).filter((d) =>
1311
+ d.startsWith("beta-"),
1147
1312
  );
1313
+ assert.equal(quarantined.length, 1, "exactly one quarantined copy — the edited target");
1148
1314
  assert.match(
1149
- readFileSync(join(agentsDir("beta"), "SKILL.md"), "utf-8"),
1315
+ readFileSync(join(globalQuarantineDir(), quarantined[0], "SKILL.md"), "utf-8"),
1150
1316
  /local edit/,
1151
1317
  );
1152
- assert.match(stderr.text(), /beta\/ was removed from this repo's skillset but has local changes/);
1153
1318
  assert.deepEqual(server.getLastReceipt().repo.resolved, [], "emptied set still attested");
1154
1319
  });
1155
1320
  });