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.
@@ -1,64 +1,159 @@
1
+ import { createHash } from 'crypto';
1
2
  import { promises as fs } from 'fs';
2
3
  import * as path from 'path';
3
4
  import { LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES } from '../../core/plugins.js';
4
- import { isUnrealFolder } from '../../targets/claude-code/linked-folder.js';
5
+ import { RETIRED_SHIPPED } from '../../core/retired-shipped-names.js';
6
+ import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
7
+ import { BARE_LEGACY_SKILL_HASHES } from '../../targets/claude-code/legacy-skill-hashes.js';
8
+ import { directoryState, isUnrealFolder } from '../../targets/claude-code/linked-folder.js';
9
+ /** The prefix of a skill folder in devflow's own namespace. A folder without it is bare. */
10
+ const NAMESPACE_PREFIX = 'devflow:';
5
11
  /**
6
- * Whether the sweep may remove files from `dir`: the install did not refuse it, and `lstat` shows a
7
- * real folder or nothing. `readdir` and `rm` of a path below a link act on the folder it points to.
12
+ * Whether the sweep may remove files from `dir`: the install did not refuse it, nothing in it stays
13
+ * because a move failed or the folder was too large to scan, and `lstat` shows a real folder or
14
+ * nothing. `readdir` and `rm` of a path below a link act on the folder it points to.
8
15
  */
9
- async function mayRemoveFrom(report, dir) {
10
- return !report.refusedFolders.includes(dir) && !(await isUnrealFolder(dir));
16
+ async function mayRemoveFrom(report, backups, dir) {
17
+ return !report.refusedFolders.includes(dir) && !backups.isKept(dir) && !(await isUnrealFolder(dir));
18
+ }
19
+ /** Each name once, in the order given. */
20
+ const uniqueNames = (...lists) => [...new Set(lists.flat())];
21
+ /**
22
+ * Remove `<folder>/<name>.md` for each name, by exact name. A name that is shipped today is never
23
+ * removed, and a file that stays is left for the session's record.
24
+ */
25
+ async function removeRetiredMarkdown(folder, names, backups) {
26
+ const removed = [];
27
+ for (const name of names) {
28
+ const file = path.join(folder, `${name}.md`);
29
+ if (backups.classifyAt(file, 'file') === 'shipped')
30
+ continue;
31
+ if ((await backups.removeExact(file)).status === 'removed')
32
+ removed.push(file);
33
+ }
34
+ return removed;
11
35
  }
12
36
  /**
13
37
  * Remove the files that earlier releases installed into `commands/devflow` and `rules/devflow` and
14
- * that no release installs now: the legacy command and rule names, and every `*-teams.md` command.
15
- * Runs on every install shape, since the full install's pre-clean covers only a full install.
38
+ * that no release installs now: the retired command and rule names and the legacy lists, each by its
39
+ * exact name. Runs on every install shape, since the full install's pre-clean covers only a full
40
+ * install. A file whose name only looks like a retired one (a `*-teams.md` of the user's) is not
41
+ * matched here: the install's pre-pass moves it aside and names it.
16
42
  *
17
- * A folder the install refused (`report.refusedFolders`) or that is a link now is skipped whole: the
18
- * sweep reads and removes nothing there, and the folder it points to is left as it is. Never throws.
43
+ * A folder the install refused (`report.refusedFolders`), that the session could not scan, or that
44
+ * is a link now is skipped whole: the sweep reads and removes nothing there, and the folder a link
45
+ * points to is left as it is. Every removal goes through `backups`, so each removed file is named
46
+ * once, on the retired line of its record. Never throws.
19
47
  *
20
- * @param opts.legacyCommands - Legacy command names; defaults to the registry list.
21
- * @param opts.legacyRules - Legacy rule names; defaults to the registry list.
48
+ * @param opts.legacyCommands - Legacy command names; added to the retired commands.
49
+ * @param opts.legacyRules - Legacy rule names; added to the retired rules.
22
50
  */
23
51
  export async function sweepLegacyDevflowFiles(opts) {
24
- const { claudeDir, report } = opts;
25
- const legacyCommands = opts.legacyCommands ?? LEGACY_COMMAND_NAMES;
26
- const legacyRules = opts.legacyRules ?? LEGACY_RULE_NAMES;
27
- const result = { staleCommandsRemoved: 0, staleRulesRemoved: 0 };
52
+ const { claudeDir, report, backups } = opts;
53
+ const commands = uniqueNames(RETIRED_SHIPPED.commands, opts.legacyCommands ?? LEGACY_COMMAND_NAMES);
54
+ const rules = uniqueNames(RETIRED_SHIPPED.rules, opts.legacyRules ?? LEGACY_RULE_NAMES);
55
+ const removed = [];
28
56
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
29
- if (await mayRemoveFrom(report, commandsDir)) {
30
- for (const legacy of legacyCommands) {
31
- try {
32
- await fs.rm(path.join(commandsDir, `${legacy}.md`));
33
- result.staleCommandsRemoved++;
34
- }
35
- catch { /* absent — expected for most entries */ }
36
- }
37
- // Orphaned *-teams.md workflow command variants left by the Agent Teams refactor. None is ever
38
- // re-installed, so a blanket sweep is safe on any install type.
39
- try {
40
- for (const entry of await fs.readdir(commandsDir)) {
41
- if (!entry.endsWith('-teams.md'))
42
- continue;
43
- try {
44
- await fs.rm(path.join(commandsDir, entry));
45
- result.staleCommandsRemoved++;
46
- }
47
- catch { /* already gone */ }
48
- }
49
- }
50
- catch { /* commands folder absent — nothing to sweep */ }
57
+ if (await mayRemoveFrom(report, backups, commandsDir)) {
58
+ removed.push(...await removeRetiredMarkdown(commandsDir, commands, backups));
51
59
  }
52
60
  const rulesDir = path.join(claudeDir, 'rules', 'devflow');
53
- if (await mayRemoveFrom(report, rulesDir)) {
54
- for (const legacy of legacyRules) {
55
- try {
56
- await fs.rm(path.join(rulesDir, `${legacy}.md`));
57
- result.staleRulesRemoved++;
58
- }
59
- catch { /* absent — expected for most entries */ }
61
+ if (await mayRemoveFrom(report, backups, rulesDir)) {
62
+ removed.push(...await removeRetiredMarkdown(rulesDir, rules, backups));
63
+ }
64
+ return { removed };
65
+ }
66
+ /** The most bytes of a `SKILL.md` the check reads. Every released copy is far smaller, so a larger file is not one. */
67
+ const MAX_SKILL_FILE_BYTES = 1024 * 1024;
68
+ /**
69
+ * Who made a bare legacy skill folder, judged from its `SKILL.md` alone:
70
+ * - `devflow`: the file is a regular file whose SHA-256 is one of `released`;
71
+ * - `other`: the file reads and is not one of them, or is larger than any release copy;
72
+ * - `unknown`: the check cannot decide. There is no `SKILL.md`, it is not a regular file, the
73
+ * folder or the file is a link, or a read fails.
74
+ * An `absent` folder needs no answer. Reads one file at most. Never throws.
75
+ */
76
+ async function judgeBareSkillFolder(folder, released) {
77
+ const state = await directoryState(folder);
78
+ if (state === 'absent')
79
+ return 'absent';
80
+ if (state !== 'real')
81
+ return 'unknown';
82
+ try {
83
+ const skillFile = path.join(folder, 'SKILL.md');
84
+ const stat = await fs.lstat(skillFile);
85
+ if (!stat.isFile())
86
+ return 'unknown';
87
+ if (stat.size > MAX_SKILL_FILE_BYTES)
88
+ return 'other';
89
+ const digest = createHash('sha256').update(await fs.readFile(skillFile)).digest('hex');
90
+ return released.includes(digest) ? 'devflow' : 'other';
91
+ }
92
+ catch {
93
+ return 'unknown';
94
+ }
95
+ }
96
+ /**
97
+ * {@link judgeBareSkillFolder} for the bare legacy skill `name`, against the released copies of that
98
+ * name. A name with no entry in `released` was never installed bare by a release, so a folder of
99
+ * that name is `other`. The one check that `devflow init` and `devflow uninstall` share.
100
+ *
101
+ * @param folder - The folder of the skill, `<skills folder>/<name>`.
102
+ * @param released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
103
+ */
104
+ export function judgeBareLegacySkill(folder, name, released = BARE_LEGACY_SKILL_HASHES) {
105
+ return judgeBareSkillFolder(folder, Object.hasOwn(released, name) ? released[name] : []);
106
+ }
107
+ /**
108
+ * Deal with the skill folders that earlier installs left under the legacy names: every name of the
109
+ * legacy skill list, bare and `devflow:`-prefixed.
110
+ *
111
+ * A `devflow:`-prefixed folder that no release listed moves whole, with one rename and no walk, and
112
+ * is named; it is never deleted. A bare folder lives in the skills folder that Claude Code and other
113
+ * tools share, so its name proves nothing (`python`, `plan` and `review` are names of a user's own
114
+ * skills too). It moves only when its `SKILL.md` is a copy that a release of devflow installed
115
+ * (`released`). A bare folder whose `SKILL.md` is any other file stays where it is, and nothing is
116
+ * said. A bare folder that the check cannot judge stays, is never deleted, and is returned in
117
+ * `kept`, so the caller names it. A name with a listing in `retiredFiles` (the files a release
118
+ * shipped in that folder) goes by the listing instead: the listed files are removed, the rest of
119
+ * the folder moves, and a plain `rmdir` removes the folder only when it is empty. A move that fails
120
+ * leaves the folder and its files where they are, and the session records it. A name that is shipped
121
+ * today, or that an earlier step could not move, is skipped, and so is every name when the skills
122
+ * folder is a link or not a folder: nothing is moved through a link. Nothing here removes a folder
123
+ * with recursion. Never throws.
124
+ *
125
+ * @param opts.legacySkills - Folder names to deal with; defaults to the legacy skill list.
126
+ * @param opts.retiredFiles - Released file listings by folder name; defaults to the retired registry.
127
+ * @param opts.released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
128
+ */
129
+ export async function sweepLegacySkillFolders(opts) {
130
+ const { backups } = opts;
131
+ const retiredFiles = opts.retiredFiles ?? RETIRED_SHIPPED.skillFiles;
132
+ const released = opts.released ?? BARE_LEGACY_SKILL_HASHES;
133
+ const kept = [];
134
+ const skillsDir = path.join(opts.claudeDir, 'skills');
135
+ if ((await directoryState(skillsDir)) !== 'real')
136
+ return { kept };
137
+ for (const name of opts.legacySkills ?? LEGACY_SKILL_NAMES) {
138
+ const folder = path.join(skillsDir, name);
139
+ if (backups.isKept(folder) || backups.classifyAt(folder, 'directory') === 'shipped')
140
+ continue;
141
+ const listed = Object.hasOwn(retiredFiles, name) && retiredFiles[name].length > 0;
142
+ if (listed && (await directoryState(folder)) === 'real') {
143
+ await backups.prepass([folder]);
144
+ await backups.removeListed(folder);
145
+ }
146
+ else if (name.startsWith(NAMESPACE_PREFIX)) {
147
+ await backups.moveWhole(folder);
148
+ }
149
+ else {
150
+ const verdict = await judgeBareLegacySkill(folder, name, released);
151
+ if (verdict === 'devflow')
152
+ await backups.moveWhole(folder);
153
+ else if (verdict === 'unknown')
154
+ kept.push(folder);
60
155
  }
61
156
  }
62
- return result;
157
+ return { kept };
63
158
  }
64
159
  //# sourceMappingURL=legacy-sweep.js.map
@@ -10,7 +10,9 @@ import { rulesDir } from '../../core/assets.js';
10
10
  import { installAllRules, validateRuleShadow } from '../../targets/claude-code/installer.js';
11
11
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
12
12
  import { convergeBehaviorFromManifest } from '../../targets/claude-code/behavior-install.js';
13
- import { newLinkReport, refuseUnrealFolder, refusedFolderMessage, removeReportingLink } from '../../targets/claude-code/linked-folder.js';
13
+ import { newLinkReport, refuseUnrealFolder, refusedFolderMessage } from '../../targets/claude-code/linked-folder.js';
14
+ import { removeRulesFolder, rulesFolderRefusedMessage } from '../../targets/claude-code/rules-folder.js';
15
+ import { openBackupSessionOrFail, runWithBackupReport } from './backup-session.js';
14
16
  import { formatLinksSummary } from './install-report.js';
15
17
  /**
16
18
  * Check whether a rule has a user shadow file at ~/.devflow/rules/{name}.md.
@@ -205,20 +207,29 @@ async function handleRuleUnshadow(name, allRules, devflowDir) {
205
207
  *
206
208
  * Warn-not-throw on per-item compliance convergence failure, so a convergence
207
209
  * failure never undoes the rules install.
210
+ *
211
+ * The record of `backups` prints once, as the enable returns or throws.
208
212
  */
209
- export async function runRulesEnable(claudeDir, devflowDir) {
213
+ export function runRulesEnable(claudeDir, devflowDir, backups) {
214
+ return runWithBackupReport(backups, printBackups => enableRules(claudeDir, devflowDir, backups, printBackups));
215
+ }
216
+ async function enableRules(claudeDir, devflowDir, backups, printBackups) {
210
217
  const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
211
218
  const manifest = await readManifest(devflowDir);
212
219
  if (!manifest) {
213
220
  p.log.error('No manifest found. Run devflow init first.');
221
+ printBackups();
214
222
  process.exit(1);
215
223
  }
216
224
  const installedPlugins = DEVFLOW_PLUGINS.filter(pl => manifest.plugins.includes(pl.name));
217
225
  const rulesMap = buildRulesMap(installedPlugins);
218
- // Wipe stale rules from previously uninstalled plugins before re-installing. A rules folder that is
219
- // a link is unlinked and replaced by a real folder, never emptied through, and the link is reported.
226
+ // Clear stale rules from previously uninstalled plugins before re-installing: the files devflow did
227
+ // not ship move to the backup folder, and the rest of the folder is removed by the session's list.
228
+ // A rules folder that is a link is unlinked and replaced by a real folder, never emptied through,
229
+ // and the link is reported. Something that is not a folder stays and is refused below.
220
230
  const links = newLinkReport();
221
- links.replacedLinks.push(...await removeReportingLink(rulesTarget));
231
+ if ((await removeRulesFolder(rulesTarget, backups)) === 'unlinked')
232
+ links.replacedLinks.push(rulesTarget);
222
233
  // A link that could not be removed is still there, and `mkdir` and every rule write would follow it.
223
234
  // Prove the folder first; a refused folder gets no rule, no manifest write and one refusal line.
224
235
  const rulesFolderRefused = await refuseUnrealFolder(links, rulesTarget);
@@ -268,6 +279,7 @@ export async function runRulesEnable(claudeDir, devflowDir) {
268
279
  manifest,
269
280
  warn: warnConverge,
270
281
  rulesEnabledOverride: !installFailed,
282
+ backups,
271
283
  });
272
284
  }
273
285
  catch (err) {
@@ -325,17 +337,42 @@ export const rulesCommand = new Command('rules')
325
337
  }
326
338
  // No positional action — fall through to flag-based handling
327
339
  if (options.enable) {
328
- await runRulesEnable(claudeDir, devflowDir);
340
+ const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
341
+ if (backups === null)
342
+ return;
343
+ await runRulesEnable(claudeDir, devflowDir, backups);
329
344
  }
330
345
  else if (options.disable) {
331
- await fs.rm(rulesTarget, { recursive: true, force: true });
332
- const manifest = await readManifest(devflowDir);
333
- if (manifest) {
334
- manifest.features.rules = false;
335
- manifest.updatedAt = new Date().toISOString();
336
- await writeManifest(devflowDir, manifest);
346
+ const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
347
+ if (backups === null)
348
+ return;
349
+ const removal = await runWithBackupReport(backups, async () => {
350
+ const outcome = await removeRulesFolder(rulesTarget, backups);
351
+ const manifest = await readManifest(devflowDir);
352
+ if (manifest) {
353
+ manifest.features.rules = false;
354
+ manifest.updatedAt = new Date().toISOString();
355
+ await writeManifest(devflowDir, manifest);
356
+ }
357
+ return outcome;
358
+ });
359
+ switch (removal) {
360
+ case 'kept':
361
+ p.log.warn(`Rules disabled — ${rulesTarget} stays, because it still holds files that devflow did not remove`);
362
+ break;
363
+ case 'refused':
364
+ p.log.warn(`Rules disabled. ${rulesFolderRefusedMessage(rulesTarget)}`);
365
+ break;
366
+ case 'absent':
367
+ case 'removed':
368
+ case 'unlinked':
369
+ p.log.success('Rules disabled — removed rules directory');
370
+ break;
371
+ default: {
372
+ const unreachable = removal;
373
+ void unreachable;
374
+ }
337
375
  }
338
- p.log.success('Rules disabled — removed rules directory');
339
376
  }
340
377
  else if (options.status) {
341
378
  let installedFiles = [];
@@ -37,8 +37,10 @@ import { probeTrackedArea } from '../../core/tracked-areas.js';
37
37
  import { restampInstalledCommands } from '../../targets/claude-code/language-stamp.js';
38
38
  import { installRenderedCharter, removeHandoffSentinel } from '../../targets/claude-code/charter-render.js';
39
39
  import { installCommandsAndAgents } from '../../targets/claude-code/installer.js';
40
+ import { BACKUP_ROOT_NAME, escapeName } from '../../targets/claude-code/install-backup.js';
40
41
  import { isUnrealFolder, newLinkReport, refuseUnrealFolder, refusedFolderStatement } from '../../targets/claude-code/linked-folder.js';
41
42
  import { formatReplacedLinksSummary } from './install-report.js';
43
+ import { judgeBareLegacySkill } from './legacy-sweep.js';
42
44
  /**
43
45
  * Where a retired repo-local install lives: `<gitRoot>/.claude` and `<gitRoot>/.devflow`.
44
46
  *
@@ -654,6 +656,9 @@ export function userContentPaths(devflowDir) {
654
656
  { relPath: 'skills', isDir: true, label: `skill shadows (${path.join(devflowDir, 'skills')})` },
655
657
  // Rule shadow overrides (~/.devflow/rules/{name}.md)
656
658
  { relPath: 'rules', isDir: true, label: `rule shadows (${path.join(devflowDir, 'rules')})` },
659
+ // The files an install moved out of the devflow folders because devflow did not ship them.
660
+ // They are the user's own files, so only a confirmed full wipe removes them.
661
+ { relPath: BACKUP_ROOT_NAME, isDir: true, label: `install backups (${path.join(devflowDir, BACKUP_ROOT_NAME)})` },
657
662
  // preference-profile.md — user-curated decision-preference profile
658
663
  { relPath: 'preference-profile.md', label: 'preference-profile.md' },
659
664
  // tracker/ — the inferred, hand-editable issue-tracker conventions, one file
@@ -921,7 +926,7 @@ export function installArtifactPaths(devflowDir) {
921
926
  * This function runs on the decline, cancel, non-interactive AND --keep-docs paths,
922
927
  * so an entry here is deleted even when the user answers "no" to the full wipe.
923
928
  * User-authored state (everything in `userContentPaths`: skill/rule shadows,
924
- * preference-profile.md, the tracker conventions with the legacy tracker.md and
929
+ * the install backups, preference-profile.md, the tracker conventions with the legacy tracker.md and
925
930
  * its backups, the global tuning file under both names, hud.json) is removed only by
926
931
  * the confirmed full-dir rm.
927
932
  * agent-models.json is an INSTALL ARTIFACT (stale per-agent overrides silently
@@ -1012,6 +1017,35 @@ export async function isDevFlowInstalled(claudeDir) {
1012
1017
  catch { /* skills dir absent or unreadable */ }
1013
1018
  return false;
1014
1019
  }
1020
+ /** Why a bare legacy skill folder stays: its `SKILL.md` is a file that no release of devflow installed. */
1021
+ const KEPT_NOT_MADE = 'devflow did not make this folder';
1022
+ /** Why a bare legacy skill folder stays: there is no regular `SKILL.md`, the folder or file is a link, or a read failed. */
1023
+ const KEPT_UNDECIDED = 'devflow could not tell if it made this folder';
1024
+ /**
1025
+ * Sort the bare legacy skill folders that exist in `skillsDir` into those a full uninstall removes
1026
+ * and those it keeps. The skills folder is shared with Claude Code and other tools, so a bare name
1027
+ * (`python`, `plan`) does not prove that devflow made the folder. It is removed only when the check
1028
+ * `devflow init` uses finds a `SKILL.md` that a release installed (`judgeBareLegacySkill`). The dry
1029
+ * run and the removal both take this one list. A `devflow:` name is in the namespace of devflow, and
1030
+ * the prefixed pass removes it. Reads one file per folder at most. Never throws.
1031
+ */
1032
+ async function planBareLegacySkills(skillsDir) {
1033
+ const remove = [];
1034
+ const keep = [];
1035
+ for (const name of new Set(LEGACY_SKILL_NAMES)) {
1036
+ if (name.startsWith(SKILL_NAMESPACE))
1037
+ continue;
1038
+ const folder = path.join(skillsDir, name);
1039
+ const verdict = await judgeBareLegacySkill(folder, name);
1040
+ if (verdict === 'devflow')
1041
+ remove.push(folder);
1042
+ else if (verdict === 'other')
1043
+ keep.push({ folder, reason: KEPT_NOT_MADE });
1044
+ else if (verdict === 'unknown')
1045
+ keep.push({ folder, reason: KEPT_UNDECIDED });
1046
+ }
1047
+ return { remove, keep };
1048
+ }
1015
1049
  // ---------------------------------------------------------------------------
1016
1050
  // Phase runners — extracted from the action function for readability and
1017
1051
  // independent testability. Each covers one logical stage of the uninstall.
@@ -1029,9 +1063,10 @@ export async function isDevFlowInstalled(claudeDir) {
1029
1063
  * and the scripts directory inside devflowDir.
1030
1064
  * 2. ALL skill removal candidates (existence-filtered):
1031
1065
  * - Prefixed (devflow:name): live registry ∪ LEGACY_SKILL_NAMES.
1032
- * - Bare (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY.
1033
- * ~/.claude/skills/ is shared; bare dirs for live-registry skill names
1034
- * are by construction foreign to Devflow.
1066
+ * - Bare (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY, and only a
1067
+ * folder that devflow made (planBareLegacySkills). ~/.claude/skills/ is
1068
+ * shared; bare dirs for live-registry skill names are by construction
1069
+ * foreign to Devflow. A bare folder that stays is listed as `(kept: <reason>)`.
1035
1070
  * 3. manifest.json (removed separately in removeDevFlowInstallArtifacts).
1036
1071
  * 4. Every artifact path from installArtifactPaths(devflowDir) — the single
1037
1072
  * source of truth shared with the real removal loop.
@@ -1057,8 +1092,8 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir, folderWorkflo
1057
1092
  // 2. Skills: enumerate ALL removal candidates that exist on disk.
1058
1093
  // Mirrors removeAllDevFlow's split-pass approach:
1059
1094
  // Prefixed: live registry ∪ LEGACY_SKILL_NAMES ∪ FEATURE_OWNED_SKILLS
1060
- // Bare: LEGACY_SKILL_NAMES only — shared skills/ dir; live-registry bare
1061
- // dirs are by construction foreign to Devflow.
1095
+ // Bare: LEGACY_SKILL_NAMES only, and only a folder that devflow made — shared
1096
+ // skills/ dir; live-registry bare dirs are by construction foreign to Devflow.
1062
1097
  // FEATURE_OWNED_SKILLS (compliance) is included so full uninstall removes
1063
1098
  // devflow:compliance even though it left the plugin registry (step 1.5).
1064
1099
  // The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) join through getAllSkillNames.
@@ -1072,14 +1107,10 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir, folderWorkflo
1072
1107
  }
1073
1108
  catch { /* absent */ }
1074
1109
  }
1075
- for (const skillName of LEGACY_SKILL_NAMES) {
1076
- const barePath = path.join(skillsDir, skillName);
1077
- try {
1078
- await fs.access(barePath);
1079
- extras.push(barePath);
1080
- }
1081
- catch { /* absent */ }
1082
- }
1110
+ const bareSkills = await planBareLegacySkills(skillsDir);
1111
+ extras.push(...bareSkills.remove);
1112
+ for (const { folder, reason } of bareSkills.keep)
1113
+ extras.push(`${escapeName(folder)} (kept: ${reason})`);
1083
1114
  // 3. manifest.json — removed separately by removeDevFlowInstallArtifacts
1084
1115
  // (NOT in installArtifactPaths). List it here so the preview is complete.
1085
1116
  const manifestPath = path.join(devflowDir, 'manifest.json');
@@ -1422,9 +1453,11 @@ export async function runSelectivePhaseForScope(opts) {
1422
1453
  * @param opts.isTTY - Whether the session is interactive. Injected rather than
1423
1454
  * read from process.stdin so the prompt gate is an explicit input (matching
1424
1455
  * resolveDevflowDirCleanup) instead of an ambient global a test cannot control.
1456
+ * @param opts.confirm - The prompt for the full-dir wipe (default `p.confirm`).
1425
1457
  */
1426
1458
  export async function runFullPhaseForScope(opts) {
1427
1459
  const { scope, claudeDir, devflowDir, devflowScriptsDir, verbose, keepDocs, isTTY } = opts;
1460
+ const confirm = opts.confirm ?? p.confirm;
1428
1461
  // D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first.
1429
1462
  const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir });
1430
1463
  // Revert GPT agent frontmatter before removing agents — ensures no orphaned
@@ -1482,7 +1515,7 @@ export async function runFullPhaseForScope(opts) {
1482
1515
  for (const item of userContent) {
1483
1516
  p.log.info(` ${item}`);
1484
1517
  }
1485
- const confirmFullCleanup = await p.confirm({
1518
+ const confirmFullCleanup = await confirm({
1486
1519
  message: `Remove entire ${devflowDir}? (includes the items listed above, plus logs and install metadata)`,
1487
1520
  initialValue: false,
1488
1521
  });
@@ -2005,10 +2038,11 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
2005
2038
  // prefixSkillName() is idempotent for already-prefixed legacy entries.
2006
2039
  // FEATURE_OWNED_SKILLS (compliance) is included so full uninstall removes
2007
2040
  // devflow:compliance even though it left the plugin registry (step 1.5).
2008
- // Bare pass (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY.
2009
- // A bare dir whose name matches a live-registry skill is by construction
2010
- // foreign to Devflow (the devflow: namespace shipped in dcecda3, 2026-03-30).
2011
- // A bare 'compliance/' dir is also foreign (not in LEGACY_SKILL_NAMES).
2041
+ // Bare pass (name or devflow-name legacy): LEGACY_SKILL_NAMES ONLY, and only a folder
2042
+ // whose SKILL.md is a copy that a release of devflow installed (planBareLegacySkills).
2043
+ // Any other bare folder stays and is named. A bare dir whose name matches a
2044
+ // live-registry skill is by construction foreign to Devflow (the devflow: namespace
2045
+ // shipped in dcecda3, 2026-03-30). A bare 'compliance/' dir is also foreign (not in LEGACY_SKILL_NAMES).
2012
2046
  // The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) are in getAllSkillNames, so the folder is removed.
2013
2047
  const prefixedSkillNames = new Set([...getAllSkillNames(folderWorkflows), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
2014
2048
  const skillsDir = path.join(claudeDir, 'skills');
@@ -2025,17 +2059,21 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
2025
2059
  }
2026
2060
  catch { /* Skill doesn't exist */ }
2027
2061
  }
2028
- for (const skillName of LEGACY_SKILL_NAMES) {
2029
- // Remove bare variant — frozen legacy list only, never the live registry
2030
- const barePath = path.join(skillsDir, skillName);
2062
+ // Bare variant: frozen legacy list only, never the live registry, and only a folder that devflow made.
2063
+ const bareSkills = await planBareLegacySkills(skillsDir);
2064
+ for (const barePath of bareSkills.remove) {
2031
2065
  try {
2032
- await fs.stat(barePath);
2033
2066
  if (!(await mayChange(barePath)))
2034
2067
  continue;
2035
2068
  await fs.rm(barePath, { recursive: true, force: true });
2036
2069
  skillsRemoved++;
2037
2070
  }
2038
- catch { /* Skill doesn't exist */ }
2071
+ catch (error) {
2072
+ p.log.warn(`Could not remove ${escapeName(barePath)}: ${error}`);
2073
+ }
2074
+ }
2075
+ for (const { folder, reason } of bareSkills.keep) {
2076
+ p.log.info(`Kept ${escapeName(folder)}: ${reason}`);
2039
2077
  }
2040
2078
  if (skillsRemoved > 0 && verbose) {
2041
2079
  p.log.success(`Removed ${skillsRemoved} Devflow skill directories`);
@@ -31,6 +31,20 @@ export const COMPLIANCE_RULE_PLACEHOLDER = '${DEVFLOW_COMPLIANCE_FRAMEWORKS}';
31
31
  * definition. Adding a third always-present ref requires only one change here.
32
32
  */
33
33
  export const ALWAYS_PRESENT_REFS = ['detection.md', 'sources.md'];
34
+ /**
35
+ * Every file the installed compliance skill folder holds, as POSIX paths below it: `SKILL.md`, the
36
+ * always-present references, and one `references/{id}.md` per registry framework. The install
37
+ * writes all of them on every machine whatever the selection (D-COMPLIANCE-INSTALL-ALWAYS), so
38
+ * this is also the set a scan treats as shipped. The skill source holds more (the framework
39
+ * fragments), which the install does not copy.
40
+ */
41
+ export function complianceSkillFiles() {
42
+ return [
43
+ 'SKILL.md',
44
+ ...ALWAYS_PRESENT_REFS.map(ref => `references/${ref}`),
45
+ ...COMPLIANCE_FRAMEWORKS.map(fw => `references/${fw.id}.md`),
46
+ ];
47
+ }
34
48
  // ---------------------------------------------------------------------------
35
49
  // Internal helpers
36
50
  // ---------------------------------------------------------------------------
@@ -10,6 +10,10 @@ import { promises as fs } from 'fs';
10
10
  * intentionally remains a separate implementation — same semantics, different module
11
11
  * system. Any change to the retry logic here MUST be mirrored there.
12
12
  */
13
+ /** The sibling `filePath` is written to before the rename: `<file>.tmp.<pid>`. */
14
+ export function atomicTempPath(filePath) {
15
+ return `${filePath}.tmp.${process.pid}`;
16
+ }
13
17
  /**
14
18
  * Atomically write `filePath` by writing to a sibling `.tmp` then renaming.
15
19
  *
@@ -38,7 +42,7 @@ export async function writeFileAtomicExclusive(filePath, data, createMode) {
38
42
  // (e.g., two Claude Code sessions) never collide on the same .tmp path.
39
43
  // mirrors proxy-log.ts rotation at src/core/proxy-log.ts which PID-scopes
40
44
  // for the same reason.
41
- const tmp = `${filePath}.tmp.${process.pid}`;
45
+ const tmp = atomicTempPath(filePath);
42
46
  const options = { encoding: 'utf-8', flag: 'wx', ...(createMode === undefined ? {} : { mode: createMode }) };
43
47
  try {
44
48
  await fs.writeFile(tmp, data, options);
@@ -59,6 +59,10 @@ export function mdEntryName(entry) {
59
59
  * that `dir` is a real folder before the call, because the sweep reads and
60
60
  * removes inside whatever `dir` names.
61
61
  *
62
+ * @param removal - How an admitted orphan is removed ({@link SweepRemoval}). Defaults to
63
+ * `recursive`. With `unlink`, an entry that has become a folder since the check stays and is
64
+ * recorded in `failed`; an entry that has vanished since the check counts as removed.
65
+ *
62
66
  * @returns A {@link SweepResult} reporting how many entries were scanned, which
63
67
  * registry names were removed, and which removals failed.
64
68
  * `scanned` is the count matched by the predicate (not the removed count) —
@@ -70,7 +74,7 @@ export function mdEntryName(entry) {
70
74
  * individual removal is recorded in `failed` without aborting the sweep.
71
75
  * Never writes, only removes — no delete-then-write window.
72
76
  */
73
- export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName, mayRemove) {
77
+ export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName, mayRemove, removal = 'recursive') {
74
78
  const removed = [];
75
79
  const failed = [];
76
80
  let scanned = 0;
@@ -86,7 +90,7 @@ export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName,
86
90
  try {
87
91
  if (mayRemove !== undefined && !(await mayRemove(entryPath)))
88
92
  continue;
89
- await fs.rm(entryPath, { recursive: true, force: true });
93
+ await removeEntry(entryPath, removal);
90
94
  removed.push(registryName);
91
95
  }
92
96
  catch (err) {
@@ -98,4 +102,18 @@ export async function sweepOrphanedAssets(dir, knownNames, extractRegistryName,
98
102
  catch { /* directory absent or unreadable — not an error */ }
99
103
  return { scanned, removed, failed };
100
104
  }
105
+ /** Remove one orphan as `removal` says. An absent entry is already removed, as `fs.rm` with `force` treats it. */
106
+ async function removeEntry(entryPath, removal) {
107
+ if (removal === 'recursive') {
108
+ await fs.rm(entryPath, { recursive: true, force: true });
109
+ return;
110
+ }
111
+ try {
112
+ await fs.unlink(entryPath);
113
+ }
114
+ catch (err) {
115
+ if (err.code !== 'ENOENT')
116
+ throw err;
117
+ }
118
+ }
101
119
  //# sourceMappingURL=orphan-sweep.js.map
@@ -0,0 +1,15 @@
1
+ /** Build a {@link PathSet} from file paths: the folder prefixes are computed once. */
2
+ export function createPathSet(files) {
3
+ const all = new Set(files);
4
+ const folders = new Set();
5
+ for (const file of all) {
6
+ for (let cut = file.indexOf('/'); cut !== -1; cut = file.indexOf('/', cut + 1)) {
7
+ folders.add(file.slice(0, cut));
8
+ }
9
+ }
10
+ return Object.freeze({
11
+ files: all,
12
+ holds: (folder) => all.has(folder) || folders.has(folder),
13
+ });
14
+ }
15
+ //# sourceMappingURL=path-set.js.map
@@ -640,11 +640,12 @@ export function parsePluginSelection(input, validPlugins) {
640
640
  /**
641
641
  * Deprecated command names from old installations.
642
642
  * Used during init to clean up stale command files on upgrade.
643
+ *
644
+ * Each name is a command file that a release installed. The cleanup removes such a file with no
645
+ * backup, so a name that no release shipped does not belong here.
643
646
  */
644
647
  export const LEGACY_COMMAND_NAMES = [
645
- 'review',
646
648
  'specify',
647
- 'specify-teams',
648
649
  ];
649
650
  /** A command entry of a plugin without its leading `/`. */
650
651
  function commandName(cmd) {