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.
package/src/lib/sync.mjs CHANGED
@@ -77,7 +77,15 @@
77
77
  * @typedef {Object} SyncSummary
78
78
  * @property {number} added - Skills newly written that were not on disk
79
79
  * @property {number} updated - Skills overwritten on disk
80
- * @property {number} removed - Tombstones applied
80
+ * @property {number} removed - Skills removed from the loadable set,
81
+ * counted PER SKILL: delta tombstones plus
82
+ * the full-sync orphan reconcile (#2804). A
83
+ * quarantine counts here too (it left the
84
+ * loadable set).
85
+ * @property {number} [quarantined] - Subset of `removed` MOVED to quarantine
86
+ * (an edited copy of a removed skill)
87
+ * rather than deleted, so the summary can
88
+ * show edits were preserved (#2804)
81
89
  * @property {boolean} notModified - True if 304 short-circuit fired
82
90
  * @property {boolean} [throttled] - True when the SessionStart staleness
83
91
  * throttle skipped this run entirely — no
@@ -221,8 +229,11 @@ import { getCliVersion } from "./cli-version.mjs";
221
229
  import {
222
230
  writeSkillDir,
223
231
  removeSkillDir,
232
+ removeManagedDir,
233
+ quarantineSkillDir,
224
234
  cleanupOrphans,
225
235
  placementTargetsFor,
236
+ placementTargetsForScopeLenient,
226
237
  resolvePlacementDir,
227
238
  describePlacementTarget,
228
239
  escapeControlChars,
@@ -1152,6 +1163,19 @@ async function runWholeLibrarySync({
1152
1163
  opts.since = lastSync.syncedAt;
1153
1164
  }
1154
1165
 
1166
+ // #2804: the ACTUAL full-fetch wire decision. The server returns the
1167
+ // complete authoritative set (no `since` delta filter) exactly when
1168
+ // `opts.since` is absent — so absence from `result.skills` on such a
1169
+ // response means the skill has genuinely left the library and must be
1170
+ // reconciled off disk. This is deliberately NOT `summary.fullSync` below
1171
+ // (= "no prior state existed"): a forced full re-fetch from incomplete
1172
+ // placements or the #1911 upgrade heal still reports `fullSync:false`, yet
1173
+ // its response IS a complete set that must be reconciled. Keying the orphan
1174
+ // reconcile off `since`-absence is load-bearing — key it off `fullSync` and
1175
+ // the routine forced-full-fetch cases (new vendor, CLI upgrade) never clean
1176
+ // up a recalled skill.
1177
+ const fullFetch = opts.since === undefined;
1178
+
1155
1179
  // Track whether this is a full or delta sync BEFORE the network
1156
1180
  // call, for the returned summary's `fullSync` field. The documented
1157
1181
  // semantic is "no PRIOR last-sync state existed" — NOT "no `since` was
@@ -1262,6 +1286,10 @@ async function runWholeLibrarySync({
1262
1286
  added: 0,
1263
1287
  updated: 0,
1264
1288
  removed: 0,
1289
+ // Subset of `removed` that was MOVED to quarantine (an edited copy of a
1290
+ // removed skill) rather than deleted (#2804). Surfaced so the summary
1291
+ // can tell the user their edits were preserved, not destroyed.
1292
+ quarantined: 0,
1265
1293
  // Skills the server sent that could not be written locally (e.g. a
1266
1294
  // stored path that fails client-side validation). Counted rather than
1267
1295
  // fatal — see the quarantine-and-continue block in the apply loop.
@@ -1298,6 +1326,43 @@ async function runWholeLibrarySync({
1298
1326
  // machine A, this is machine B) is not an error.
1299
1327
  }
1300
1328
 
1329
+ // #2804: full-sync orphan reconcile (also closes #2532's full-sync case).
1330
+ // On a full fetch the server sends the complete served set with
1331
+ // removals:[] (route.ts:624), so a skill that has LEFT the library —
1332
+ // recalled to absence with no fallback (#2367), owner-deleted (#2532),
1333
+ // membership-removed, pin-rolled-back — is in NEITHER skills[] NOR
1334
+ // removals[], and the tombstone loop above never touches it. Treat the
1335
+ // full response's skills[] as authoritative and reconcile every prior
1336
+ // baseline entry absent from it off disk, guarded by the recorded write
1337
+ // baseline: unmodified → deleted; CLI-written-then-EDITED → quarantined
1338
+ // (not destroyed); never-written → never visited (the reconcile iterates
1339
+ // the CLI's own `.last-sync` baseline). DELTA fetches carry a PARTIAL
1340
+ // skills[] and MUST NEVER be reconciled by absence — hence the fullFetch
1341
+ // gate (see the `fullFetch` derivation above).
1342
+ /** @type {string[]} */
1343
+ let reconciledKeys = [];
1344
+ if (fullFetch) {
1345
+ const servedKeys = new Set(
1346
+ result.skills.map((skill) => `${skill.owner}/${skill.name}`),
1347
+ );
1348
+ const servedNames = new Set(result.skills.map((skill) => skill.name));
1349
+ const reconcile = reconcileWholeLibraryOrphans({
1350
+ baselineSkills: lastSync?.skills,
1351
+ servedKeys,
1352
+ servedNames,
1353
+ vendors,
1354
+ global,
1355
+ stderr,
1356
+ });
1357
+ // A quarantine counts toward `removed` too (it left the loadable set,
1358
+ // so a quarantine-only reconcile can't leave added+updated+removed at 0
1359
+ // and go silent on the hook — the #2413 lesson) AND toward `quarantined`
1360
+ // so the move stays distinguishable from a delete in the summary.
1361
+ summary.removed += reconcile.removed;
1362
+ summary.quarantined += reconcile.quarantined;
1363
+ reconciledKeys = reconcile.reconciledKeys;
1364
+ }
1365
+
1301
1366
  // Apply skills. We don't have on-disk SHAs to compute "unchanged"
1302
1367
  // efficiently — `unchanged` stays 0 unless a future enhancement
1303
1368
  // adds disk-side hashing. For the user-facing summary, "added" vs
@@ -1353,6 +1418,13 @@ async function runWholeLibrarySync({
1353
1418
  const key = `${tombstone.owner}/${tombstone.name}`;
1354
1419
  delete skillsMap[key];
1355
1420
  }
1421
+ // Drop reconciled orphans (#2804) from the persisted map too, so
1422
+ // `.last-sync` stops listing a skill we just removed from disk — mirrors
1423
+ // the tombstone purge above. Absent from the map, it is not carried
1424
+ // forward and `list` will not report it as a phantom managed skill.
1425
+ for (const key of reconciledKeys) {
1426
+ delete skillsMap[key];
1427
+ }
1356
1428
 
1357
1429
  // Placement configuration is validated ONCE, before any skill is
1358
1430
  // written. `placementTargetsFor` depends only on `vendors` and
@@ -1854,6 +1926,8 @@ async function runSkillsetScopedSync({
1854
1926
  added: 0,
1855
1927
  updated: 0,
1856
1928
  removed: 0,
1929
+ // Subset of `removed` moved to quarantine rather than deleted (#2804).
1930
+ quarantined: 0,
1857
1931
  skipped: 0,
1858
1932
  notModified: false,
1859
1933
  // Scoped syncs have no delta mode; `fullSync` keeps its documented
@@ -1866,6 +1940,7 @@ async function runSkillsetScopedSync({
1866
1940
  const servedKeys = new Set(
1867
1941
  result.skills.map((skill) => `${skill.owner}/${skill.name}`),
1868
1942
  );
1943
+ const servedNames = new Set(result.skills.map((skill) => skill.name));
1869
1944
  const priorResolved = prior?.resolved ?? [];
1870
1945
 
1871
1946
  // Composed-state guard evidence (#2365 prod-readiness HIGH finding;
@@ -1898,18 +1973,24 @@ async function runSkillsetScopedSync({
1898
1973
  for (const entry of priorResolved) {
1899
1974
  const key = `${entry.owner}/${entry.name}`;
1900
1975
  if (servedKeys.has(key)) continue;
1901
- if (
1902
- reconcileDeleteGuarded({
1903
- entry,
1904
- targets,
1905
- rootDir,
1906
- vendors,
1907
- stderr,
1908
- bulkBaselines,
1909
- })
1910
- ) {
1911
- summary.removed++;
1912
- }
1976
+ // Re-attribution guard (mirrors reconcileWholeLibraryOrphans): a member
1977
+ // whose bare NAME is still served — under a DIFFERENT owner — was
1978
+ // re-keyed, not removed (a skillset response's owner is the live
1979
+ // `attributionSlug`, which a claim/rename changes while membership is
1980
+ // stable). Skip the reconcile so an edited member isn't spuriously
1981
+ // quarantined moments before the apply loop rewrites it under the new
1982
+ // key; the per-repo manifest is rebuilt from the served set below, so
1983
+ // the stale entry drops on its own.
1984
+ if (servedNames.has(entry.name)) continue;
1985
+ const outcome = reconcileDeleteGuarded({
1986
+ entry,
1987
+ targets,
1988
+ rootDir,
1989
+ stderr,
1990
+ bulkBaselines,
1991
+ });
1992
+ if (outcome.changed) summary.removed++;
1993
+ if (outcome.quarantined) summary.quarantined++;
1913
1994
  }
1914
1995
 
1915
1996
  // Full-set apply with the #2108-class serve guard: a placement dir
@@ -1922,6 +2003,17 @@ async function runSkillsetScopedSync({
1922
2003
  const priorByKey = new Map(
1923
2004
  priorResolved.map((entry) => [`${entry.owner}/${entry.name}`, entry]),
1924
2005
  );
2006
+ // Name-keyed fallback for the re-attribution case (#2804 review): after a
2007
+ // claim/rename the served owner changes but the placement dir (name-keyed)
2008
+ // and the per-repo baseline (recorded under the OLD owner key) do not — so
2009
+ // the owner-keyed lookup misses, and for a skillset-only repo `.last-sync`
2010
+ // has no entry either, which would make `decideScopedWrite` mislabel a
2011
+ // CLI-authored-then-edited member as "unmanaged" instead of "modified"
2012
+ // (a materially different, wrong compliance signal). Names are unique
2013
+ // within a repo's manifest, so this never collides.
2014
+ const priorByName = new Map(
2015
+ priorResolved.map((entry) => [entry.name, entry]),
2016
+ );
1925
2017
 
1926
2018
  for (const skill of result.skills) {
1927
2019
  const version = typeof skill.version === "string" ? skill.version : "";
@@ -1944,7 +2036,9 @@ async function runSkillsetScopedSync({
1944
2036
  skill,
1945
2037
  targets,
1946
2038
  rootDir,
1947
- priorEntry: priorByKey.get(`${skill.owner}/${skill.name}`),
2039
+ priorEntry:
2040
+ priorByKey.get(`${skill.owner}/${skill.name}`) ??
2041
+ priorByName.get(skill.name),
1948
2042
  bulkBaselines,
1949
2043
  });
1950
2044
 
@@ -2277,46 +2371,191 @@ function decideScopedWrite({ skill, targets, rootDir, priorEntry, bulkBaselines
2277
2371
  }
2278
2372
 
2279
2373
  /**
2280
- * Guarded reconcile-delete for one manifest entry absent from the
2281
- * served set. All-or-nothing across targets, same rationale as the
2282
- * write guard: delete ONLY when every existing placement's content
2283
- * still matches a recorded write baseline — the per-repo manifest's
2284
- * OR the machine-global `.last-sync` map's (#2365 prod-readiness
2285
- * HIGH, same composed-state shape as `decideScopedWrite`: a bulk sync
2286
- * during an undeclared interval rewrites the dir and moves the only
2287
- * valid baseline into `.last-sync`; a per-repo-only check would then
2288
- * refuse a legitimate reconcile-delete forever). Entries matching
2289
- * NEITHER store (hand-authored, or genuinely user-modified) are never
2290
- * deleted — the #2108 invariant. Returns true when at least one dir
2291
- * was removed.
2374
+ * Full-sync orphan reconcile for the whole-library path (#2804; also
2375
+ * closes #2532's full-sync case). Given the authoritative served-key set
2376
+ * from a FULL response, every prior `.last-sync` baseline entry ABSENT
2377
+ * from it has left the library and is reconciled off disk, guarded by the
2378
+ * per-entry write baseline the CLI itself recorded:
2379
+ *
2380
+ * • disk matches the entry's baseline SHAs (exactly what the CLI wrote)
2381
+ * → deleted;
2382
+ * • disk diverged (the CLI wrote it — it IS in `.last-sync` — and the
2383
+ * user has since edited or replaced it) → moved to quarantine, not
2384
+ * destroyed (compliance: it leaves the loadable folder; safety: the
2385
+ * content survives outside every vendor's scan path);
2386
+ * • a name the CLI never wrote is never visited — this iterates the
2387
+ * CLI's OWN baseline, so hand-authored dirs (no `.last-sync` entry)
2388
+ * are structurally out of scope; no separate foreign guard is needed,
2389
+ * unlike the skillset path whose per-repo/bulk stores can disagree.
2390
+ *
2391
+ * Deletions and quarantines are counted PER SKILL (once each, never once
2392
+ * per placement target — the tombstone and skillset-reconcile paths count
2393
+ * the same way); a quarantine also counts as `removed` (it left the
2394
+ * loadable set, so the SessionStart hook is never silent) and additionally
2395
+ * as `quarantined` so the move stays legible even when the per-skill
2396
+ * warning goes to a black-holed hook stderr. A skill still served under a
2397
+ * DIFFERENT owner (a re-attributed Skillbot claim) is NOT a removal and is
2398
+ * skipped. The state KEY is dropped only when the skill is no longer on
2399
+ * disk in EITHER scope: `.last-sync` is machine-global and does not record
2400
+ * which scope wrote a skill, so purging a `--global`-placed skill's key
2401
+ * during a project sync (which cleans only project dirs) would strand its
2402
+ * global dir loadable forever. The caller runs this ONLY when `opts.since`
2403
+ * was absent (a genuine full fetch); a delta response's `skills[]` is
2404
+ * partial and absence there is not removal.
2405
+ *
2406
+ * @param {object} args
2407
+ * @param {Record<string, SyncedSkillEntry> | undefined} args.baselineSkills
2408
+ * Prior `.last-sync` skills map (`owner/name` → entry with SHAs).
2409
+ * @param {Set<string>} args.servedKeys `owner/name` keys in the full response.
2410
+ * @param {Set<string>} args.servedNames Bare skill names in the full response.
2411
+ * A library has one owner per name, so a served name means the same
2412
+ * skill (used to skip a re-attributed skill rather than remove it).
2413
+ * @param {string[]} [args.vendors]
2414
+ * @param {boolean} [args.global]
2415
+ * @param {NodeJS.WritableStream} args.stderr
2416
+ * @returns {{ removed: number, quarantined: number, reconciledKeys: string[] }}
2417
+ */
2418
+ function reconcileWholeLibraryOrphans({
2419
+ baselineSkills,
2420
+ servedKeys,
2421
+ servedNames,
2422
+ vendors,
2423
+ global,
2424
+ stderr,
2425
+ }) {
2426
+ /** @type {string[]} */
2427
+ const reconciledKeys = [];
2428
+ let removed = 0;
2429
+ let quarantined = 0;
2430
+ if (!baselineSkills || typeof baselineSkills !== "object") {
2431
+ return { removed, quarantined, reconciledKeys };
2432
+ }
2433
+
2434
+ // Clean only the CURRENT sync's scope; the other scope is PROBED
2435
+ // read-only for the key-purge guard below (never cleaned — `.last-sync`
2436
+ // doesn't record which scope wrote a skill, so cleaning the other scope
2437
+ // could move hand-authored content the CLI never wrote there). The probe
2438
+ // is lenient so a vendor with no global scope contributes no target
2439
+ // instead of throwing.
2440
+ const currentTargets = placementTargetsFor({ vendors, global: global === true });
2441
+ const otherTargets = placementTargetsForScopeLenient(vendors, global !== true);
2442
+
2443
+ for (const [key, entry] of Object.entries(baselineSkills)) {
2444
+ if (!entry || typeof entry !== "object") continue;
2445
+ if (servedKeys.has(key)) continue; // still served — keep
2446
+
2447
+ // `.last-sync` is local data, not trusted input: never resolve an
2448
+ // invalid name into a path (a traversal shape must never reach `join`).
2449
+ const slashAt = key.indexOf("/");
2450
+ const name = slashAt >= 0 ? key.slice(slashAt + 1) : key;
2451
+ if (!isValidSkillName(name)) continue;
2452
+
2453
+ // Re-attribution guard: a skill whose NAME is still served — under a
2454
+ // different owner — was re-keyed, not removed (e.g. a Skillbot skill
2455
+ // `skillrepo/foo` claimed to `alice/foo` changes the owner slug but not
2456
+ // the name-keyed placement dir). The server enforces one owner per name
2457
+ // within a library, so a served name is the same skill: leave its dir
2458
+ // for the apply loop to rewrite under the new key, and just drop the
2459
+ // stale old key so state doesn't carry a phantom.
2460
+ if (servedNames.has(name)) {
2461
+ reconciledKeys.push(key);
2462
+ continue;
2463
+ }
2464
+
2465
+ let leftLoadable = false;
2466
+ let wasQuarantined = false;
2467
+ for (const target of currentTargets) {
2468
+ const dir = resolvePlacementDir(target, name);
2469
+ if (!existsSync(dir)) continue;
2470
+ const diskShas = computeShasFromDir(dir);
2471
+ if (
2472
+ shasMatch(diskShas, {
2473
+ skillMdSha256: entry.skillMdSha256,
2474
+ filesSha256: entry.filesSha256,
2475
+ })
2476
+ ) {
2477
+ // Exactly what the CLI wrote — delete outright.
2478
+ if (removeManagedDir(dir)) leftLoadable = true;
2479
+ } else {
2480
+ // The CLI wrote this name (it is in our baseline) but the content
2481
+ // diverged: a local edit or a same-named replacement. Compliance
2482
+ // requires it leave the loadable folder; quarantine preserves it.
2483
+ const dest = quarantineSkillDir(dir, { skillName: name });
2484
+ leftLoadable = true;
2485
+ wasQuarantined = true;
2486
+ const displayPath = `${describePlacementTarget(target)}${escapeControlChars(name)}`;
2487
+ stderr.write(
2488
+ ` warning: ${displayPath}/ is no longer in your library but has local edits — ` +
2489
+ `moved to quarantine (${dest}) instead of deleted, so it stops loading in your agents ` +
2490
+ `while your changes are preserved. Recover them from that folder if needed.\n`,
2491
+ );
2492
+ }
2493
+ }
2494
+ // Count ONCE per skill (not once per target).
2495
+ if (leftLoadable) removed++;
2496
+ if (wasQuarantined) quarantined++;
2497
+
2498
+ // Drop the state key only when the skill is no longer on disk in the
2499
+ // OTHER scope either — otherwise a `--global`-placed skill reconciled
2500
+ // during a project sync (its dir in `otherTargets`, which this pass
2501
+ // does not clean) would be stranded loadable forever once its key is
2502
+ // gone. Leaving the key lets the next same-scope sync reconcile it. A
2503
+ // true phantom (no dir in either scope) still drops.
2504
+ const stillInOtherScope = otherTargets.some((target) =>
2505
+ existsSync(resolvePlacementDir(target, name)),
2506
+ );
2507
+ if (!stillInOtherScope) reconciledKeys.push(key);
2508
+ }
2509
+
2510
+ return { removed, quarantined, reconciledKeys };
2511
+ }
2512
+
2513
+ /**
2514
+ * Guarded reconcile for one manifest entry absent from the served set —
2515
+ * the skillset-scoped counterpart to `reconcileWholeLibraryOrphans`.
2516
+ * Classifies each existing placement dir against the recorded write
2517
+ * baseline (the per-repo manifest's OR the machine-global `.last-sync`
2518
+ * map's — #2365 composed-state, same shape as `decideScopedWrite`):
2519
+ *
2520
+ * • matches a baseline (exactly what the CLI wrote) → deleted;
2521
+ * • diverged AND the PER-REPO manifest recorded writing this repo's dir
2522
+ * (positive proof the CLI wrote it here, and the user then edited it)
2523
+ * → moved to quarantine, not destroyed (#2804 compliance: a removed
2524
+ * skill must leave the loadable folder; the edits are preserved);
2525
+ * • diverged with only a BULK `.last-sync` baseline (proves the CLI
2526
+ * wrote SOME placement — possibly the global one — not necessarily
2527
+ * this repo's dir) OR no baseline at all (hand-authored) → kept
2528
+ * untouched with a warning. This is the surviving half of the #2108
2529
+ * invariant: the owner's remove-and-quarantine decision covers a
2530
+ * CLI-authored-then-edited skill, NEVER content whose authorship of
2531
+ * THIS repo's dir the CLI cannot prove.
2532
+ *
2533
+ * Returns `{changed, quarantined}`: `changed` is true when at least one
2534
+ * dir was removed or quarantined (the caller's per-skill `removed`
2535
+ * increment); `quarantined` is true when any was MOVED to quarantine
2536
+ * rather than deleted (a subset of `changed`, surfaced for the summary).
2292
2537
  *
2293
2538
  * @param {object} args
2294
2539
  * @param {import("./repo-sync-state.mjs").RepoResolvedEntry} args.entry
2295
2540
  * @param {string[]} args.targets
2296
2541
  * @param {string} args.rootDir
2297
- * @param {string[]} [args.vendors]
2298
2542
  * @param {NodeJS.WritableStream} args.stderr
2299
2543
  * @param {Map<string, {skillMdSha256: string, filesSha256: string}>} args.bulkBaselines
2300
2544
  * Name-keyed `.last-sync` write baselines, read once per sync.
2301
- * @returns {boolean}
2545
+ * @returns {{ changed: boolean, quarantined: boolean }}
2302
2546
  */
2303
- function reconcileDeleteGuarded({ entry, targets, rootDir, vendors, stderr, bulkBaselines }) {
2547
+ function reconcileDeleteGuarded({ entry, targets, rootDir, stderr, bulkBaselines }) {
2304
2548
  // A state file is local data, not a trusted input: an entry whose
2305
2549
  // name fails spec validation is never resolved into a path (the
2306
2550
  // join would otherwise honor traversal shapes like "../x").
2307
- if (!isValidSkillName(entry.name)) return false;
2551
+ if (!isValidSkillName(entry.name)) return { changed: false, quarantined: false };
2308
2552
 
2309
2553
  const hasPerRepoBaseline =
2310
2554
  typeof entry.skillMdSha256 === "string" &&
2311
2555
  typeof entry.filesSha256 === "string";
2312
2556
  const bulkEntry = bulkBaselines.get(entry.name);
2313
2557
 
2314
- const existing = targets.filter((target) =>
2315
- existsSync(resolvePlacementDir(target, entry.name, rootDir)),
2316
- );
2317
- if (existing.length === 0) return false; // nothing on disk — just drops from state
2318
-
2319
- const mismatched = existing.filter((target) => {
2558
+ const matchesBaseline = (target) => {
2320
2559
  const diskShas = computeShasFromDir(
2321
2560
  resolvePlacementDir(target, entry.name, rootDir),
2322
2561
  );
@@ -2327,34 +2566,57 @@ function reconcileDeleteGuarded({ entry, targets, rootDir, vendors, stderr, bulk
2327
2566
  filesSha256: entry.filesSha256,
2328
2567
  })
2329
2568
  ) {
2330
- return false;
2569
+ return true;
2331
2570
  }
2332
- return !(bulkEntry && shasMatch(diskShas, bulkEntry));
2333
- });
2571
+ return !!(bulkEntry && shasMatch(diskShas, bulkEntry));
2572
+ };
2334
2573
 
2335
- if (mismatched.length > 0) {
2336
- const hadAnyBaseline = hasPerRepoBaseline || !!bulkEntry;
2337
- for (const target of mismatched) {
2338
- const displayPath = `${describePlacementTarget(target)}${escapeControlChars(entry.name)}`;
2574
+ const existing = targets.filter((target) =>
2575
+ existsSync(resolvePlacementDir(target, entry.name, rootDir)),
2576
+ );
2577
+ if (existing.length === 0) return { changed: false, quarantined: false }; // nothing on disk — just drops from state
2578
+
2579
+ const classified = existing.map((target) => ({
2580
+ target,
2581
+ match: matchesBaseline(target),
2582
+ }));
2583
+
2584
+ let changed = false;
2585
+ let quarantined = false;
2586
+ for (const { target, match } of classified) {
2587
+ const dir = resolvePlacementDir(target, entry.name, rootDir);
2588
+ const displayPath = `${describePlacementTarget(target)}${escapeControlChars(entry.name)}`;
2589
+ if (match) {
2590
+ // Exactly what the CLI wrote — safe to delete outright.
2591
+ if (removeManagedDir(dir)) changed = true;
2592
+ } else if (hasPerRepoBaseline) {
2593
+ // The per-repo manifest proves the CLI wrote THIS repo's placement
2594
+ // and it has since been edited: quarantine (compliance + no data
2595
+ // loss). A bulk-only baseline is deliberately NOT sufficient here —
2596
+ // it proves the CLI wrote SOME placement, not this repo's dir, so a
2597
+ // divergence could be hand-authored project content.
2598
+ const dest = quarantineSkillDir(dir, { skillName: entry.name });
2599
+ changed = true;
2600
+ quarantined = true;
2339
2601
  stderr.write(
2340
- hadAnyBaseline
2341
- ? ` warning: ${displayPath}/ was removed from this repo's skillset but has local changes, ` +
2342
- `so it was not deleted. Push it to your library with 'skillrepo push ${displayPath}', ` +
2343
- `or remove the directory yourself.\n`
2344
- : ` warning: ${displayPath}/ was removed from this repo's skillset but was not written ` +
2345
- `by this CLI, so it was not deleted. Push it to your library with ` +
2346
- `'skillrepo push ${displayPath}', or remove the directory yourself.\n`,
2602
+ ` warning: ${displayPath}/ was removed from this repo's skillset but has local edits — ` +
2603
+ `moved to quarantine (${dest}) instead of deleted, so it stops loading in your agents ` +
2604
+ `while your changes are preserved. Recover them from that folder if needed.\n`,
2605
+ );
2606
+ } else {
2607
+ // Diverged with no per-repo authorship proof — either genuinely
2608
+ // hand-authored (no baseline anywhere) or the #2365 composed-state
2609
+ // where only a bulk `.last-sync` baseline exists, which proves the CLI
2610
+ // wrote SOME placement but not THIS repo's dir. Same safe action
2611
+ // either way: never move content the CLI cannot prove it wrote here.
2612
+ stderr.write(
2613
+ ` warning: ${displayPath}/ was removed from this repo's skillset but was not written ` +
2614
+ `by this CLI, so it was not deleted. Push it to your library with ` +
2615
+ `'skillrepo push ${displayPath}', or remove the directory yourself.\n`,
2347
2616
  );
2348
2617
  }
2349
- return false;
2350
2618
  }
2351
-
2352
- const removeResult = removeSkillDir(entry.name, {
2353
- vendors,
2354
- global: false,
2355
- baseDir: rootDir,
2356
- });
2357
- return removeResult.removed.length > 0;
2619
+ return { changed, quarantined };
2358
2620
  }
2359
2621
 
2360
2622
  /**
@@ -126,9 +126,12 @@ describe("runUpdate — summary printer (removed line)", () => {
126
126
  it("prints the removed line in the human summary when a tombstone deletes a skill", async () => {
127
127
  // printSummary only emits `− N removed` when `s.removed > 0`. Every
128
128
  // other human-mode test produces added/updated-only summaries, so the
129
- // `if (s.removed > 0)` true branch was never taken. Sync a skill,
130
- // then re-sync with it tombstoned so `removed` is non-zero and the
131
- // line is rendered.
129
+ // `if (s.removed > 0)` true branch was never taken. Sync a skill (with
130
+ // an etag baseline so the next sync is a genuine DELTA), then re-sync
131
+ // with it tombstoned so `removed` is non-zero and the line renders.
132
+ // (A tombstone only rides a DELTA — a full sync carries removals:[],
133
+ // #2804 — so the etag is load-bearing here.)
134
+ server.setEtag('"v1"');
132
135
  server.setLibraryResponse({
133
136
  skills: [makeSkill("doomed")],
134
137
  removals: [],
@@ -139,6 +142,7 @@ describe("runUpdate — summary printer (removed line)", () => {
139
142
 
140
143
  stdout.clear();
141
144
  server.resetLibraryInspection?.();
145
+ server.setEtag('"v2"');
142
146
  server.setLibraryResponse({
143
147
  skills: [],
144
148
  removals: [{ owner: "alice", name: "doomed", removedAt: "x" }],
@@ -154,6 +158,36 @@ describe("runUpdate — summary printer (removed line)", () => {
154
158
  "the tombstoned skill is gone from disk",
155
159
  );
156
160
  });
161
+
162
+ it("surfaces the quarantine in the summary when an EDITED removed skill is moved (#2804)", async () => {
163
+ // Sync a skill, edit it locally, then recall it on a full fetch so the
164
+ // reconcile QUARANTINES rather than deletes. The summary must say so —
165
+ // otherwise the user thinks their edits were deleted.
166
+ server.setEtag('"v1"');
167
+ server.setLibraryResponse({ skills: [makeSkill("edited")], removals: [], syncedAt: "x" });
168
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
169
+ const dir = resolvePlacementDir("claudeProject", "edited");
170
+ writeFileSync(join(dir, "SKILL.md"), "---\nname: edited\n---\nlocally changed\n");
171
+
172
+ // Force a full fetch (pre-floor cliVersion) that recalls the skill.
173
+ const prior = readLastSync();
174
+ writeLastSync({
175
+ etag: prior.etag,
176
+ syncedAt: prior.syncedAt,
177
+ skills: prior.skills,
178
+ cliVersion: "4.7.0",
179
+ });
180
+ server.setEtag('"v2"');
181
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "y" });
182
+
183
+ stdout.clear();
184
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
185
+
186
+ const out = stdout.text();
187
+ assert.match(out, /−\s*1 removed/, "still counts as removed (left the loadable set)");
188
+ assert.match(out, /quarantine/, "and tells the user it was MOVED, not deleted");
189
+ assert.ok(!existsSync(dir), "the loadable copy is gone");
190
+ });
157
191
  });
158
192
 
159
193
  describe("runUpdate — credential resolution", () => {
@@ -689,6 +689,14 @@ export function createMockServer(initialPayload, options = {}) {
689
689
  const filteredResponse = {
690
690
  ...libraryResponse,
691
691
  skills: respondedSkills,
692
+ // Match production (src/app/api/v1/library/route.ts:624): a FULL
693
+ // library sync (no `since`) NEVER carries removals — a departed
694
+ // skill is expressed by ABSENCE from the complete `skills[]`, and
695
+ // the client reconciles it (#2804). Only delta syncs (with `since`)
696
+ // carry tombstones. The mock previously spread `removals` through
697
+ // unfiltered, so a "full-sync tombstone" test passed against a shape
698
+ // production never sends and the #2804 orphan gap had NO coverage.
699
+ removals: sinceParam ? (libraryResponse.removals ?? []) : [],
692
700
  };
693
701
 
694
702
  const headers = { "Content-Type": "application/json" };