devflow-kit 3.10.0 → 3.11.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.
@@ -9,7 +9,8 @@ import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-
9
9
  import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
10
10
  import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
11
11
  import { complianceOffCopyExclusions, removeComplianceOffFiles } from './compliance-off-files.js';
12
- import { clearLinkForWrite, directoryState, refuseUnrealFolder, removeReportingLink, unlinkLinkAt } from './linked-folder.js';
12
+ import { discardDisplacedFolder } from './folder-swap.js';
13
+ import { clearLinkForWrite, directoryState, refuseUnrealFolder, unlinkLinkAt } from './linked-folder.js';
13
14
  import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
14
15
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
15
16
  import { isTrackerStampedAgent } from './tracker-servers.js';
@@ -218,8 +219,12 @@ export async function copyDirectory(src, dest, exclude, links) {
218
219
  * {@link composeScripts}, swallows it with the rest of its copy-and-chmod step; that tree
219
220
  * is three levels of shipped assets, so a breach there means the package itself grew a
220
221
  * shape no walk in this repo expects, and neither call site aborts an install over it.
222
+ *
223
+ * `skip` names the entries the walk leaves alone: a path it accepts keeps its mode, and a folder
224
+ * it accepts is not entered. The reference overlay passes the backup session's `isKept`, so a file
225
+ * of the user that stays because its move failed keeps the mode the user gave it.
221
226
  */
222
- export async function chmodRecursive(dir, mode, _depth = 0) {
227
+ export async function chmodRecursive(dir, mode, skip, _depth = 0) {
223
228
  if (_depth > MAX_REFERENCE_SWEEP_DEPTH) {
224
229
  throw new Error(`chmodRecursive: descent into ${dir} exceeds the bound of ` +
225
230
  `${MAX_REFERENCE_SWEEP_DEPTH} levels — that subtree keeps the modes it arrived with.`);
@@ -227,8 +232,10 @@ export async function chmodRecursive(dir, mode, _depth = 0) {
227
232
  const entries = await fs.readdir(dir, { withFileTypes: true });
228
233
  for (const entry of entries) {
229
234
  const fullPath = path.join(dir, entry.name);
235
+ if (skip?.(fullPath) === true)
236
+ continue;
230
237
  if (entry.isDirectory()) {
231
- await chmodRecursive(fullPath, mode, _depth + 1);
238
+ await chmodRecursive(fullPath, mode, skip, _depth + 1);
232
239
  }
233
240
  else if (entry.isFile()) {
234
241
  await fs.chmod(fullPath, mode);
@@ -434,7 +441,7 @@ const STAGING_TOKEN = `${process.pid}-${Date.now().toString(36)}`;
434
441
  * arm's basename is `{provider}.{token}.tmp`, which is not dot-prefixed, so a rule keyed
435
442
  * on the name would reach the other arms only.
436
443
  */
437
- function stagingDirFor(referencesTarget, unit) {
444
+ export function stagingDirFor(referencesTarget, unit) {
438
445
  return `${sidecarBaseFor(referencesTarget, unit)}.${STAGING_TOKEN}.tmp`;
439
446
  }
440
447
  /**
@@ -443,7 +450,7 @@ function stagingDirFor(referencesTarget, unit) {
443
450
  * Beside the staging tree, and under `tracker/` for the same reason (property 2 of
444
451
  * {@link stagingDirFor}): a backup a crash strands is removed by a later run's prune.
445
452
  */
446
- function backupDirFor(referencesTarget, unit) {
453
+ export function backupDirFor(referencesTarget, unit) {
447
454
  return `${sidecarBaseFor(referencesTarget, unit)}.old`;
448
455
  }
449
456
  /**
@@ -603,6 +610,10 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
603
610
  * entries the overlay must never replace or delete (D-OVERLAY-FLAT-UNIT), so its
604
611
  * files are exactly the comparison and the neighbours are none of its business.
605
612
  *
613
+ * A directory unit that {@link unitStaysInPlace} is promoted file by file, like a flat set,
614
+ * so a file of the user's that stays in its folder is no sign that the unit is stale: its
615
+ * files are exactly the comparison there too.
616
+ *
606
617
  * What it deliberately does NOT compare is file MODE. D-OVERLAY-MODE-SCOPE normalises the
607
618
  * whole references directory on every run regardless of which units promoted, so a unit
608
619
  * skipped here still has its modes converged — a skipped promotion can hide byte drift
@@ -612,9 +623,9 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
612
623
  * — answers "no". The fallback is always the promotion that was going to happen anyway,
613
624
  * so a failure to compare can only cost a write, never correctness.
614
625
  */
615
- async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir) {
626
+ async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir, backups) {
616
627
  const basenameOf = (relPath) => relPath.split('/').slice(-1)[0];
617
- if (unit.kind !== 'cross-cutting') {
628
+ if (unit.kind !== 'cross-cutting' && !unitStaysInPlace(backups, underRoot(referencesTarget, unitSubdir(unit)))) {
618
629
  const owned = new Set(unit.files.map(basenameOf));
619
630
  let installed;
620
631
  try {
@@ -659,6 +670,50 @@ async function restoreDisplacedUnit(backup, target) {
659
670
  return { ok: false, error: String(err) };
660
671
  }
661
672
  }
673
+ /**
674
+ * Does a directory unit's folder hold an entry that must stay where it is?
675
+ *
676
+ * True when the backup session kept an entry at or below the folder (a move that failed, a
677
+ * folder it could not scan), or kept the folder or a folder above it (one over the entry
678
+ * cap, or a root it refused). Such a folder is never swapped out: a swap sets the whole
679
+ * folder aside, and what the session kept in it would go with it. The unit is written into
680
+ * the folder file by file instead, and the prune that follows leaves the kept entries alone.
681
+ */
682
+ function unitStaysInPlace(backups, unitDir) {
683
+ return backups.isKept(unitDir) || backups.keptUnder(unitDir).length > 0;
684
+ }
685
+ /**
686
+ * Rename the documents a staging tree holds into `destDir`, one `rename` each, and drop the
687
+ * staging tree. Shared by the two promotions that write into a folder that stays in place.
688
+ *
689
+ * The directory is created rather than assumed — {@link promoteDirectoryUnit} does the same
690
+ * for its target's parent. The two flat directories the registry emits today exist by the time
691
+ * promotion runs for reasons that have nothing to do with the unit landing in them: the
692
+ * references root is created by {@link overlayGeneratedReferences}, and `tracker/` as a side
693
+ * effect of the flat arm's own staging path. A flat set landing anywhere else takes ENOENT on
694
+ * its first rename — a unit reported as failed for where it was asked to land rather than for
695
+ * anything wrong with the documents it carries.
696
+ *
697
+ * Past the first rename the set is mixed, and there is no directory to swap back
698
+ * (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the rest still
699
+ * carry the previous install's. The state is recorded after each rename so a failure on the
700
+ * next one names both halves instead of claiming the set is untouched.
701
+ *
702
+ * Throws on the first failing rename; the caller reports the state recorded by then.
703
+ */
704
+ async function renameDocumentsInto(files, destDir, stagingDir, record) {
705
+ await fs.mkdir(destDir, { recursive: true });
706
+ for (const [index, relPath] of files.entries()) {
707
+ const basename = relPath.split('/').slice(-1)[0];
708
+ await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
709
+ record({
710
+ kind: 'partially-refreshed',
711
+ refreshed: files.slice(0, index + 1),
712
+ stale: files.slice(index + 1),
713
+ });
714
+ }
715
+ await fs.rm(stagingDir, { recursive: true, force: true });
716
+ }
662
717
  /**
663
718
  * Promote the flat cross-cutting set — one `rename` per document.
664
719
  *
@@ -673,29 +728,7 @@ async function restoreDisplacedUnit(backup, target) {
673
728
  * Throws on the first failing rename; the caller reports the state recorded by then.
674
729
  */
675
730
  async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record) {
676
- const destDir = underRoot(referencesTarget, unit.dir);
677
- // The directory this set lands in, created rather than assumed — {@link promoteDirectoryUnit}
678
- // does the same for its target's parent. The two flat directories the registry emits today
679
- // exist by the time promotion runs for reasons that have nothing to do with the unit landing
680
- // in them: the references root is created by {@link overlayGeneratedReferences}, and
681
- // `tracker/` as a side effect of this arm's own staging path. A flat set landing anywhere
682
- // else takes ENOENT on its first rename — a unit reported as failed for where it was asked
683
- // to land rather than for anything wrong with the documents it carries.
684
- await fs.mkdir(destDir, { recursive: true });
685
- for (const [index, relPath] of unit.files.entries()) {
686
- const basename = relPath.split('/').slice(-1)[0];
687
- await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
688
- // Past the first rename the set is mixed, and there is no directory to swap back
689
- // (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the
690
- // rest still carry the previous install's. Recorded after each rename so a failure
691
- // on the next one names both halves instead of claiming the set is untouched.
692
- record({
693
- kind: 'partially-refreshed',
694
- refreshed: unit.files.slice(0, index + 1),
695
- stale: unit.files.slice(index + 1),
696
- });
697
- }
698
- await fs.rm(stagingDir, { recursive: true, force: true });
731
+ await renameDocumentsInto(unit.files, underRoot(referencesTarget, unit.dir), stagingDir, record);
699
732
  }
700
733
  /**
701
734
  * Promote a directory unit (a provider, or `pr/`) — swapped whole, or not at all.
@@ -705,9 +738,17 @@ async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, recor
705
738
  * either entirely the previous install or entirely the new one (DR-05, risk P2-g), and a
706
739
  * rename that fails half-way restores the previous one rather than leaving it empty.
707
740
  *
741
+ * A folder that holds an entry which must stay where it is ({@link unitStaysInPlace}), or
742
+ * that cannot be set aside because an earlier `.old` folder still stands beside it, is not
743
+ * swapped: the unit's files are renamed into it one by one, as a flat set is
744
+ * ({@link renameDocumentsInto}), and the prune that follows converges the rest. Nothing here
745
+ * removes a folder with a recursive `rm`: a displaced folder goes through the backup
746
+ * session, which moves what devflow did not ship, removes what it shipped by the list the
747
+ * scan made and ends with a plain `rmdir` ({@link discardDisplacedFolder}).
748
+ *
708
749
  * Throws once the state it left has been recorded; the caller reports it.
709
750
  */
710
- async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record) {
751
+ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups) {
711
752
  const target = underRoot(referencesTarget, unitSubdir(unit));
712
753
  const backup = backupDirFor(referencesTarget, unit);
713
754
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -724,8 +765,15 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
724
765
  // either is converged away by a later run's tracker-subtree prune (both names end
725
766
  // in neither `/` nor `.md`, so no manifest entry can collide with them) — but the
726
767
  // backup this run is still relying on is exempt from this run's prune, which is
727
- // what `restore-failed` carries the recovery path for.
728
- await fs.rm(backup, { recursive: true, force: true });
768
+ // what `restore-failed` carries the recovery path for. A file of the user's in a
769
+ // stranded `.old` moves to the backup folder first; one that cannot move keeps the
770
+ // folder standing.
771
+ await discardDisplacedFolder(backups, backup, target);
772
+ if ((await directoryState(target)) === 'real'
773
+ && (unitStaysInPlace(backups, target) || (await directoryState(backup)) !== 'absent')) {
774
+ await renameDocumentsInto(unit.files, target, stagingDir, record);
775
+ return;
776
+ }
729
777
  let displaced = false;
730
778
  try {
731
779
  await fs.rename(target, backup);
@@ -750,7 +798,8 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
750
798
  }
751
799
  throw err;
752
800
  }
753
- await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
801
+ if (displaced)
802
+ await discardDisplacedFolder(backups, backup, target).catch(() => undefined);
754
803
  }
755
804
  /**
756
805
  * Promote a fully built staging tree into place, dispatched on what kind of unit it is.
@@ -773,7 +822,7 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
773
822
  * that failure selects (previous unit restored, not deleted) is exactly the one worth
774
823
  * pinning.
775
824
  */
776
- export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir) {
825
+ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir, backups) {
777
826
  let state = { kind: 'installed-unchanged' };
778
827
  const record = next => { state = next; };
779
828
  try {
@@ -783,7 +832,7 @@ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir)
783
832
  break;
784
833
  case 'provider':
785
834
  case 'pr-host':
786
- await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record);
835
+ await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups);
787
836
  break;
788
837
  default: {
789
838
  const _exhaustive = unit;
@@ -900,7 +949,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
900
949
  * coordinate system as the manifest that decided them and as the stranded-skip failure
901
950
  * below, which has always named its subtree from the root.
902
951
  */
903
- async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures) {
952
+ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups) {
904
953
  const subtreeRoot = underRoot(referencesTarget, subtree);
905
954
  const prefix = `${subtree}/`;
906
955
  // Checked here rather than trusted from the unit loop: this is the call that follows
@@ -933,7 +982,7 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
933
982
  }
934
983
  // Keyed by relative path, because `tracker/{provider}/{op}.md` is what distinguishes
935
984
  // two providers' identically named files — the reason mdEntryName cannot serve here.
936
- const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))));
985
+ const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))), referenceSweepDelegate(backups));
937
986
  return {
938
987
  scanned: swept.scanned,
939
988
  removed: swept.removed.map(rel => `${prefix}${rel}`),
@@ -953,12 +1002,12 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
953
1002
  * `scanned` sums, which keeps it the non-vacuity counter it is everywhere else: a run
954
1003
  * that scanned nothing scanned nothing in any subtree.
955
1004
  */
956
- async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures) {
1005
+ async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures, backups) {
957
1006
  let scanned = 0;
958
1007
  const removed = [];
959
1008
  const failed = [];
960
1009
  for (const subtree of CONVERGED_SUBTREES) {
961
- const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures);
1010
+ const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups);
962
1011
  scanned += swept.scanned;
963
1012
  removed.push(...swept.removed);
964
1013
  failed.push(...swept.failed);
@@ -1039,6 +1088,10 @@ export async function overlayGeneratedReferences(opts) {
1039
1088
  if (fault !== null)
1040
1089
  rootFaults.set(subtree, fault);
1041
1090
  }
1091
+ // A unit swap deletes the folder it displaces, and the prune below sweeps what the manifest does
1092
+ // not name. What the user put in a converged subtree therefore moves aside first, before either.
1093
+ // A root that is not a real folder is not scanned; the unit refusal and the prune skip it too.
1094
+ await opts.backups.prepass(CONVERGED_SUBTREES.filter(subtree => !rootFaults.has(subtree)).map(subtree => underRoot(opts.referencesTarget, subtree)));
1042
1095
  for (const unit of planOverlayUnits(manifest)) {
1043
1096
  const fault = subtreesTouchedBy(opts.referencesTarget, unit)
1044
1097
  .map(subtree => rootFaults.get(subtree))
@@ -1064,19 +1117,19 @@ export async function overlayGeneratedReferences(opts) {
1064
1117
  // installed, so `overlaidRefs` names what this run WROTE (AC-23). The discard is the
1065
1118
  // same one both promotion halves perform on their way out; skipping it would leave
1066
1119
  // the `.tmp` residue every other path is asserted not to leave.
1067
- if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir)) {
1120
+ if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir, opts.backups)) {
1068
1121
  await fs.rm(built.stagingDir, { recursive: true, force: true }).catch(() => undefined);
1069
1122
  unchangedRefs.push(...unit.files);
1070
1123
  continue;
1071
1124
  }
1072
- const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir);
1125
+ const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir, opts.backups);
1073
1126
  if (!promoted.ok) {
1074
1127
  overlayFailures.push({ unit: unitRef(unit), state: promoted.state, error: promoted.error });
1075
1128
  continue;
1076
1129
  }
1077
1130
  overlaidRefs.push(...unit.files);
1078
1131
  }
1079
- const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures);
1132
+ const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures, opts.backups);
1080
1133
  // D-OVERLAY-MODE-SCOPE: normalise the WHOLE references directory, not only the files
1081
1134
  // this run installed. copyDirectory preserves source modes, so a hand-authored
1082
1135
  // reference checked in with an odd mode installs with it; a reference is read-only
@@ -1087,12 +1140,13 @@ export async function overlayGeneratedReferences(opts) {
1087
1140
  // boundary is stated as "never replace or delete" rather than "never touch": the MODE of
1088
1141
  // a hand-authored reference — and of whatever a shadowed skill supplied outside
1089
1142
  // `tracker/` — is normalised here. The prove-you-wrote-it rule permits exactly that: the
1090
- // ownership guard protects deletion, not overwrite.
1143
+ // ownership guard protects deletion, not overwrite. A file of the user that the backup session
1144
+ // kept in place (its move failed) is the exception: its mode stays as the user set it.
1091
1145
  //
1092
1146
  // It is also the one walk that can breach chmodRecursive's descent bound. The catch is
1093
1147
  // that breach's reporting channel, not just an I/O guard (see {@link chmodRecursive}).
1094
1148
  try {
1095
- await chmodRecursive(opts.referencesTarget, 0o644);
1149
+ await chmodRecursive(opts.referencesTarget, 0o644, entry => opts.backups.isKept(entry));
1096
1150
  }
1097
1151
  catch (err) {
1098
1152
  warn(`reference overlay: could not normalise reference file modes — ${String(err)}`);
@@ -1186,77 +1240,6 @@ export function overlayOwnedSkillPaths(manifest) {
1186
1240
  }
1187
1241
  return owned;
1188
1242
  }
1189
- /**
1190
- * Empty a directory of everything but the paths another converger owns.
1191
- *
1192
- * `fs.rm(dir)` with a hole in it. `keep` holds directory-relative paths, each preserved
1193
- * whole — a file as itself, a directory with its entire subtree. Everything else is
1194
- * removed exactly as the unconditional pre-clean would have removed it.
1195
- *
1196
- * A kept path is preserved only as a real file or directory. A symlink planted at one is
1197
- * removed like anything else (the link, never its target): the converger that owns the
1198
- * path writes and prunes through it, so a link left standing here would hand that
1199
- * converger someone else's directory (D-CONVERGED-ROOT-REAL). `Dirent` types are
1200
- * lstat-based, which is what makes the check see the link rather than its target.
1201
- *
1202
- * Descent is bounded, and the bound is the `keep` set's own deepest path rather than a
1203
- * constant: the walk only ever descends INTO a directory that still has a kept
1204
- * descendant below it, so there is nothing to look for past that depth. A `keep` set of
1205
- * depth 2 — which is what {@link overlayOwnedSkillPaths} produces for every manifest the
1206
- * build emits — walks two levels and `fs.rm`s the rest recursively in one call.
1207
- *
1208
- * An unreadable directory is left alone rather than reported: the caller already
1209
- * swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
1210
- * read its target has nothing to remove from it.
1211
- *
1212
- * The walk never goes through a link. `readdir` follows one, so a link at `dir` itself is
1213
- * unlinked and nothing is read from it, and below `dir` only a `Dirent` that is a real
1214
- * directory is entered. The folder a link points to is left as it is. A link that stands where
1215
- * the pre-clean expects its own folder or file (`dir`, or a path in `keep` or above one) is
1216
- * returned, so the caller reports it; any other link is removed like the file it stands for.
1217
- * A link at `dir` that cannot be removed is left in place and nothing is emptied.
1218
- *
1219
- * A link at a kept path or above one is returned as `awaitingWrite`, not as replaced: the
1220
- * converger that owns the path has not written there yet, and a summary that says a real file or
1221
- * folder stands where the link stood is true only after it has ({@link reportLinksOnceWritten}).
1222
- * `leaveKeptLinks` is for a kept path whose converger replaces a link itself and reports it, never
1223
- * writing through it (the tracker agent file): the link then stays for that converger.
1224
- *
1225
- * @returns The links at `dir` that this replaced, and the links it removed that a later write replaces.
1226
- */
1227
- async function emptyDirectoryExcept(dir, keep, options = {}) {
1228
- const rootLink = await unlinkLinkAt(dir);
1229
- if (rootLink !== 'no-link')
1230
- return { replacedLinks: rootLink === 'unlinked' ? [dir] : [], awaitingWrite: [] };
1231
- const kept = [...keep];
1232
- const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
1233
- const awaitingWrite = [];
1234
- const walk = async (current, rel, depth) => {
1235
- let entries;
1236
- try {
1237
- entries = await fs.readdir(current, { withFileTypes: true });
1238
- }
1239
- catch {
1240
- return;
1241
- }
1242
- for (const entry of entries) {
1243
- const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
1244
- if (keep.has(entryRel) && (!entry.isSymbolicLink() || options.leaveKeptLinks === true))
1245
- continue;
1246
- const entryPath = path.join(current, entry.name);
1247
- const holdsSomethingKept = kept.some(relPath => relPath.startsWith(`${entryRel}/`));
1248
- if (entry.isDirectory() && depth < maxDepth && holdsSomethingKept) {
1249
- await walk(entryPath, entryRel, depth + 1);
1250
- continue;
1251
- }
1252
- await fs.rm(entryPath, { recursive: true, force: true });
1253
- if (entry.isSymbolicLink() && (holdsSomethingKept || keep.has(entryRel)))
1254
- awaitingWrite.push(entryPath);
1255
- }
1256
- };
1257
- await walk(dir, '', 1);
1258
- return { replacedLinks: [], awaitingWrite };
1259
- }
1260
1243
  /**
1261
1244
  * Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
1262
1245
  * path. A path that is still absent, or a link again, is not a replacement and is not reported: the
@@ -1540,30 +1523,176 @@ function recordSweep(report, kind, sweep) {
1540
1523
  * report which of them were actually there to remove.
1541
1524
  *
1542
1525
  * `names` is registry arithmetic (the skills no selected plugin owns), so most of
1543
- * it is routinely absent from disk. Only the deleter can say what it deleted:
1544
- * `fs.rm` is called WITHOUT `force`, so an absent directory rejects with ENOENT —
1545
- * "nothing to remove", neither a removal nor a failure. Reporting the plan instead
1546
- * would tell the user about deletions that never happened.
1547
- *
1548
- * Per-item failure isolation, never throws: any other rejection is recorded in
1549
- * `failed` and the remaining names are still attempted.
1526
+ * it is routinely absent from disk. Only the deleter can say what it deleted: an
1527
+ * absent folder is "nothing to remove", neither a removal nor a failure. Reporting
1528
+ * the plan instead would tell the user about deletions that never happened.
1529
+ *
1530
+ * A folder goes by the entries the backup session listed, then a plain `rmdir`. The
1531
+ * pre-pass has moved every file devflow did not ship, so a folder that still holds
1532
+ * an entry (a move that failed) stays whole and is not reported as removed. A link
1533
+ * at the folder is unlinked and its target is left as it is; a path that is not a
1534
+ * folder is a failure and stays.
1535
+ *
1536
+ * Per-item failure isolation, never throws: a failure is recorded in `failed`
1537
+ * and the remaining names are still attempted.
1550
1538
  */
1551
- async function removeDeselectedSkills(skillsDir, names) {
1539
+ async function removeDeselectedSkills(skillsDir, names, backups) {
1552
1540
  const removed = [];
1553
1541
  const failed = [];
1554
1542
  for (const name of names) {
1555
- try {
1556
- await fs.rm(path.join(skillsDir, prefixSkillName(name)), { recursive: true });
1557
- removed.push(name);
1558
- }
1559
- catch (err) {
1560
- if (err.code === 'ENOENT')
1561
- continue;
1562
- failed.push({ name, error: err });
1543
+ const folder = path.join(skillsDir, prefixSkillName(name));
1544
+ const state = await directoryState(folder);
1545
+ switch (state) {
1546
+ case 'absent':
1547
+ break;
1548
+ case 'link':
1549
+ if ((await unlinkLinkAt(folder)) === 'unlinked')
1550
+ removed.push(name);
1551
+ else
1552
+ failed.push({ name, error: new Error('a link that could not be removed') });
1553
+ break;
1554
+ case 'unusable':
1555
+ failed.push({ name, error: new Error('not a folder, so nothing was removed') });
1556
+ break;
1557
+ case 'real': {
1558
+ const result = await backups.removeListed(folder);
1559
+ if (result.folderRemoved)
1560
+ removed.push(name);
1561
+ break;
1562
+ }
1563
+ default: {
1564
+ const unreachable = state;
1565
+ void unreachable;
1566
+ }
1563
1567
  }
1564
1568
  }
1565
1569
  return { removed, failed };
1566
1570
  }
1571
+ /** The kind of the entry at `entryPath`, read with `lstat` so a link is a link; null when it cannot be read. */
1572
+ async function entryKindAt(entryPath) {
1573
+ try {
1574
+ const stat = await fs.lstat(entryPath);
1575
+ if (stat.isSymbolicLink())
1576
+ return 'link';
1577
+ if (stat.isDirectory())
1578
+ return 'directory';
1579
+ return stat.isFile() ? 'file' : 'special';
1580
+ }
1581
+ catch {
1582
+ return null;
1583
+ }
1584
+ }
1585
+ /**
1586
+ * The `mayRemove` check of a flat registry-diff sweep. It admits a file or link that the backup
1587
+ * session classifies as devflow's own (a retired or superseded file, a leftover of an interrupted
1588
+ * write) and nothing else. An unknown entry was moved by the pre-pass or stays where it is, and a
1589
+ * folder is never admitted: the sweep removes what this check admits with `unlink`, so an entry
1590
+ * that turns into a folder after the check is not removed. An entry the session kept, or one in a
1591
+ * folder it kept (over the entry cap, or one it could not scan), is not admitted either: the run
1592
+ * said it would leave that folder whole.
1593
+ */
1594
+ function removesOnlyDevflowFiles(backups) {
1595
+ return async (entryPath) => {
1596
+ if (backups.isKept(entryPath))
1597
+ return false;
1598
+ const kind = await entryKindAt(entryPath);
1599
+ if (kind === null || kind === 'directory')
1600
+ return false;
1601
+ return backups.classifyAt(entryPath, kind) !== 'unknown';
1602
+ };
1603
+ }
1604
+ /**
1605
+ * The delegate the reference prune acts through: it decides, per entry the manifest does not name,
1606
+ * between keeping a file of the user's and removing one of devflow's own.
1607
+ *
1608
+ * An unknown entry moves whole to the backup folder, with one rename. An entry the session
1609
+ * classifies as devflow's own (a retired or superseded reference, a leftover of an interrupted
1610
+ * write) is removed: a file or link by `unlink`, a folder by the files the session lists and a plain
1611
+ * `rmdir`, after its unknown entries moved. A folder is never removed recursively. A failed move is
1612
+ * in the session's record and the entry stays; a failed unlink reaches the sweep as a failure.
1613
+ *
1614
+ * An entry the session kept stays as it is, with no second attempt: a move that failed earlier in
1615
+ * the run, or a folder over the entry cap or one the scan could not read, and everything in it.
1616
+ */
1617
+ export function referenceSweepDelegate(backups) {
1618
+ return {
1619
+ async dispose(entry, isDirectory) {
1620
+ if (backups.isKept(entry))
1621
+ return { outcome: 'kept' };
1622
+ const kind = isDirectory ? 'directory' : (await entryKindAt(entry)) ?? 'file';
1623
+ if (backups.classifyAt(entry, kind) === 'unknown') {
1624
+ const moved = await backups.moveWhole(entry);
1625
+ return { outcome: moved.status === 'moved' ? 'moved' : 'kept' };
1626
+ }
1627
+ if (kind === 'directory') {
1628
+ await backups.prepass([entry]);
1629
+ const removal = await backups.removeListed(entry);
1630
+ return { outcome: removal.folderRemoved ? 'removed' : 'kept' };
1631
+ }
1632
+ try {
1633
+ await fs.unlink(entry);
1634
+ return { outcome: 'removed' };
1635
+ }
1636
+ catch (err) {
1637
+ if (err.code === 'ENOENT')
1638
+ return { outcome: 'kept' };
1639
+ return { outcome: 'failed', error: err };
1640
+ }
1641
+ },
1642
+ };
1643
+ }
1644
+ /**
1645
+ * Whether the run may move and remove below `root`: it is a real folder. An absent root has nothing
1646
+ * to scan. A link at a root the pre-clean replaces (`replaceLink`) is unlinked and reported, as the
1647
+ * pre-clean always did, and its target is not walked. A link that stays, or a path that is not a
1648
+ * folder, is a refused root: no move and no removal below it, and `refuseUnrealFolder` names it
1649
+ * later in the run.
1650
+ */
1651
+ async function mayChangeBelow(root, report, replaceLink) {
1652
+ const state = await directoryState(root);
1653
+ if (state === 'real')
1654
+ return true;
1655
+ if (state === 'link' && replaceLink && (await unlinkLinkAt(root)) === 'unlinked')
1656
+ report.replacedLinks.push(root);
1657
+ return false;
1658
+ }
1659
+ /** Whether `relative` (a path below a skill folder) is an overlay-owned path or lies below one. */
1660
+ function isUnderOverlayOwnedPath(relative, owned) {
1661
+ for (const ownedPath of owned) {
1662
+ if (relative === ownedPath || relative.startsWith(`${ownedPath}/`))
1663
+ return true;
1664
+ }
1665
+ return false;
1666
+ }
1667
+ /**
1668
+ * Whether the top-level entry that `relative` starts with is a `devflow:*` skill folder outside the
1669
+ * registry: the folders the skill registry-diff sweep would remove.
1670
+ */
1671
+ function isUnregisteredSkillFolder(relative, registry) {
1672
+ const top = relative.split('/', 1)[0];
1673
+ return top.startsWith(SKILL_NAMESPACE) && !registry.has(unprefixSkillName(top));
1674
+ }
1675
+ /**
1676
+ * Unlink a link that stands at an overlay-owned path or above one, inside the references skill
1677
+ * `skillDir`. The overlay writes and prunes through those paths, so a link left there would hand it
1678
+ * another folder. Each unlinked link is returned for {@link reportLinksOnceWritten}, since it is
1679
+ * replaced only once the overlay has written a real entry. Bounded by the owned set.
1680
+ */
1681
+ async function unlinkOverlayOwnedLinks(skillDir, owned) {
1682
+ const paths = new Set();
1683
+ for (const rel of owned) {
1684
+ const segments = rel.split('/');
1685
+ for (let depth = 1; depth <= segments.length; depth++)
1686
+ paths.add(segments.slice(0, depth).join('/'));
1687
+ }
1688
+ const unlinked = [];
1689
+ for (const rel of [...paths].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : 1))) {
1690
+ const entry = path.join(skillDir, ...rel.split('/'));
1691
+ if ((await unlinkLinkAt(entry)) === 'unlinked')
1692
+ unlinked.push(entry);
1693
+ }
1694
+ return unlinked;
1695
+ }
1567
1696
  /** The most variant files a refusal names; the count says how many more there are. */
1568
1697
  const MAX_REPORTED_MISSING_VARIANTS = 5;
1569
1698
  /** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
@@ -1773,7 +1902,7 @@ export async function installCommandsAndAgents(opts) {
1773
1902
  * Returns an InstallReport describing which shadows were applied and which were skipped.
1774
1903
  */
1775
1904
  export async function installViaFileCopy(options) {
1776
- const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, } = options;
1905
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
1777
1906
  const packageRoot = options.packageRoot ?? getPackageRoot();
1778
1907
  // D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
1779
1908
  // empty, and with it every folder step below is a no-op over an empty set.
@@ -1828,6 +1957,7 @@ export async function installViaFileCopy(options) {
1828
1957
  refusedFolders: [],
1829
1958
  removedSkills: [],
1830
1959
  dormantShadows: [],
1960
+ backups: backups.snapshot(),
1831
1961
  };
1832
1962
  // The skill decision, made in full before anything is touched — which skills
1833
1963
  // this selection installs, which it removes, and which shadows it leaves inert.
@@ -1855,50 +1985,107 @@ export async function installViaFileCopy(options) {
1855
1985
  // Links the pre-clean removed at a path that a later step of this install writes. Each is reported
1856
1986
  // as replaced only once a real file or folder stands there (reportLinksOnceWritten).
1857
1987
  const awaitingWrite = [];
1858
- // Clean old Devflow files before installing
1859
- spinner.message('Cleaning old files...');
1988
+ const skillsRoot = path.join(claudeDir, 'skills');
1989
+ const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1990
+ const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1991
+ const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1992
+ const skillFolder = (name) => path.join(skillsRoot, prefixSkillName(name));
1993
+ const registrySkills = new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]);
1994
+ const overlayOwned = overlayOwnedSkillPaths(refsManifest);
1995
+ const trackerAgentFile = mdFileName(TRACKER_AGENT_NAME);
1996
+ // Every root this run would change is checked first, with the same link checks the install uses
1997
+ // for its writes, and before the first move or removal. A refused root (a link that stays, or a
1998
+ // path that is not a folder) and everything below it get no move and no removal; the other roots
1999
+ // go on. A full install replaces a link at a root, as its pre-clean always did, and does not walk
2000
+ // the folder the link points to.
2001
+ const wipedFolders = [];
2002
+ const prepassTargets = [];
2003
+ const skillsRootState = await directoryState(skillsRoot);
2004
+ const mayChangeSkills = skillsRootState === 'real';
2005
+ // A skills root that is a link or not a folder is refused once, for every step below it: the
2006
+ // pre-pass, the sweeps, the skill copies and the overlay, the exact-name removals and the
2007
+ // replacement of a link at a workflow folder. Each of them would move, remove or write inside the
2008
+ // folder the link points to. The refusal is named in `refusedFolders`, and the install summary says
2009
+ // that nothing was written there. An absent root is fine: the skill copies create it.
2010
+ const skillsRefused = skillsRootState === 'link' || skillsRootState === 'unusable';
2011
+ if (skillsRefused)
2012
+ report.refusedFolders.push(skillsRoot);
1860
2013
  if (!isPartialInstall) {
1861
2014
  // Commands and agents are plugin-scoped — only wipe on full install.
1862
2015
  // On partial installs the registry-diff sweeps below handle stale entries
1863
2016
  // without discarding assets from plugins not included in this run.
1864
- const oldDirs = [
1865
- path.join(claudeDir, 'commands', 'devflow'),
1866
- path.join(claudeDir, 'rules', 'devflow'),
1867
- ];
1868
- for (const dir of oldDirs) {
1869
- try {
1870
- report.replacedLinks.push(...await removeReportingLink(dir));
1871
- }
1872
- catch { /* ignore */ }
2017
+ for (const dir of [commandsTarget, rulesTarget]) {
2018
+ if (!(await mayChangeBelow(dir, report, true)))
2019
+ continue;
2020
+ prepassTargets.push({ folder: dir });
2021
+ wipedFolders.push(dir);
1873
2022
  }
1874
2023
  // D-TRACKER-AGENT-OWNER, pre-clean half — the same split the reference tree
1875
2024
  // needed (D-OVERLAY-OWNERSHIP), for the same reason. The agent directory is
1876
2025
  // emptied AROUND the one file `convergeTrackerArtifacts` owns: taking it
1877
2026
  // would leave converge with nothing to byte-compare against, so a
1878
2027
  // steady-state re-init would re-copy the agent and announce
1879
- // `tracker agent installed` on every run. Everything else is removed
1880
- // exactly as the unconditional wipe removed it, and the file is still
1881
- // converged on this run — drift in it is restored, so preserving it strands
1882
- // nothing.
2028
+ // `tracker agent installed` on every run. The scan leaves that file out, and
2029
+ // the file is still converged on this run — drift in it is restored, so
2030
+ // preserving it strands nothing.
1883
2031
  //
1884
2032
  // A link at that file is left standing: the converge replaces it with a real file and reports it
1885
2033
  // once it has written, so the install does not claim a replacement the converge may not make.
1886
- try {
1887
- const emptied = await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]), { leaveKeptLinks: true });
1888
- report.replacedLinks.push(...emptied.replacedLinks);
1889
- awaitingWrite.push(...emptied.awaitingWrite);
2034
+ if (await mayChangeBelow(agentsTarget, report, true)) {
2035
+ prepassTargets.push({ folder: agentsTarget, include: relative => relative !== trackerAgentFile });
2036
+ wipedFolders.push(agentsTarget);
2037
+ }
2038
+ // The skill folders this run reinstalls, and the ones it removes. The references skill is
2039
+ // scanned around the paths the overlay owns (D-OVERLAY-OWNERSHIP): the overlay converges them
2040
+ // and moves what the user put in them, so the scan leaves them out.
2041
+ if (mayChangeSkills) {
2042
+ for (const skill of skillsMap.keys()) {
2043
+ const target = skillFolder(skill);
2044
+ if (!(await mayChangeBelow(target, report, true)))
2045
+ continue;
2046
+ if (skill === SKILL_REFS_SKILL_NAME) {
2047
+ awaitingWrite.push(...await unlinkOverlayOwnedLinks(target, overlayOwned));
2048
+ prepassTargets.push({ folder: target, include: relative => !isUnderOverlayOwnedPath(relative, overlayOwned) });
2049
+ }
2050
+ else {
2051
+ prepassTargets.push({ folder: target });
2052
+ }
2053
+ wipedFolders.push(target);
2054
+ }
2055
+ for (const name of [...skillPlan.remove, ...skillPlan.removeFolders]) {
2056
+ const target = skillFolder(name);
2057
+ if ((await directoryState(target)) === 'real')
2058
+ prepassTargets.push({ folder: target });
2059
+ }
2060
+ }
2061
+ }
2062
+ else {
2063
+ // Only what the registry-diff sweeps below would remove: the unknown .md files of the commands
2064
+ // and agents folders. Files of other kinds stay, as they always did on a partial install.
2065
+ for (const dir of [commandsTarget, agentsTarget]) {
2066
+ if (await mayChangeBelow(dir, report, false)) {
2067
+ prepassTargets.push({ folder: dir, include: relative => mdEntryName(relative) !== null });
2068
+ }
1890
2069
  }
1891
- catch { /* ignore */ }
1892
2070
  }
2071
+ // The devflow:* folders outside the registry, on every shape: a retired folder goes by its listed
2072
+ // files and anything else moves whole.
2073
+ if (mayChangeSkills) {
2074
+ prepassTargets.push({ folder: skillsRoot, include: relative => isUnregisteredSkillFolder(relative, registrySkills) });
2075
+ }
2076
+ const prepass = await backups.prepass(prepassTargets);
2077
+ const movedFromAgents = prepass.moved.some(unit => isContainedIn(agentsTarget, unit.original));
2078
+ // Clean old Devflow files before installing: each folder loses the entries the pre-pass listed as
2079
+ // devflow's own, then a plain rmdir. A folder that still holds a file the pre-pass could not move stays.
2080
+ spinner.message('Cleaning old files...');
2081
+ for (const folder of wipedFolders)
2082
+ await backups.removeListed(folder);
1893
2083
  // The commands, agents and rules folders, each proved a real folder (or absent) before any write
1894
2084
  // below. A full install's pre-clean replaced a link at each of them. A partial install has no
1895
2085
  // pre-clean, and a link the pre-clean could not remove is still standing, so every write below
1896
2086
  // would go through it into another directory: the sweeps, the copies and the restamp. A refused
1897
2087
  // folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
1898
2088
  // only when the selection has a rule, so only then is it proved.
1899
- const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1900
- const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1901
- const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1902
2089
  const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
1903
2090
  const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
1904
2091
  const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
@@ -1914,12 +2101,20 @@ export async function installViaFileCopy(options) {
1914
2101
  // are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
1915
2102
  // runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
1916
2103
  // then re-materialized by converge, creating a false-orphan report on every install.
1917
- recordSweep(report, 'skill', await sweepOrphanedAssets(path.join(claudeDir, 'skills'), new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null));
1918
- // Pre-clean the prefixed install targets before re-copying so stale content
1919
- // never bleeds into a fresh install. Bare pre-namespace dirs at
1920
- // ~/.claude/skills/{name} are owned solely by the frozen LEGACY_SKILL_NAMES
1921
- // pass in init.ts (runs immediately after this call). A bare dir whose name
1922
- // matches a current registry skill is by construction foreign to Devflow and
2104
+ //
2105
+ // The pre-pass has already moved every devflow:* folder outside the registry that holds a file of
2106
+ // the user's, so this sweep removes nothing itself: a retired folder goes by the files the session
2107
+ // listed, and a folder the pre-pass could not move stays where it is.
2108
+ if (mayChangeSkills) {
2109
+ recordSweep(report, 'skill', await sweepOrphanedAssets(skillsRoot, registrySkills, (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null, async (entryPath) => {
2110
+ await backups.removeListed(entryPath);
2111
+ return false;
2112
+ }));
2113
+ }
2114
+ // The prefixed install targets were pre-cleaned above, before re-copying, so stale content never
2115
+ // bleeds into a fresh install. Bare pre-namespace dirs at ~/.claude/skills/{name} are owned
2116
+ // solely by the frozen LEGACY_SKILL_NAMES pass in init.ts (runs immediately after this call). A
2117
+ // bare dir whose name matches a current registry skill is by construction foreign to Devflow and
1923
2118
  // must not be touched here.
1924
2119
  //
1925
2120
  // The pre-clean is SCOPED to what this run reinstalls and the orphan sweep
@@ -1954,28 +2149,11 @@ export async function installViaFileCopy(options) {
1954
2149
  // A target that is a link is unlinked and reported in `replacedLinks`, never emptied
1955
2150
  // through: the folder it points to is not Devflow's, and the install below writes a real
1956
2151
  // folder in its place.
1957
- if (!isPartialInstall) {
1958
- const overlayOwned = overlayOwnedSkillPaths(refsManifest);
1959
- for (const skill of skillsMap.keys()) {
1960
- // Empty the prefixed directory (its contents are re-created during the install
1961
- // phase), minus whatever another converger owns inside it.
1962
- const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
1963
- try {
1964
- if (skill === SKILL_REFS_SKILL_NAME) {
1965
- const emptied = await emptyDirectoryExcept(target, overlayOwned);
1966
- report.replacedLinks.push(...emptied.replacedLinks);
1967
- awaitingWrite.push(...emptied.awaitingWrite);
1968
- }
1969
- else {
1970
- report.replacedLinks.push(...await removeReportingLink(target));
1971
- }
1972
- }
1973
- catch { /* ignore */ }
1974
- }
2152
+ if (!isPartialInstall && !skillsRefused) {
1975
2153
  // A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
1976
2154
  // for a skill. A link it cannot remove is refused by the install below.
1977
2155
  for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
1978
- const target = path.join(claudeDir, 'skills', prefixSkillName(name));
2156
+ const target = skillFolder(name);
1979
2157
  if ((await unlinkLinkAt(target)) === 'unlinked')
1980
2158
  report.replacedLinks.push(target);
1981
2159
  }
@@ -1986,10 +2164,12 @@ export async function installViaFileCopy(options) {
1986
2164
  // (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
1987
2165
  // was on disk and is now gone: the plan is registry arithmetic and mostly names
1988
2166
  // skills that were never installed.
1989
- const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), [...skillPlan.remove, ...skillPlan.removeFolders]);
1990
- report.removedSkills.push(...deselected.removed);
1991
- for (const failure of deselected.failed) {
1992
- warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
2167
+ if (mayChangeSkills) {
2168
+ const deselected = await removeDeselectedSkills(skillsRoot, [...skillPlan.remove, ...skillPlan.removeFolders], backups);
2169
+ report.removedSkills.push(...deselected.removed);
2170
+ for (const failure of deselected.failed) {
2171
+ warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
2172
+ }
1993
2173
  }
1994
2174
  // Retired agents. The sweep runs BEFORE the copy so the copy can see what it found:
1995
2175
  // an agent file whose name left the registry means a previous release is installed
@@ -1999,17 +2179,21 @@ export async function installViaFileCopy(options) {
1999
2179
  // Ungated: runs on every install shape. knownNames spans ALL plugins
2000
2180
  // (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
2001
2181
  // names absent from the full registry are removed.
2182
+ //
2183
+ // The sweep removes only what the backup session classifies as devflow's own (a retired name, a
2184
+ // leftover of an interrupted write): an unknown file was moved by the pre-pass or stays.
2002
2185
  const agentSweep = agentsRefused
2003
2186
  ? { scanned: 0, removed: [], failed: [] }
2004
- : await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName);
2187
+ : await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink');
2005
2188
  recordSweep(report, 'agent', agentSweep);
2006
2189
  // A partial install writes the selected plugins' commands and agents and leaves every
2007
2190
  // other plugin's files alone, which is only safe while those files still name agents
2008
2191
  // that exist. After a retired agent was removed they do not, so a partial install
2009
2192
  // writes the commands and agents of every plugin the manifest records instead. The
2010
2193
  // widening is conditional because an ordinary add-one run has nothing to repair and
2011
- // must not rewrite files the user did not ask about.
2012
- const widenToInstalled = isPartialInstall && agentSweep.removed.length > 0;
2194
+ // must not rewrite files the user did not ask about. An agent file the pre-pass moved out of the
2195
+ // folder is the same sign: it came from an earlier release, so the commands may still name it.
2196
+ const widenToInstalled = isPartialInstall && (agentSweep.removed.length > 0 || movedFromAgents);
2013
2197
  const commandAgentPlugins = widenToInstalled ? (options.effectivePlugins ?? plugins) : plugins;
2014
2198
  const commandAgentMap = widenToInstalled ? buildAssetMaps(commandAgentPlugins).agentsMap : agentsMap;
2015
2199
  // Install commands and agents (D-TRACKER-AGENT-OWNER and the source-resolution rules
@@ -2034,7 +2218,7 @@ export async function installViaFileCopy(options) {
2034
2218
  // plugins survive a partial run. Only names absent from the full registry are removed.
2035
2219
  // A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
2036
2220
  if (!commandsRefused) {
2037
- recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName));
2221
+ recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink'));
2038
2222
  }
2039
2223
  // D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
2040
2224
  // installed /code-review, on every install shape. This is the language gate the command reads
@@ -2060,8 +2244,12 @@ export async function installViaFileCopy(options) {
2060
2244
  // skills here. The pre-clean above walked the UNFILTERED map, so on a full
2061
2245
  // install a leftover copy is already gone; a partial install leaves removal to
2062
2246
  // convergeVariantAxes, which init runs straight after this call.
2247
+ //
2248
+ // A refused skills root (a link or not a folder) gets no skill: every copy, and the overlay
2249
+ // below it, would write into the folder the link points to.
2063
2250
  spinner.message('Installing skills...');
2064
- for (const [skillName] of omitCurationGatedSkills(skillsMap, curation)) {
2251
+ const skillsToInstall = skillsRefused ? [] : [...omitCurationGatedSkills(skillsMap, curation).keys()];
2252
+ for (const skillName of skillsToInstall) {
2065
2253
  const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
2066
2254
  // A full install's pre-clean replaced a link at the skill or its references entry. A partial
2067
2255
  // install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
@@ -2104,6 +2292,7 @@ export async function installViaFileCopy(options) {
2104
2292
  sourceRoot: refsSourceRoot,
2105
2293
  manifest: refsManifest,
2106
2294
  warn,
2295
+ backups,
2107
2296
  });
2108
2297
  report.overlaidRefs.push(...overlay.overlaidRefs);
2109
2298
  report.unchangedRefs.push(...overlay.unchangedRefs);
@@ -2119,7 +2308,8 @@ export async function installViaFileCopy(options) {
2119
2308
  // none of them; this step removes the ones an earlier compliance-on install left, which a partial
2120
2309
  // install copies over without clearing. A removal that was refused is a reported failure and
2121
2310
  // never an abort; the compliance converge that init runs next fails closed on the same files.
2122
- if (!compliance) {
2311
+ // A refused skills root is not entered: the removal would reach files in the folder a link points to.
2312
+ if (!compliance && !skillsRefused) {
2123
2313
  const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
2124
2314
  for (const refusal of removal.refused) {
2125
2315
  report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
@@ -2141,7 +2331,7 @@ export async function installViaFileCopy(options) {
2141
2331
  warn(`workflow skill folder: ${read.reason}`);
2142
2332
  continue;
2143
2333
  }
2144
- const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report });
2334
+ const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report, backups });
2145
2335
  if (installed.outcome === 'failed') {
2146
2336
  warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
2147
2337
  }
@@ -2176,6 +2366,7 @@ export async function installViaFileCopy(options) {
2176
2366
  warn,
2177
2367
  });
2178
2368
  spinner.stop('Components installed via file copy');
2369
+ report.backups = backups.snapshot();
2179
2370
  return report;
2180
2371
  }
2181
2372
  //# sourceMappingURL=installer.js.map