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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to Devflow will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.11.0] - 2026-10-31
9
+
10
+ ### Upgrade notes — Files that devflow moves to a backup folder
11
+
12
+ **What you must do:**
13
+ - Nothing. If `devflow init` moves a file of yours, the output names the file and its new path.
14
+ - To restore a moved file, copy it from the backup folder, `~/.devflow/install-backups/<UTC stamp>-<8 hex>/`, to one of its stable homes. Skills: `~/.devflow/skills/<name>/`. Rules: `~/.devflow/rules/`. Commands and agents: the top-level `commands/` and `agents/` folders of the Claude directory, outside the `devflow/` subfolders. A file that you put back into a devflow folder moves again on the next `devflow init`.
15
+
16
+ **What to know:**
17
+ - The backup is not encrypted. Devflow never prunes it. It stays until you delete it or run a full uninstall.
18
+ - A file of yours with the same name as a shipped file is overwritten without a report.
19
+ - Relative links in a moved folder can break.
20
+ - Concurrent devflow runs are not supported.
21
+
22
+ ### Behaviour change
23
+
24
+ - **Devflow now names the retired files that it removes, and it moves bare legacy skill folders instead of deleting them** ([#688](https://github.com/dean0x/devflow/issues/688)).
25
+ - `devflow init` and the toggles print each retired devflow file that they remove. Before, most of these removals printed nothing.
26
+ - A bare legacy skill folder, such as `skills/python` (a name from releases before v2), is now moved whole to the backup folder and named, but only when its `SKILL.md` is a copy that a release of devflow installed. Before, `devflow init` deleted every such folder. A skill of your own with such a name stays where it is. A folder that devflow cannot judge (no `SKILL.md`, or a link) also stays, and the output names it.
27
+ - A full `devflow uninstall` now removes a bare legacy skill folder, such as `skills/python`, only when its `SKILL.md` is a copy that a release of devflow installed. Before, it deleted every folder with such a name, without a backup, so a skill of your own with that name was lost. A skill of your own stays where it is. A folder that devflow cannot judge (no `SKILL.md`, or a link) also stays. The output names each folder that stays, and `--dry-run` lists the same folders. A `devflow:`-prefixed skill folder is removed as before.
28
+ - An interactive `devflow uninstall` where the install backups are the only user content in `~/.devflow` now asks before the full wipe. The prompt lists the backup folder, and a decline keeps the backups.
29
+
30
+ ### Fixed
31
+
32
+ - **`devflow init` and the curation, compliance and rules toggles no longer delete files that devflow did not ship** ([#688](https://github.com/dean0x/devflow/issues/688)).
33
+ - A file that you keep in `commands/devflow/`, `agents/devflow/`, `rules/devflow/`, a `skills/devflow:*` folder or its `references/` folders is now moved to `~/.devflow/install-backups/<UTC stamp>-<8 hex>/`, and the output names it. Before, these commands deleted it. This holds for a full install, for a partial install (`--plugin`) and for the three toggles.
34
+ - A file that cannot move stays in place with its folder, and the output names it.
35
+
36
+ ---
37
+
8
38
  ## [3.10.0] - 2026-10-30
9
39
 
10
40
  ### Upgrade notes — Shadowed rules and skills
@@ -2338,6 +2368,7 @@ devflow init
2338
2368
  ---
2339
2369
 
2340
2370
  [Unreleased]: https://github.com/dean0x/devflow/compare/v3.8.0...HEAD
2371
+ [3.11.0]: https://github.com/dean0x/devflow/compare/v3.10.0...v3.11.0
2341
2372
  [3.10.0]: https://github.com/dean0x/devflow/compare/v3.9.0...v3.10.0
2342
2373
  [3.9.0]: https://github.com/dean0x/devflow/compare/v3.8.0...v3.9.0
2343
2374
  [3.8.0]: https://github.com/dean0x/devflow/compare/v3.7.0...v3.8.0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The backup session of one command run: made once, before the command changes anything, and its
3
+ * record printed once when the command ends.
4
+ *
5
+ * `devflow init`, `devflow curation`, `devflow compliance` and `devflow rules` each make one
6
+ * session with {@link openBackupSession} and wrap the steps that may move or remove files in
7
+ * {@link runWithBackupReport}. The wrapper prints the record after the steps return or throw, so a
8
+ * run that moved a file and then failed still names where the file went.
9
+ */
10
+ import { randomBytes } from 'crypto';
11
+ import * as p from '@clack/prompts';
12
+ import { WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
13
+ import { getPackageRoot } from '../../core/paths.js';
14
+ import { retiredPathSet } from '../../core/retired-shipped-names.js';
15
+ import { createBackupSession } from '../../targets/claude-code/install-backup.js';
16
+ import { loadShippedSet } from '../../targets/claude-code/shipped-set.js';
17
+ import { formatBackupSummary } from './install-report.js';
18
+ /**
19
+ * Log each summary line at the severity it carries — the one dispatch every
20
+ * `SummaryLine[]` renderer shares.
21
+ *
22
+ * Exhaustive over `SummaryLine['level']` rather than an `if/else`: a level added
23
+ * to the interface has to be routed here, at compile time, instead of silently
24
+ * degrading to `info` at every call site.
25
+ */
26
+ export function logSummaryLines(lines) {
27
+ for (const line of lines) {
28
+ switch (line.level) {
29
+ case 'success':
30
+ p.log.success(line.message);
31
+ break;
32
+ case 'info':
33
+ p.log.info(line.message);
34
+ break;
35
+ case 'warn':
36
+ p.log.warn(line.message);
37
+ break;
38
+ default: {
39
+ const _exhaustive = line.level;
40
+ void _exhaustive;
41
+ break;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ /**
47
+ * Make the backup session of a command run, before the command changes anything.
48
+ *
49
+ * The shipped set comes from the full registries and the package source, never from what an
50
+ * earlier install left on disk. When it cannot be built the answer is a message and no session:
51
+ * a session with a smaller set would move devflow's own files aside. The caller prints the
52
+ * message, exits non-zero and changes nothing.
53
+ */
54
+ export async function openBackupSession(options) {
55
+ const loaded = await loadShippedSet({
56
+ devflowDir: options.devflowDir,
57
+ packageRoot: options.packageRoot ?? getPackageRoot(),
58
+ workflowFolders: WORKFLOW_FOLDERS,
59
+ });
60
+ if (!loaded.ok) {
61
+ return {
62
+ ok: false,
63
+ message: `Could not list the files devflow ships, so nothing was changed: ${loaded.reason}`,
64
+ };
65
+ }
66
+ const session = createBackupSession({
67
+ shipped: loaded.shipped,
68
+ retired: retiredPathSet(),
69
+ claudeDir: options.claudeDir,
70
+ devflowDir: options.devflowDir,
71
+ now: () => new Date(),
72
+ bytes: size => randomBytes(size),
73
+ });
74
+ return { ok: true, session };
75
+ }
76
+ /**
77
+ * The session of a command that reports its failures through the exit code: the session, or null
78
+ * after one error line and exit code 1. The caller then returns without a change.
79
+ */
80
+ export async function openBackupSessionOrFail(options) {
81
+ const opened = await openBackupSession(options);
82
+ if (opened.ok)
83
+ return opened.session;
84
+ p.log.error(opened.message);
85
+ process.exitCode = 1;
86
+ return null;
87
+ }
88
+ /**
89
+ * Run `fn`, which makes its session part-way through (it calls `begin`), and print the record of
90
+ * that session once: at its own `print` call, or else after `fn` returns or throws and before the
91
+ * caller returns or rethrows. A step that ends the process (`process.exit`) calls `print` first,
92
+ * since nothing after an exit runs. A throw from `fn` passes on unchanged, and a run that never
93
+ * calls `begin` prints nothing.
94
+ *
95
+ * @param emit - Where the lines go. Defaults to the clack log.
96
+ */
97
+ export async function runWithLateBackupReport(fn, emit = logSummaryLines) {
98
+ let session = null;
99
+ let printed = false;
100
+ const report = {
101
+ begin: (opened) => { session = opened; },
102
+ print: () => {
103
+ if (printed || session === null)
104
+ return;
105
+ printed = true;
106
+ emit(formatBackupSummary(session.snapshot()));
107
+ },
108
+ };
109
+ try {
110
+ return await fn(report);
111
+ }
112
+ finally {
113
+ report.print();
114
+ }
115
+ }
116
+ /**
117
+ * Run `fn` and print the record of `session` once, after `fn` returns or throws and before the
118
+ * caller returns, rethrows or exits. A throw from `fn` passes on unchanged.
119
+ *
120
+ * `fn` receives `printBackups`, which prints the record at once and is a no-op afterwards: a step
121
+ * that ends the process (`process.exit`) calls it first, since nothing after an exit runs.
122
+ *
123
+ * @param emit - Where the lines go. Defaults to the clack log.
124
+ */
125
+ export function runWithBackupReport(session, fn, emit = logSummaryLines) {
126
+ return runWithLateBackupReport(async (report) => {
127
+ report.begin(session);
128
+ return fn(report.print);
129
+ }, emit);
130
+ }
131
+ //# sourceMappingURL=backup-session.js.map
@@ -21,6 +21,7 @@ import * as path from 'path';
21
21
  import * as p from '@clack/prompts';
22
22
  import color from 'picocolors';
23
23
  import { COMPLIANCE_FRAMEWORKS, normalizeFrameworks, parseFrameworkList, } from '../../core/compliance.js';
24
+ import { openBackupSessionOrFail, runWithBackupReport } from './backup-session.js';
24
25
  import { frameworkChoices, FRAMEWORK_SELECT_MESSAGE } from './compliance-prompts.js';
25
26
  import { COMPLIANCE_SKILL_TOKENS } from '../../core/compliance-compose.js';
26
27
  import { evidencePolicyStatusLine, evidencePolicySuggestion, formatEvidencePolicyUnavailable, loadEvidencePolicyModule, loadSettingsModule, repoComplianceStatusLines, } from '../../core/evidence-policy.js';
@@ -213,10 +214,15 @@ async function recordComplianceSwitch(devflowDir, nextState) {
213
214
  * A reference that the converge could not refresh, a linked folder for example, is an error that
214
215
  * names it, with exit 1, and the switch stays recorded as on.
215
216
  *
216
- * Returns a result and never throws; the caller prints the messages and sets the exit code.
217
+ * Returns a result and never throws; the caller prints the messages and sets the exit code. The
218
+ * record of the backup session prints once, as the change returns (an exit-1 result included),
219
+ * ahead of the messages.
217
220
  */
218
- export async function applyComplianceChange(opts) {
219
- const { claudeDir, devflowDir, manifest, action, setFrameworks, runningVersion, packageRoot, warn } = opts;
221
+ export function applyComplianceChange(opts) {
222
+ return runWithBackupReport(opts.backups, () => applyChange(opts));
223
+ }
224
+ async function applyChange(opts) {
225
+ const { claudeDir, devflowDir, manifest, action, setFrameworks, runningVersion, packageRoot, warn, backups } = opts;
220
226
  const resolved = resolveComplianceCliAction(manifest.features.compliance, action, setFrameworks);
221
227
  // normalizeFrameworks deduplicates (e.g. --set gdpr,GDPR → [gdpr]).
222
228
  const nextState = {
@@ -240,6 +246,7 @@ export async function applyComplianceChange(opts) {
240
246
  runningVersion,
241
247
  warn,
242
248
  packageRoot,
249
+ backups,
243
250
  });
244
251
  if (outcome.kind === 'skipped') {
245
252
  // The compliance skill and rule do not depend on the installed version, so they converge
@@ -249,6 +256,7 @@ export async function applyComplianceChange(opts) {
249
256
  devflowDir,
250
257
  manifest: { features: { compliance: nextState, rules: manifest.features.rules } },
251
258
  warn,
259
+ backups,
252
260
  });
253
261
  if (!nextState.enabled) {
254
262
  return { nextState, messages: [{ level: 'error', text: outcome.message }], exitCode: 1 };
@@ -422,6 +430,10 @@ export const complianceCommand = new Command('compliance')
422
430
  else {
423
431
  action = 'disable';
424
432
  }
433
+ // The session comes before the first change. Without a shipped set the command changes nothing.
434
+ const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
435
+ if (backups === null)
436
+ return;
425
437
  // Write the switch, converge both axes, record the installed variants (applyComplianceChange).
426
438
  const applied = await applyComplianceChange({
427
439
  claudeDir,
@@ -431,6 +443,7 @@ export const complianceCommand = new Command('compliance')
431
443
  setFrameworks,
432
444
  runningVersion: await readRunningVersion(),
433
445
  warn: (msg) => p.log.warn(msg),
446
+ backups,
434
447
  });
435
448
  // Emit messages
436
449
  for (const msg of applied.messages) {
@@ -9,6 +9,7 @@ import { loadShippedAgentDefaults } from '../../core/agent-models.js';
9
9
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
10
10
  import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
11
11
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
12
+ import { openBackupSessionOrFail, runWithBackupReport } from './backup-session.js';
12
13
  import { applyVariantToggle, describeCurationConverge, describePostingLeft, installedScriptsSkew, readRunningVersion, } from '../../targets/claude-code/curation-install.js';
13
14
  import { getLedgerRoot } from '../../core/ledger-root.js';
14
15
  import { AREA_PAUSE_NOTICE } from '../../core/area-pause-notice.js';
@@ -657,16 +658,19 @@ async function handleClear() {
657
658
  * this CLI writes. The toggle moves the ledger folder only then: under version skew
658
659
  * (or when that could not be told) it moves nothing.
659
660
  */
660
- async function convergeInstalledVariants(enabled) {
661
+ export async function convergeInstalledVariants(enabled, backups) {
661
662
  try {
662
- const outcome = await applyVariantToggle({
663
+ // The record of the session prints when the toggle returns or throws, ahead of the warning
664
+ // below, so a file moved aside before a failure is still named.
665
+ const outcome = await runWithBackupReport(backups, async () => applyVariantToggle({
663
666
  claudeDir: getClaudeDirectory(),
664
667
  devflowDir: getDevFlowDirectory(),
665
668
  axis: 'curation',
666
669
  enabled,
667
670
  runningVersion: await readRunningVersion(),
668
671
  warn: (msg) => p.log.warn(msg),
669
- });
672
+ backups,
673
+ }));
670
674
  if (outcome.kind === 'skipped') {
671
675
  p.log.warn(outcome.message);
672
676
  return 'not-converged';
@@ -721,13 +725,17 @@ async function tryMoveForToggle(ledgerRoot) {
721
725
  * tries the shared move with no wait. Only `--disable` drains, skew included.
722
726
  */
723
727
  async function handleToggle(enabled) {
728
+ // The session comes before the first change. Without a shipped set the toggle changes nothing.
729
+ const backups = await openBackupSessionOrFail({ claudeDir: getClaudeDirectory(), devflowDir: getDevFlowDirectory() });
730
+ if (backups === null)
731
+ return;
724
732
  const recorded = await writeMachineFeature(getDevFlowDirectory(), 'curation', enabled);
725
733
  if (!recorded.ok) {
726
734
  p.log.error(`Devflow is not installed on this machine — run ${color.cyan('devflow init')} first`);
727
735
  process.exitCode = 1;
728
736
  return;
729
737
  }
730
- const converged = await convergeInstalledVariants(enabled);
738
+ const converged = await convergeInstalledVariants(enabled, backups);
731
739
  const ledgerRoot = await getLedgerRoot();
732
740
  const shownSkip = converged === 'converged' && ledgerRoot ? await tryMoveForToggle(ledgerRoot) : null;
733
741
  if (enabled) {
@@ -15,9 +15,11 @@ import { describeMoveText, describeOutcome, runInitCurationMove, shownSkipAt } f
15
15
  import { realGlobalTuningMoveIO, realProjectLedgerMoveIO } from './curation-move-io.js';
16
16
  import { describeEchoedValue } from '../../core/echo-value.js';
17
17
  import { installViaFileCopy, installScripts } from '../../targets/claude-code/installer.js';
18
- import { formatLinksSummary, formatOverlaySummary, formatSkillScopeSummary, formatTrackerAssetSummary, isPluginListUnchanged } from './install-report.js';
18
+ import { logSummaryLines, openBackupSession, runWithLateBackupReport } from './backup-session.js';
19
+ import { formatKeptLegacySkills, formatLinksSummary, formatOverlaySummary, formatSkillScopeSummary, formatTrackerAssetSummary, isPluginListUnchanged } from './install-report.js';
19
20
  import { mergeLinkReports, newLinkReport } from '../../targets/claude-code/linked-folder.js';
20
- import { sweepLegacyDevflowFiles } from './legacy-sweep.js';
21
+ import { sweepLegacyDevflowFiles, sweepLegacySkillFolders } from './legacy-sweep.js';
22
+ import { removeRulesFolder, rulesFolderRefusedMessage } from '../../targets/claude-code/rules-folder.js';
21
23
  import { convergeTrackerArtifacts } from '../../targets/claude-code/tracker-install.js';
22
24
  import { installSettings, installManagedSettings, installClaudeignore, hasClaudeignore, discoverProjectGitRoots, ensureDevflowGitignore, applyUserSecurityDenyList, detectDenyState, resolveSecurityAction, assertHistoricalDenySuperset, loadTemplateDenyEntries, stripUserSecurityDenyList, } from '../../targets/claude-code/post-install.js';
23
25
  import { DEVFLOW_PLUGINS, registeredPluginNames, buildAssetMaps, buildScopedSkillsMap, buildRulesMap, partitionSelectablePlugins, WORKFLOW_ORDER, parsePluginSelection, resolveFeatureRedirect } from '../../core/plugins.js';
@@ -119,34 +121,6 @@ export function formatSweepSummary(report) {
119
121
  }
120
122
  return lines;
121
123
  }
122
- /**
123
- * Log each summary line at the severity it carries — the one dispatch every
124
- * `SummaryLine[]` renderer shares.
125
- *
126
- * Exhaustive over `SummaryLine['level']` rather than an `if/else`: a level added
127
- * to the interface has to be routed here, at compile time, instead of silently
128
- * degrading to `info` at every call site.
129
- */
130
- function logSummaryLines(lines) {
131
- for (const line of lines) {
132
- switch (line.level) {
133
- case 'success':
134
- p.log.success(line.message);
135
- break;
136
- case 'info':
137
- p.log.info(line.message);
138
- break;
139
- case 'warn':
140
- p.log.warn(line.message);
141
- break;
142
- default: {
143
- const _exhaustive = line.level;
144
- void _exhaustive;
145
- break;
146
- }
147
- }
148
- }
149
- }
150
124
  /**
151
125
  * Converge the installed agents onto the saved model and effort assignments, after the file copy
152
126
  * wrote the shipped defaults. Per-item failures are non-fatal.
@@ -766,7 +740,7 @@ export const initCommand = new Command('init')
766
740
  .option('--recommended', 'Apply recommended defaults after plugin selection (skip advanced prompts)')
767
741
  .option('--advanced', 'Show all configuration prompts')
768
742
  .option('--reset', 'Factory reset — restore all defaults, ignoring prior installation state')
769
- .action(async (options) => {
743
+ .action((options) => runWithLateBackupReport(async (backupReport) => {
770
744
  // Get package version
771
745
  const packageJsonPath = path.join(getPackageRoot(), 'package.json');
772
746
  let version = '';
@@ -1822,6 +1796,17 @@ export const initCommand = new Command('init')
1822
1796
  const { agentsMap } = buildAssetMaps(pluginsToInstall);
1823
1797
  // Rules: install only from selected plugins (plugin-scoped, not universal)
1824
1798
  const rulesMap = rulesEnabled ? buildRulesMap(pluginsToInstall) : new Map();
1799
+ // The one backup session of this run, made before any change. The install and the converge
1800
+ // after it share it, so the run makes at most one backup folder. When the shipped set cannot
1801
+ // be built, init changes nothing.
1802
+ const openedBackups = await openBackupSession({ claudeDir, devflowDir });
1803
+ if (!openedBackups.ok) {
1804
+ s.stop('Installation failed');
1805
+ p.log.error(openedBackups.message);
1806
+ process.exit(1);
1807
+ }
1808
+ const backups = openedBackups.session;
1809
+ backupReport.begin(backups);
1825
1810
  // D32/D35: Apply one-time migrations (global + per-project) tracked at ~/.devflow/migrations.json.
1826
1811
  // Migrations clean up ~/.devflow runtime data and never touch the installer's copy
1827
1812
  // targets, so their position relative to installViaFileCopy carries no dependency.
@@ -1878,11 +1863,14 @@ export const initCommand = new Command('init')
1878
1863
  // generated reference tree, mode-normalisation failures) reach the user rather
1879
1864
  // than the void. Collected now, emitted after the spinner stops.
1880
1865
  warn: (msg) => { installWarnings.push(msg); },
1866
+ backups,
1881
1867
  });
1882
1868
  }
1883
1869
  catch (error) {
1884
1870
  s.stop('Installation failed');
1885
1871
  p.log.error(`${error}`);
1872
+ // A file the install moved aside before it failed is named now: nothing runs after the exit.
1873
+ backupReport.print();
1886
1874
  process.exit(1);
1887
1875
  }
1888
1876
  // The one converge for both switches (D-LEARNING-VARIANT-INSTALL, AC-27): the file copy
@@ -1906,6 +1894,7 @@ export const initCommand = new Command('init')
1906
1894
  },
1907
1895
  plugins: effectivePlugins,
1908
1896
  warn: (msg) => installWarnings.push(msg),
1897
+ backups,
1909
1898
  });
1910
1899
  if (verbose && variantConverge.converged) {
1911
1900
  p.log.info(`Curation ${curationEnabled ? 'on' : 'off'}, compliance ${complianceEnabled ? 'on' : 'off'} variants: ` +
@@ -1943,29 +1932,22 @@ export const initCommand = new Command('init')
1943
1932
  if (behaviorEnabled && !rulesEnabled) {
1944
1933
  p.log.info('Behavior rule is on but rules are disabled, so it is not installed — run `devflow rules --enable`.');
1945
1934
  }
1946
- // Clean up stale skills from previous installations
1935
+ // Stale skill folders from previous installations move whole to the backup folder and are named,
1936
+ // but a bare folder moves only when its SKILL.md is a copy that a release of devflow installed.
1937
+ // A bare folder that the check cannot judge stays where it is, and is named here.
1947
1938
  s.message('Cleaning up');
1948
- const skillsDir = path.join(claudeDir, 'skills');
1949
- const skillRemoveResults = await Promise.allSettled(LEGACY_SKILL_NAMES.map(legacy => fs.rm(path.join(skillsDir, legacy), { recursive: true })));
1950
- const staleRemoved = skillRemoveResults.filter(r => r.status === 'fulfilled').length;
1951
- if (staleRemoved > 0 && verbose) {
1952
- p.log.info(`Cleaned up ${staleRemoved} legacy skill(s)`);
1953
- }
1939
+ const legacySkills = await sweepLegacySkillFolders({ claudeDir, backups, legacySkills: LEGACY_SKILL_NAMES });
1940
+ logSummaryLines(formatKeptLegacySkills(legacySkills.kept));
1954
1941
  // Clean up stale commands (e.g., /review → /code-review) and rules from previous installations.
1955
1942
  // A folder the install refused, or that is a link, is not swept: nothing is read or removed through it.
1956
- const { staleCommandsRemoved, staleRulesRemoved } = await sweepLegacyDevflowFiles({ claudeDir, report: installReport });
1957
- if (staleCommandsRemoved > 0 && verbose) {
1958
- p.log.info(`Cleaned up ${staleCommandsRemoved} legacy command(s)`);
1959
- }
1960
- if (staleRulesRemoved > 0 && verbose) {
1961
- p.log.info(`Cleaned up ${staleRulesRemoved} legacy rule(s)`);
1962
- }
1963
- // Disable rules directory if rules not enabled
1943
+ // Each removed file is named on the retired line of the backup record.
1944
+ await sweepLegacyDevflowFiles({ claudeDir, report: installReport, backups });
1945
+ // Disable rules directory if rules not enabled: the files devflow did not ship move aside first.
1964
1946
  if (!rulesEnabled) {
1965
- try {
1966
- await fs.rm(path.join(claudeDir, 'rules', 'devflow'), { recursive: true, force: true });
1947
+ const rulesFolder = path.join(claudeDir, 'rules', 'devflow');
1948
+ if ((await removeRulesFolder(rulesFolder, backups)) === 'refused') {
1949
+ p.log.warn(rulesFolderRefusedMessage(rulesFolder));
1967
1950
  }
1968
- catch { /* ignore */ }
1969
1951
  }
1970
1952
  // Clean up legacy hook scripts and lib files left by prior installs
1971
1953
  // (paths relative to hooksDir; copyDirectory is additive, so stale files
@@ -2007,12 +1989,11 @@ export const initCommand = new Command('init')
2007
1989
  ];
2008
1990
  const hooksDir = path.join(devflowDir, 'scripts', 'hooks');
2009
1991
  for (const legacy of LEGACY_HOOK_FILES) {
2010
- const legacyPath = path.join(hooksDir, legacy);
2011
- try {
2012
- await fs.rm(legacyPath);
2013
- }
2014
- catch { /* doesn't exist */ }
1992
+ await backups.removeExact(path.join(hooksDir, legacy));
2015
1993
  }
1994
+ // Every step that moves or removes files in devflow's folders has run: name what the session
1995
+ // moved aside, ahead of the summary below. A throw before this point prints it on the way out.
1996
+ backupReport.print();
2016
1997
  // The curation move (#529): after the legacy sweep, before the manifest persist and
2017
1998
  // the disabled-feature drain below, so the drain sees the moved folder. Its lines
2018
1999
  // and its one notice print with the install warnings, after the spinner stops.
@@ -2611,5 +2592,5 @@ export const initCommand = new Command('init')
2611
2592
  p.log.info(`External model routing: ${color.green('enabled')} — takes effect in new Claude Code sessions`);
2612
2593
  }
2613
2594
  p.outro(color.green('Ready! Run any command in Claude Code to get started.'));
2614
- });
2595
+ }));
2615
2596
  //# sourceMappingURL=init.js.map
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import color from 'picocolors';
11
11
  import { overlayUnitLabel } from '../../targets/claude-code/installer.js';
12
+ import { escapeName, formatCappedList } from '../../targets/claude-code/install-backup.js';
12
13
  import { refusedFolderMessage, replacedLinkMessage } from '../../targets/claude-code/linked-folder.js';
13
14
  import { SKILL_REFS_SKILL_NAME } from '../../core/mds-variants.js';
14
15
  import { prefixSkillName } from '../../core/plugins.js';
@@ -231,4 +232,98 @@ export function formatSkillScopeSummary(report, pluginListUnchanged) {
231
232
  }
232
233
  return lines;
233
234
  }
235
+ const BACKUP_SHOWN_UNITS = 5;
236
+ /** One moved unit as `original -> backup`; a folder carries a trailing slash. */
237
+ function movedUnitLabel(unit) {
238
+ const slash = unit.kind === 'directory' ? '/' : '';
239
+ return `${unit.original}${slash} -> ${unit.backup}${slash}`;
240
+ }
241
+ function failureLine(failure) {
242
+ const where = escapeName(failure.path);
243
+ const reason = escapeName(failure.reason);
244
+ switch (failure.action) {
245
+ case 'move':
246
+ return { level: 'warn', message: `Could not move ${where} to the backup folder (${reason}) — it stays where it is` };
247
+ case 'scan':
248
+ return { level: 'warn', message: `Could not scan ${where} (${reason}) — nothing in it was moved or removed` };
249
+ case 'remove':
250
+ return { level: 'warn', message: `Could not remove ${where} (${reason}) — it stays where it is` };
251
+ default: {
252
+ const _exhaustive = failure.action;
253
+ void _exhaustive;
254
+ return { level: 'warn', message: `Could not finish a step on ${where} (${reason})` };
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * The lines for the bare legacy skill folders that init kept because it could not tell if devflow
260
+ * made them (`sweepLegacySkillFolders`): one line per folder for the first five, then one line that
261
+ * counts the rest. A run that kept none gives no lines. Every printed path goes through `escapeName`.
262
+ */
263
+ export function formatKeptLegacySkills(kept) {
264
+ const lines = kept.slice(0, BACKUP_SHOWN_UNITS).map((folder) => ({
265
+ level: 'info',
266
+ message: `Kept ${escapeName(folder)}: devflow could not tell if it made this folder`,
267
+ }));
268
+ if (kept.length > BACKUP_SHOWN_UNITS) {
269
+ lines.push({ level: 'info', message: `Kept ${kept.length - BACKUP_SHOWN_UNITS} more folder(s) for the same reason` });
270
+ }
271
+ return lines;
272
+ }
273
+ /**
274
+ * Turn the backup session's record into summary lines: what moved aside, what stayed because it
275
+ * could not move, and which retired files were removed. An empty snapshot gives no lines, so a
276
+ * clean run prints nothing.
277
+ *
278
+ * A move is not a removal. The moved line has its own wording and counts only moved units (a moved
279
+ * folder is one), and it never feeds the "Removed N orphaned asset(s)" line of `formatSweepSummary`.
280
+ * One removed name prints on one line only, by the kind of removal:
281
+ *
282
+ * - the retired line below lists the retired files the session removed itself, with
283
+ * `BackupSession.removeListed` (the earlier releases' commands, agents, rules and skill files)
284
+ * or `BackupSession.removeExact` (the legacy sweep's command and rule names, and the legacy
285
+ * hook scripts);
286
+ * - `formatSweepSummary` lists the names the registry-diff sweeps removed, which the install
287
+ * report records in `sweptOrphans`. A sweep removes a name only after the session has not,
288
+ * and the session removes no name that a sweep records.
289
+ *
290
+ * Every printed name goes through `escapeName`. Pure function — returns lines, logs nothing.
291
+ */
292
+ export function formatBackupSummary(snapshot) {
293
+ const lines = [];
294
+ if (snapshot.moved.length > 0) {
295
+ lines.push({
296
+ level: 'info',
297
+ message: `Moved ${snapshot.moved.length} item(s) that devflow does not ship out of its folders: ` +
298
+ formatCappedList(snapshot.moved.map(movedUnitLabel), BACKUP_SHOWN_UNITS),
299
+ });
300
+ if (snapshot.runFolder !== null) {
301
+ lines.push({ level: 'info', message: `Backup folder: ${escapeName(snapshot.runFolder)}` });
302
+ }
303
+ }
304
+ if (snapshot.retiredRemoved.length > 0) {
305
+ lines.push({
306
+ level: 'info',
307
+ message: `Removed ${snapshot.retiredRemoved.length} retired devflow file(s): ` +
308
+ formatCappedList(snapshot.retiredRemoved, BACKUP_SHOWN_UNITS),
309
+ });
310
+ }
311
+ for (const failure of snapshot.failures)
312
+ lines.push(failureLine(failure));
313
+ for (const hit of snapshot.capHits) {
314
+ lines.push({
315
+ level: 'warn',
316
+ message: `${escapeName(hit.folder)} holds ${hit.entries} entries, more than the limit of ${hit.cap} — ` +
317
+ 'devflow left it whole and moved nothing out of it',
318
+ });
319
+ }
320
+ if (snapshot.rootRefusal !== null) {
321
+ lines.push({
322
+ level: 'warn',
323
+ message: `Could not use the backup folder ${escapeName(snapshot.rootRefusal.path)} ` +
324
+ `(${escapeName(snapshot.rootRefusal.reason)}) — nothing was moved aside in this run`,
325
+ });
326
+ }
327
+ return lines;
328
+ }
234
329
  //# sourceMappingURL=install-report.js.map