devflow-kit 2.5.0 → 3.0.1

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.
Files changed (158) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +44 -19
  3. package/dist/agents/git.md +13 -15
  4. package/dist/cli/commands/ambient.js +160 -145
  5. package/dist/cli/commands/capture.js +29 -55
  6. package/dist/cli/commands/compliance.js +32 -61
  7. package/dist/cli/commands/context.js +17 -32
  8. package/dist/cli/commands/debug.js +65 -26
  9. package/dist/cli/commands/flags.js +3 -3
  10. package/dist/cli/commands/hud.js +34 -10
  11. package/dist/cli/commands/init-seed.js +40 -4
  12. package/dist/cli/commands/init.js +249 -271
  13. package/dist/cli/commands/install-report.js +10 -15
  14. package/dist/cli/commands/knowledge/index.js +1 -1
  15. package/dist/cli/commands/knowledge/toggle.js +11 -3
  16. package/dist/cli/commands/learning.js +52 -37
  17. package/dist/cli/commands/legacy-hooks.js +11 -14
  18. package/dist/cli/commands/memory.js +67 -78
  19. package/dist/cli/commands/proxy.js +23 -41
  20. package/dist/cli/commands/security.js +5 -13
  21. package/dist/cli/commands/skills.js +21 -3
  22. package/dist/cli/commands/tracker.js +100 -228
  23. package/dist/cli/commands/uninstall.js +343 -138
  24. package/dist/commands/bug-analysis.md +38 -12
  25. package/dist/commands/code-review.md +70 -21
  26. package/dist/commands/debug.md +37 -7
  27. package/dist/commands/dynamic-build.md +66 -17
  28. package/dist/commands/dynamic-plan.md +19 -8
  29. package/dist/commands/dynamic-profile.md +24 -10
  30. package/dist/commands/dynamic-tickets.md +22 -11
  31. package/dist/commands/explore.md +37 -7
  32. package/dist/commands/implement.md +96 -32
  33. package/dist/commands/plan.md +62 -19
  34. package/dist/commands/release.md +2 -2
  35. package/dist/commands/research.md +34 -8
  36. package/dist/commands/resolve.md +65 -17
  37. package/dist/commands/self-review.md +45 -9
  38. package/dist/core/compliance-compose.js +27 -27
  39. package/dist/core/evidence-policy.js +240 -24
  40. package/dist/core/feature-config.js +94 -25
  41. package/dist/core/feature-switch.js +1 -1
  42. package/dist/core/flags.js +30 -2
  43. package/dist/core/fs-atomic.js +27 -0
  44. package/dist/core/hook-log-dirs.js +104 -0
  45. package/dist/core/learning-tuning-config.js +5 -3
  46. package/dist/core/ledger-root.js +102 -0
  47. package/dist/core/manifest.js +6 -4
  48. package/dist/core/mds-variants.js +34 -97
  49. package/dist/core/migrations.js +49 -23
  50. package/dist/core/plugins.js +5 -4
  51. package/dist/core/project-paths.js +0 -17
  52. package/dist/core/same-location.js +25 -0
  53. package/dist/core/tracker.js +226 -139
  54. package/dist/hud/components/config-counts.js +15 -4
  55. package/dist/hud/components/learning-counts.js +14 -0
  56. package/dist/hud/config.js +2 -1
  57. package/dist/hud/cost-history.js +2 -4
  58. package/dist/hud/git.js +52 -7
  59. package/dist/hud/index.js +7 -9
  60. package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
  61. package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
  62. package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
  63. package/dist/skills/git/references/tracker/_mcp.md +1 -1
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
  65. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
  66. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
  67. package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
  68. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
  70. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
  71. package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
  74. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
  76. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
  77. package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
  80. package/dist/targets/claude-code/claude-paths.js +59 -57
  81. package/dist/targets/claude-code/compliance-install.js +49 -65
  82. package/dist/targets/claude-code/hooks.js +108 -3
  83. package/dist/targets/claude-code/installer.js +30 -57
  84. package/dist/targets/claude-code/post-install.js +246 -139
  85. package/dist/targets/claude-code/tracker-install.js +38 -65
  86. package/package.json +5 -4
  87. package/src/assets/agents/code.md +4 -3
  88. package/src/assets/agents/design.md +1 -0
  89. package/src/assets/agents/git.mds +55 -57
  90. package/src/assets/agents/knowledge.md +2 -2
  91. package/src/assets/agents/review.md +3 -1
  92. package/src/assets/agents/tracker.md +37 -30
  93. package/src/assets/commands/_partials/_compliance.mds +19 -1
  94. package/src/assets/commands/_partials/_decisions.mds +15 -3
  95. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  96. package/src/assets/commands/_partials/_engine.mds +2 -2
  97. package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
  98. package/src/assets/commands/_partials/_factory.mds +1 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  100. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  101. package/src/assets/commands/_partials/_preamble.mds +1 -1
  102. package/src/assets/commands/_partials/_publication.mds +6 -2
  103. package/src/assets/commands/_partials/_settings.mds +28 -0
  104. package/src/assets/commands/_partials/_ticket_template.mds +3 -3
  105. package/src/assets/commands/_partials/_tracker.mds +4 -4
  106. package/src/assets/commands/_partials/_wave.mds +4 -4
  107. package/src/assets/commands/bug-analysis.mds +19 -17
  108. package/src/assets/commands/code-review.mds +39 -33
  109. package/src/assets/commands/debug.mds +4 -5
  110. package/src/assets/commands/dynamic-build.mds +75 -53
  111. package/src/assets/commands/dynamic-plan.mds +20 -15
  112. package/src/assets/commands/dynamic-profile.mds +24 -11
  113. package/src/assets/commands/dynamic-tickets.mds +25 -20
  114. package/src/assets/commands/explore.mds +4 -5
  115. package/src/assets/commands/implement.mds +58 -45
  116. package/src/assets/commands/plan.mds +34 -29
  117. package/src/assets/commands/release.md +2 -2
  118. package/src/assets/commands/research.mds +11 -9
  119. package/src/assets/commands/resolve.mds +41 -39
  120. package/src/assets/commands/self-review.mds +24 -25
  121. package/src/assets/mds/git/_pr.mds +61 -61
  122. package/src/assets/mds/git/_references.mds +19 -19
  123. package/src/assets/mds/tracker/_common.mds +8 -8
  124. package/src/assets/mds/tracker/_github.mds +71 -71
  125. package/src/assets/mds/tracker/_jira.mds +74 -74
  126. package/src/assets/mds/tracker/_linear.mds +75 -75
  127. package/src/assets/mds/tracker/_mcp.mds +23 -17
  128. package/src/assets/scripts/hooks/background-memory-update +35 -19
  129. package/src/assets/scripts/hooks/capture-prompt +18 -12
  130. package/src/assets/scripts/hooks/capture-question +18 -12
  131. package/src/assets/scripts/hooks/capture-turn +27 -17
  132. package/src/assets/scripts/hooks/debug-trace +11 -6
  133. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  134. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  135. package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
  136. package/src/assets/scripts/hooks/git-marker +48 -0
  137. package/src/assets/scripts/hooks/json-helper.cjs +6 -1
  138. package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
  139. package/src/assets/scripts/hooks/log-paths +80 -0
  140. package/src/assets/scripts/hooks/memory-worker +17 -15
  141. package/src/assets/scripts/hooks/pre-compact-memory +41 -16
  142. package/src/assets/scripts/hooks/queue-append +104 -30
  143. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  144. package/src/assets/scripts/hooks/session-start-context +289 -122
  145. package/src/assets/scripts/hooks/session-start-memory +35 -16
  146. package/src/assets/scripts/lib/project-config.cjs +633 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
  148. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  149. package/src/assets/scripts/verify-evidence.cjs +1 -1
  150. package/src/assets/skills/compliance/SKILL.md +2 -2
  151. package/src/assets/skills/docs-framework/SKILL.md +6 -7
  152. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  153. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  154. package/src/assets/skills/git/references/github-api.md +9 -9
  155. package/src/assets/skills/git/references/patterns.md +1 -1
  156. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  157. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  158. package/src/targets/claude-code/templates/managed-settings.json +25 -9
@@ -4,11 +4,11 @@ import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import * as p from '@clack/prompts';
6
6
  import color from 'picocolors';
7
- import { getInstallationPaths, getClaudeDirectory, getManagedSettingsPath } from '../../targets/claude-code/claude-paths.js';
7
+ import { getInstallationPaths, getClaudeDirectory, getHomeDirectory, getManagedSettingsPath } from '../../targets/claude-code/claude-paths.js';
8
8
  import { getGitRoot } from '../../core/git.js';
9
+ import { isSameLocation } from '../../core/same-location.js';
9
10
  import { DEVFLOW_PLUGINS, SKILL_NAMESPACE, getAllSkillNames, getAllAgentNames, getAllCommandNames, parsePluginSelection, resolveFeatureRedirect, prefixSkillName, unprefixSkillName, skillsOf, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
10
11
  import { readManifest } from '../../core/manifest.js';
11
- import { TRACKER_ATTEMPTS_FILE, TRACKER_CLAIM_FILE, TRACKER_ENABLED_FILE } from '../../core/tracker.js';
12
12
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
13
13
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
14
14
  import { removeAmbientHook } from './ambient.js';
@@ -20,15 +20,54 @@ import { removeContextHook } from './context.js';
20
20
  import { applyProxyTeardownToSettings } from './proxy.js';
21
21
  import { readProxyState, proxyJsonExists } from '../../core/proxy-state.js';
22
22
  import { hudCacheDir } from '../../core/cache.js';
23
- import { TRACKER_CONVENTIONS_FILE, TRACKER_CONVENTIONS_BACKUP_NAMES, TRACKER_STAGED_PREFIX, } from '../../core/tracker.js';
23
+ import { TRACKER_ATTEMPTS_NAMES, TRACKER_CLAIM_FILE, TRACKER_CONVENTIONS_DIR, TRACKER_ENABLED_FILE, TRACKER_LEGACY_ATTEMPTS_FILE, TRACKER_LEGACY_CONVENTIONS_FILE, TRACKER_PROVIDER_IDS, TRACKER_STAGED_PREFIX, } from '../../core/tracker.js';
24
24
  import { revertExternalAgents } from '../../core/agent-models.js';
25
25
  import { detectShell, getProfilePath } from '../../core/safe-delete.js';
26
26
  import { isAlreadyInstalled, removeFromProfile } from '../../core/safe-delete-install.js';
27
- import { removeManagedSettings, stripUserDenyList, detectDenyState, DEVFLOW_HISTORICAL_DENY } from '../../targets/claude-code/post-install.js';
28
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
27
+ import { removeManagedSettings, stripUserDenyList, detectDenyState, DEVFLOW_HISTORICAL_DENY, DEVFLOW_TRACKED_PATHS } from '../../targets/claude-code/post-install.js';
28
+ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
29
29
  import { stripFlags } from '../../core/flags.js';
30
30
  import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js';
31
31
  import { getPackageRoot, isContainedIn } from '../../core/paths.js';
32
+ /**
33
+ * Where a retired repo-local install lives: `<gitRoot>/.claude` and `<gitRoot>/.devflow`.
34
+ *
35
+ * D-LEGACY-LOCAL-CLEANUP: `init --scope local` no longer exists (D-SCOPE-RETIRED
36
+ * in claude-paths.ts), but repos installed by it still carry its assets, so
37
+ * `uninstall` keeps detecting and removing them. This is the ONLY place a
38
+ * repo-local install path is derived, and it is private to uninstall: nothing
39
+ * installs there any more. A local-only uninstall never reaches the user's
40
+ * settings.json or anything else under HOME — the settings strip edits
41
+ * `<gitRoot>/.claude/settings.json`, the legacy commands-rule purge is skipped,
42
+ * and the machine-wide steps (security deny list, safe-delete) run only when
43
+ * the user scope is being uninstalled too.
44
+ *
45
+ * Returns null when either repo-local directory IS a machine-wide one — a
46
+ * repository rooted at HOME (a dotfiles repo) puts `<gitRoot>/.claude` and
47
+ * `<gitRoot>/.devflow` on `~/.claude` and `~/.devflow`, and a "local" removal
48
+ * there would be a machine-wide uninstall under another name.
49
+ */
50
+ async function legacyLocalInstallPaths(gitRoot) {
51
+ const legacy = {
52
+ claudeDir: path.join(gitRoot, '.claude'),
53
+ devflowDir: path.join(gitRoot, '.devflow'),
54
+ };
55
+ const machine = getInstallationPaths();
56
+ if (await isSameLocation(legacy.claudeDir, machine.claudeDir))
57
+ return null;
58
+ if (await isSameLocation(legacy.devflowDir, machine.devflowDir))
59
+ return null;
60
+ return legacy;
61
+ }
62
+ /**
63
+ * The directories for `scope`, or null for a local scope with no repo-local
64
+ * install to act on: outside a git repository, or in one rooted at HOME.
65
+ */
66
+ async function scopeInstallPaths(scope, gitRoot) {
67
+ if (scope === 'user')
68
+ return getInstallationPaths();
69
+ return gitRoot === null ? null : legacyLocalInstallPaths(gitRoot);
70
+ }
32
71
  /**
33
72
  * The plugins the manifest records as installed, as registry definitions.
34
73
  *
@@ -164,6 +203,103 @@ export function resolveSecurityRemovalDecision(opts) {
164
203
  export function resolveProjectDataCleanup(answer) {
165
204
  return answer === true;
166
205
  }
206
+ /** Tracked entries by name and kind: `features/` is a directory, the rest are files. */
207
+ const TRACKED_ENTRIES = DEVFLOW_TRACKED_PATHS.map((tracked) => ({
208
+ name: tracked.replace(/\/$/, ''),
209
+ isDir: tracked.endsWith('/'),
210
+ }));
211
+ /**
212
+ * Split the entries under a repository's `.devflow/` into what a confirmed cleanup
213
+ * removes and what it keeps (D-UNINSTALL-CARVE-OUT). An entry is kept only when both
214
+ * its name and its kind match a tracked path, so a stray FILE named `features` is
215
+ * not mistaken for the tracked directory. PURE — both lists sorted by name.
216
+ */
217
+ export function partitionProjectData(entries) {
218
+ const isTracked = (entry) => TRACKED_ENTRIES.some((t) => t.name === entry.name && t.isDir === entry.isDir);
219
+ const byName = (a, b) => a.name.localeCompare(b.name);
220
+ return {
221
+ remove: entries.filter((e) => !isTracked(e)).sort(byName),
222
+ keep: entries.filter(isTracked).sort(byName),
223
+ };
224
+ }
225
+ /**
226
+ * Resolve the project-data step's target: `<gitRoot>/.devflow`, partitioned.
227
+ *
228
+ * D-UNINSTALL-CARVE-OUT: the step acts on the repository's `.devflow` at its git
229
+ * root — the directory devflow's hooks write — never on whatever `.devflow` the
230
+ * cwd happens to hold. It is skipped with no git root, and when the git root is
231
+ * HOME or its `.devflow` is the machine-wide devflow directory (a dotfiles repo):
232
+ * there `.devflow` is the install itself, not project data. Both comparisons use
233
+ * realpaths, so macOS's `/var` → `/private/var` and a symlinked HOME still match.
234
+ *
235
+ * A `.devflow` that is a symbolic link to anywhere else is skipped too, and never
236
+ * followed: its target is a directory devflow cannot prove it wrote (applies
237
+ * ADR-024), so a confirmed cleanup must not empty it — nor unlink a link the user made.
238
+ */
239
+ export async function resolveProjectDataPlan(opts) {
240
+ if (opts.gitRoot === null)
241
+ return { kind: 'skip', reason: 'no-git-root' };
242
+ if (await isSameLocation(opts.gitRoot, opts.homeDir))
243
+ return { kind: 'skip', reason: 'home-root' };
244
+ const dir = path.join(opts.gitRoot, '.devflow');
245
+ if (await isSameLocation(dir, opts.machineDevflowDir))
246
+ return { kind: 'skip', reason: 'machine-dir' };
247
+ try {
248
+ if ((await fs.lstat(dir)).isSymbolicLink())
249
+ return { kind: 'skip', reason: 'symlink' };
250
+ }
251
+ catch {
252
+ return { kind: 'skip', reason: 'absent' };
253
+ }
254
+ let dirents;
255
+ try {
256
+ dirents = await fs.readdir(dir, { withFileTypes: true });
257
+ }
258
+ catch {
259
+ return { kind: 'skip', reason: 'absent' };
260
+ }
261
+ const { remove, keep } = partitionProjectData(dirents.map((d) => ({ name: d.name, isDir: d.isDirectory() })));
262
+ return { kind: 'plan', plan: { dir, remove, keep } };
263
+ }
264
+ /**
265
+ * The line that tells the user why the project-data step was skipped, or null
266
+ * when there is nothing worth saying (no repository, no `.devflow`). Shared by
267
+ * the real cleanup and the dry run so both say the same. PURE.
268
+ */
269
+ export function formatProjectDataSkip(reason, gitRoot) {
270
+ if (gitRoot === null)
271
+ return null;
272
+ const dir = path.join(gitRoot, '.devflow');
273
+ switch (reason) {
274
+ case 'home-root':
275
+ case 'machine-dir':
276
+ return `Project data step skipped: ${dir}/ is the machine-wide devflow directory`;
277
+ case 'symlink':
278
+ return `Project data step skipped: ${dir} is a symbolic link — devflow does not follow it; remove it yourself if nothing there is needed`;
279
+ case 'no-git-root':
280
+ case 'absent':
281
+ return null;
282
+ }
283
+ }
284
+ /** An entry as the prompt shows it: directories carry a trailing `/`. */
285
+ function entryLabel(entry) {
286
+ return entry.isDir ? `${entry.name}/` : entry.name;
287
+ }
288
+ /** Comma-separated labels, or `(none)`. */
289
+ function entryList(entries) {
290
+ return entries.length === 0 ? '(none)' : entries.map(entryLabel).join(', ');
291
+ }
292
+ /**
293
+ * The lines shown before the project-data confirm: what is removed and what is
294
+ * kept. PURE.
295
+ */
296
+ export function formatProjectDataPlan(plan) {
297
+ return [
298
+ `Project data in ${plan.dir}/:`,
299
+ ` Remove: ${entryList(plan.remove)}`,
300
+ ` Keep (shared via git): ${entryList(plan.keep)}`,
301
+ ];
302
+ }
167
303
  /**
168
304
  * Determine the appropriate cleanup action for the user-scope devflow directory on
169
305
  * full uninstall. Mirrors the resolveSecurityRemovalDecision pattern.
@@ -175,7 +311,7 @@ export function resolveProjectDataCleanup(answer) {
175
311
  * 1. basename(devflowDir) must be '.devflow'
176
312
  * 2. devflowDir must not equal homeDir
177
313
  * 3. devflowDir must not be the filesystem root '/'
178
- * 4. devflowDir must reside inside $HOME (guards DEVFLOW_DIR env overrides)
314
+ * 4. devflowDir must reside inside $HOME
179
315
  *
180
316
  * Returns:
181
317
  * - 'artifacts-only' — remove only manifest.json; leave the directory intact
@@ -192,14 +328,11 @@ export function resolveProjectDataCleanup(answer) {
192
328
  * --keep-docs from triggering prompts about skill shadows or preference-profile.md.
193
329
  */
194
330
  export function resolveDevflowDirCleanup(opts) {
195
- // Local scope never removes project data — only install artifacts.
196
- if (opts.scope !== 'user')
197
- return 'artifacts-only';
198
331
  // --keep-docs: suppress the full cleanup prompt entirely; artifacts-only.
199
332
  if (opts.keepDocs)
200
333
  return 'artifacts-only';
201
334
  // Precondition guard: devflowDir must be a well-known, safe-to-rm path.
202
- // Any anomalous value (DEVFLOW_DIR override, bare homedir, filesystem root)
335
+ // Any anomalous value (bare homedir, filesystem root, a path outside HOME)
203
336
  // resolves to artifacts-only — never throw in business logic (engineering rule).
204
337
  const isBasenameValid = path.basename(opts.devflowDir) === '.devflow';
205
338
  const isNotHomeDir = opts.devflowDir !== opts.homeDir;
@@ -239,35 +372,48 @@ export function userContentPaths(devflowDir) {
239
372
  { relPath: 'rules', isDir: true, label: `rule shadows (${path.join(devflowDir, 'rules')})` },
240
373
  // preference-profile.md — user-curated decision-preference profile
241
374
  { relPath: 'preference-profile.md', label: 'preference-profile.md' },
242
- // tracker.md — the inferred, hand-editable issue-tracker conventions file.
375
+ // tracker/ — the inferred, hand-editable issue-tracker conventions, one file
376
+ // per provider (D-TRACKER-PER-PROVIDER-CONVENTIONS).
243
377
  //
244
378
  // USER CONTENT (OD-15), classified the same way as preference-profile.md above
245
- // rather than as an install artifact like agent-models.json, because it is
246
- // inferred ONCE per machine and then hand-editable: absence is the trigger that
247
- // re-runs inference, so deleting it on every decline/cancel/--keep-docs path
248
- // would silently discard work the user may have corrected by hand.
379
+ // rather than as an install artifact like agent-models.json, because each file
380
+ // is inferred ONCE per machine and then hand-editable: absence is the trigger
381
+ // that re-runs inference, so deleting one on every decline/cancel/--keep-docs
382
+ // path would silently discard work the user may have corrected by hand.
249
383
  //
250
384
  // REVERSAL CONDITION, recorded: this classification is CONDITIONAL on the
251
385
  // provider-mismatch guard shipping. agent-models.json was reclassified to an
252
386
  // artifact precisely because stale overrides re-apply *silently*; "silently" is
253
- // the load-bearing word. A stale tracker.md whose frontmatter provider
387
+ // the load-bearing word. A conventions file whose frontmatter provider
254
388
  // disagrees with the resolved provider produces
255
389
  // `TRACEABILITY: DEGRADED (tracker configuration mismatch (conventions file))`
256
390
  // and no tracker call — that is what removes the silence, and the reason names
257
- // THIS file rather than the per-repo override so the user is told which of the
258
- // two to edit. If that guard is ever dropped,
259
- // reclassify tracker.md to an install artifact IN THE SAME CHANGE, otherwise a
260
- // silently-authoritative stale file survives uninstall.
261
- { relPath: TRACKER_CONVENTIONS_FILE, label: `${TRACKER_CONVENTIONS_FILE} (issue tracker conventions)` },
262
- // tracker.md.{provider}.bak — what renameStaleTrackerConventions leaves behind
263
- // on a provider change. Same inferred content as tracker.md (the user's site
264
- // and project key), so the same classification: named by the confirm prompt,
265
- // kept by every artifacts-only pass. The names come from the registry, so a
266
- // fourth provider is covered the day it lands.
267
- ...TRACKER_CONVENTIONS_BACKUP_NAMES.map(name => ({
268
- relPath: name,
269
- label: `${name} (previous issue tracker conventions)`,
270
- })),
391
+ // THE FILE rather than the per-repo override so the user is told which of the
392
+ // two to edit. If that guard is ever dropped, reclassify these to install
393
+ // artifacts IN THE SAME CHANGE, otherwise a silently-authoritative stale file
394
+ // survives uninstall.
395
+ {
396
+ relPath: TRACKER_CONVENTIONS_DIR,
397
+ isDir: true,
398
+ label: `issue tracker conventions (${path.join(devflowDir, TRACKER_CONVENTIONS_DIR)})`,
399
+ },
400
+ // tracker.md and tracker.md.{provider}.bak — the conventions earlier releases
401
+ // kept in one machine-wide file, and the copies they moved aside on a provider
402
+ // change. The per-provider migration moves tracker.md when its frontmatter
403
+ // names a provider whose file does not exist yet, and leaves it otherwise;
404
+ // nothing moves or deletes a backup. Both hold what the conventions files
405
+ // hold — the user's site and project key — so the same classification: named
406
+ // by the confirm prompt, kept by every artifacts-only pass. The backup names
407
+ // come from the registry, so every provider an earlier release could have
408
+ // backed up is covered.
409
+ {
410
+ relPath: TRACKER_LEGACY_CONVENTIONS_FILE,
411
+ label: `${TRACKER_LEGACY_CONVENTIONS_FILE} (issue tracker conventions from an earlier release)`,
412
+ },
413
+ ...TRACKER_PROVIDER_IDS.map(id => {
414
+ const name = `${TRACKER_LEGACY_CONVENTIONS_FILE}.${id}.bak`;
415
+ return { relPath: name, label: `${name} (previous issue tracker conventions)` };
416
+ }),
271
417
  // learning.json — global learning agent tuning config
272
418
  { relPath: 'learning.json', label: 'learning.json' },
273
419
  // hud.json — user HUD enable/disable preference and display config
@@ -378,14 +524,16 @@ export function installArtifactPaths(devflowDir) {
378
524
  { relPath: 'proxy-routing.json' },
379
525
  { relPath: 'proxy.pid' },
380
526
  { relPath: '.proxy-spawn.lock', isDir: true },
381
- // tracker runtime artifacts — the Tracker agent's atomic claim file, its
382
- // inference attempt counter, and the provider presence sentinel the
383
- // SessionStart hook stats. All three are machine state with no user-authored
384
- // content, so they go on this list; `tracker.md` beside them and the
385
- // `tracker.md.{provider}.bak` a provider change leaves are USER CONTENT
386
- // (OD-15) and are deliberately NOT here (@D8: the two lists stay disjoint).
527
+ // tracker runtime artifacts — the Tracker agent's atomic claim file, the
528
+ // per-provider inference attempt counters (and the single counter earlier
529
+ // releases kept), and the machine provider sentinel the SessionStart hook
530
+ // reads. All are machine state with no user-authored content, so they go on
531
+ // this list; the conventions under `tracker/` beside them, and the legacy
532
+ // `tracker.md` and its backups, are USER CONTENT (OD-15) and are deliberately
533
+ // NOT here (@D8: the two lists stay disjoint).
387
534
  { relPath: TRACKER_CLAIM_FILE },
388
- { relPath: TRACKER_ATTEMPTS_FILE },
535
+ ...TRACKER_ATTEMPTS_NAMES.map(name => ({ relPath: name })),
536
+ { relPath: TRACKER_LEGACY_ATTEMPTS_FILE },
389
537
  { relPath: TRACKER_ENABLED_FILE },
390
538
  // The agent's scrubbed staging file, one per invocation under a mktemp name
391
539
  // it removes from a trap — a SIGKILL outruns the trap and leaves it behind.
@@ -414,8 +562,8 @@ export function installArtifactPaths(devflowDir) {
414
562
  * This function runs on the decline, cancel, non-interactive AND --keep-docs paths,
415
563
  * so an entry here is deleted even when the user answers "no" to the full wipe.
416
564
  * User-authored state (everything in `userContentPaths`: skill/rule shadows,
417
- * preference-profile.md, tracker.md and its provider backups, learning.json,
418
- * hud.json) is removed only by the confirmed full-dir rm.
565
+ * preference-profile.md, the tracker conventions with the legacy tracker.md and
566
+ * its backups, learning.json, hud.json) is removed only by the confirmed full-dir rm.
419
567
  * agent-models.json is an INSTALL ARTIFACT (stale per-agent overrides silently
420
568
  * re-apply to renamed/deleted agents on reinstall — AC-P1-F4) and therefore
421
569
  * belongs in this list, not in enumerateUserDevFlowContent.
@@ -619,22 +767,31 @@ export async function runDryRunPhase(opts) {
619
767
  // skills sweep. Enumerate what is actually on disk for each detected scope
620
768
  // rather than computing from the registry (which misses legacy/orphaned assets).
621
769
  const extras = [];
770
+ const gitRoot = scopesToUninstall.includes('local') ? await getGitRoot() : null;
622
771
  for (const scope of [...scopesToUninstall]) {
623
- try {
624
- const paths = await getInstallationPaths(scope);
625
- const { claudeDir: cd, devflowDir: dd } = paths;
626
- const moreExtras = await enumerateDryRunExtras(cd, dd);
627
- extras.push(...moreExtras);
772
+ const paths = await scopeInstallPaths(scope, gitRoot);
773
+ if (paths === null)
774
+ continue;
775
+ extras.push(...await enumerateDryRunExtras(paths.claudeDir, paths.devflowDir));
776
+ }
777
+ // Project data under <gitRoot>/.devflow — the same plan the real cleanup phase
778
+ // resolves, so the preview lists exactly what a confirmed removal deletes.
779
+ const projectRoot = await getGitRoot(process.cwd());
780
+ const projectData = await resolveProjectDataPlan({
781
+ gitRoot: projectRoot,
782
+ homeDir: getHomeDirectory(),
783
+ machineDevflowDir: getInstallationPaths().devflowDir,
784
+ });
785
+ if (projectData.kind === 'plan') {
786
+ for (const entry of projectData.plan.remove) {
787
+ extras.push(`${path.join(projectData.plan.dir, entryLabel(entry))} (if confirmed)`);
628
788
  }
629
- catch { /* scope path resolution failed */ }
630
789
  }
631
- // Project .devflow/ data dir
632
- const devflowDataDir = path.join(process.cwd(), '.devflow');
633
- try {
634
- await fs.access(devflowDataDir);
635
- extras.push(`${devflowDataDir} (if confirmed)`);
790
+ else {
791
+ const skipped = formatProjectDataSkip(projectData.reason, projectRoot);
792
+ if (skipped !== null)
793
+ extras.push(skipped);
636
794
  }
637
- catch { /* noop */ }
638
795
  extras.push('hooks removed from settings.json');
639
796
  for (const line of extras) {
640
797
  p.log.info(` ${line}`);
@@ -653,6 +810,7 @@ export async function runDryRunPhase(opts) {
653
810
  */
654
811
  export async function runSelectivePhaseForScope(opts) {
655
812
  const { claudeDir, devflowDir, selectedPlugins, verbose } = opts;
813
+ const scope = opts.scope ?? 'user';
656
814
  const installedPlugins = opts.installedPlugins ?? DEVFLOW_PLUGINS;
657
815
  // Revert GPT agent frontmatter BEFORE removing agent files — strips GPT model
658
816
  // lines from installed agent frontmatter while the files are still present.
@@ -676,9 +834,9 @@ export async function runSelectivePhaseForScope(opts) {
676
834
  const settingsPath = path.join(claudeDir, 'settings.json');
677
835
  try {
678
836
  const settings = await fs.readFile(settingsPath, 'utf-8');
679
- const updated = await removeAmbientHook(settings);
837
+ const updated = await removeAmbientHook(settings, { purgeLegacyRule: scope === 'user' });
680
838
  if (updated !== settings) {
681
- await fs.writeFile(settingsPath, updated, 'utf-8');
839
+ await writeSettingsFileAtomic(settingsPath, updated);
682
840
  if (verbose) {
683
841
  p.log.success('Ambient mode hooks removed from settings.json');
684
842
  }
@@ -696,7 +854,7 @@ export async function runSelectivePhaseForScope(opts) {
696
854
  * User scope: interactive TTY with user-authored content → confirm before wiping
697
855
  * ~/.devflow/; non-interactive or no user content → artifacts-only.
698
856
  *
699
- * @param opts.scope - 'user' or 'local'.
857
+ * @param opts.scope - 'user' or a legacy 'local' install.
700
858
  * @param opts.claudeDir - Target Claude Code directory for this scope.
701
859
  * @param opts.devflowDir - Devflow data directory for this scope.
702
860
  * @param opts.devflowScriptsDir - scripts/ sub-directory removed by removeAllDevFlow.
@@ -741,7 +899,6 @@ export async function runFullPhaseForScope(opts) {
741
899
  // Non-interactive, no user content, or precondition guard failure → artifacts-only.
742
900
  const userContent = await enumerateUserDevFlowContent(devflowDir);
743
901
  const cleanupDecision = resolveDevflowDirCleanup({
744
- scope: 'user',
745
902
  isTTY,
746
903
  userContent,
747
904
  devflowDir,
@@ -782,11 +939,73 @@ export async function runFullPhaseForScope(opts) {
782
939
  }
783
940
  }
784
941
  }
942
+ /**
943
+ * The project-data step of the cleanup phase: list what a confirmed cleanup removes
944
+ * from `<gitRoot>/.devflow` and what it keeps, then remove on an explicit yes.
945
+ *
946
+ * D-UNINSTALL-CARVE-OUT: DEVFLOW_TRACKED_PATHS are never removed. `--keep-docs`,
947
+ * a non-interactive run, a decline and a cancel all leave the directory untouched,
948
+ * and a cancel continues the uninstall rather than exiting (avoids PF-014). The
949
+ * directory itself is removed only when nothing tracked was in it.
950
+ */
951
+ async function runProjectDataStep(plan, gates) {
952
+ const shown = `${plan.dir}/`;
953
+ if (plan.remove.length === 0) {
954
+ p.log.info(`${shown} preserved (holds only files shared via git)`);
955
+ return;
956
+ }
957
+ if (gates.keepDocs) {
958
+ p.log.info(`${shown} preserved (--keep-docs)`);
959
+ return;
960
+ }
961
+ if (!gates.isTTY) {
962
+ p.log.info(`${shown} preserved (non-interactive mode)`);
963
+ return;
964
+ }
965
+ for (const line of formatProjectDataPlan(plan))
966
+ p.log.info(line);
967
+ const answer = await gates.confirm({
968
+ message: `Remove the project data listed above from ${shown}?`,
969
+ initialValue: false,
970
+ });
971
+ if (!resolveProjectDataCleanup(answer)) {
972
+ p.log.info(p.isCancel(answer)
973
+ ? `${shown} preserved (prompt cancelled — continuing cleanup)`
974
+ : `${shown} preserved`);
975
+ return;
976
+ }
977
+ // Each removal is non-fatal: a failure is reported and the remaining cleanup
978
+ // steps (settings.json hooks above all) still run.
979
+ const removed = [];
980
+ const failed = [];
981
+ for (const entry of plan.remove) {
982
+ try {
983
+ await fs.rm(path.join(plan.dir, entry.name), { recursive: true, force: true });
984
+ removed.push(entry);
985
+ }
986
+ catch (err) {
987
+ failed.push(`${entryLabel(entry)} (${err.code ?? err.message})`);
988
+ }
989
+ }
990
+ if (plan.keep.length === 0 && failed.length === 0) {
991
+ // Nothing tracked was there: drop the now-empty directory. rmdir refuses a
992
+ // directory something wrote into meanwhile, which is then left in place.
993
+ await fs.rmdir(plan.dir).catch(() => undefined);
994
+ }
995
+ if (removed.length > 0)
996
+ p.log.success(`Removed from ${shown}: ${entryList(removed)}`);
997
+ if (failed.length > 0)
998
+ p.log.warn(`Could not remove from ${shown}: ${failed.join(', ')}`);
999
+ if (plan.keep.length > 0)
1000
+ p.log.info(`Kept: ${entryList(plan.keep)}`);
1001
+ }
785
1002
  /**
786
1003
  * CLEANUP PHASE: post-loop extras run only on full uninstall.
787
1004
  *
788
1005
  * Steps (all non-fatal, every interactive path gated on opts.isTTY):
789
- * 1. .devflow/ project data directory
1006
+ * 1. Project data under `<gitRoot>/.devflow`, minus DEVFLOW_TRACKED_PATHS
1007
+ * (resolveProjectDataPlan; skipped outside a repo, in one rooted at HOME, and
1008
+ * when `.devflow` is a symbolic link)
790
1009
  * 2. .claudeignore
791
1010
  * 3. settings.json — remove all Devflow hooks and flags
792
1011
  * 4. Security deny list
@@ -798,58 +1017,39 @@ export async function runFullPhaseForScope(opts) {
798
1017
  * would leave every destructive prompt gated on a global the caller cannot set,
799
1018
  * which under a TTY test runner points the prompts at the developer's real files.
800
1019
  *
801
- * @param opts.scopesToUninstall - Scopes processed by the scope loop.
802
- * @param opts.keepDocs - When true, .devflow/ and security prompts are suppressed.
1020
+ * @param opts.scopesToUninstall - Scopes processed by the scope loop. Steps 4 and 5
1021
+ * edit machine-wide files under HOME, so they run
1022
+ * only when this includes `user` — a legacy
1023
+ * local-only uninstall never touches HOME
1024
+ * (D-LEGACY-LOCAL-CLEANUP).
1025
+ * @param opts.keepDocs - When true, the project-data and security prompts are suppressed.
803
1026
  * @param opts.verbose - Whether to emit verbose log lines.
804
- * @param opts.cwd - Working directory for project-local path resolution
805
- * (.devflow/, git root, .claudeignore fallback).
1027
+ * @param opts.cwd - Working directory the git root is resolved from; it
1028
+ * locates `<gitRoot>/.devflow` and `.claudeignore`
1029
+ * (the cwd itself is the `.claudeignore` fallback).
806
1030
  * @param opts.isTTY - Whether the session is interactive. Every confirm
807
1031
  * prompt in this phase is gated on it.
808
1032
  */
809
1033
  export async function runCleanupPhase(opts) {
810
1034
  const { scopesToUninstall, keepDocs, verbose, cwd, isTTY } = opts;
1035
+ const confirm = opts.confirm ?? p.confirm;
811
1036
  // Resolve the git root from the injected cwd, not process.cwd(): otherwise
812
1037
  // .devflow/ resolves under `cwd` while .claudeignore resolves under the process
813
1038
  // directory, and the two halves of this phase act on different repositories.
814
1039
  const gitRoot = await getGitRoot(cwd);
815
- // 1. .devflow/ project data directory (contains docs/, memory/, learning/, features/, etc.)
816
- const devflowDataDir = path.join(cwd, '.devflow');
817
- let devflowDataExists = false;
818
- try {
819
- await fs.access(devflowDataDir);
820
- devflowDataExists = true;
821
- }
822
- catch { /* .devflow doesn't exist */ }
823
- if (devflowDataExists) {
824
- let shouldRemoveDevflow = false;
825
- // Tracks whether a specific "preserved" log was already emitted (e.g. the
826
- // cancel-path message below) to avoid printing the generic one twice. (F10)
827
- let preservedLogged = false;
828
- if (keepDocs) {
829
- shouldRemoveDevflow = false;
830
- }
831
- else if (isTTY) {
832
- const removeDevflow = await p.confirm({
833
- message: '.devflow/ directory found. Remove project data (docs, memory, learning)?',
834
- initialValue: false,
835
- });
836
- if (p.isCancel(removeDevflow)) {
837
- // Treat cancel as decline: preserve .devflow/ and continue cleanup.
838
- // avoids PF-014: process.exit() here would skip claudeignore, hooks,
839
- // and safe-delete removal — removeAllDevFlow has already run.
840
- // applies ADR-003: clean end-state on every path.
841
- p.log.info('.devflow/ preserved (prompt cancelled — continuing cleanup)');
842
- preservedLogged = true;
843
- }
844
- shouldRemoveDevflow = resolveProjectDataCleanup(removeDevflow);
845
- }
846
- if (shouldRemoveDevflow) {
847
- await fs.rm(devflowDataDir, { recursive: true, force: true });
848
- p.log.success('.devflow/ removed');
849
- }
850
- else if (!preservedLogged) {
851
- p.log.info('.devflow/ preserved');
852
- }
1040
+ // 1. Project data under <gitRoot>/.devflow (D-UNINSTALL-CARVE-OUT)
1041
+ const projectData = await resolveProjectDataPlan({
1042
+ gitRoot,
1043
+ homeDir: getHomeDirectory(),
1044
+ machineDevflowDir: getInstallationPaths().devflowDir,
1045
+ });
1046
+ if (projectData.kind === 'plan') {
1047
+ await runProjectDataStep(projectData.plan, { keepDocs, isTTY, confirm });
1048
+ }
1049
+ else {
1050
+ const skipped = formatProjectDataSkip(projectData.reason, gitRoot);
1051
+ if (skipped !== null)
1052
+ p.log.info(skipped);
853
1053
  }
854
1054
  // 2. .claudeignore
855
1055
  const claudeignorePath = gitRoot
@@ -863,7 +1063,7 @@ export async function runCleanupPhase(opts) {
863
1063
  catch { /* doesn't exist */ }
864
1064
  if (claudeignoreExists) {
865
1065
  if (isTTY) {
866
- const removeClaudeignore = await p.confirm({
1066
+ const removeClaudeignore = await confirm({
867
1067
  message: '.claudeignore found. Remove it? (may contain custom rules)',
868
1068
  initialValue: false,
869
1069
  });
@@ -882,11 +1082,13 @@ export async function runCleanupPhase(opts) {
882
1082
  // 3. settings.json (Devflow hooks)
883
1083
  for (const scope of [...scopesToUninstall]) {
884
1084
  try {
885
- const paths = await getInstallationPaths(scope);
1085
+ const paths = await scopeInstallPaths(scope, gitRoot);
1086
+ if (paths === null)
1087
+ continue;
886
1088
  const settingsPath = path.join(paths.claudeDir, 'settings.json');
887
1089
  const originalContent = await fs.readFile(settingsPath, 'utf-8');
888
1090
  // Remove all Devflow hooks and flags in one pass (idempotent)
889
- let settingsContent = await removeAmbientHook(originalContent);
1091
+ let settingsContent = await removeAmbientHook(originalContent, { purgeLegacyRule: scope === 'user' });
890
1092
  settingsContent = removeMemoryHooks(settingsContent);
891
1093
  settingsContent = removeCaptureHooks(settingsContent);
892
1094
  settingsContent = removeDreamHook(settingsContent);
@@ -908,7 +1110,7 @@ export async function runCleanupPhase(opts) {
908
1110
  settingsContent = JSON.stringify(parsedSettings, null, 2) + '\n';
909
1111
  }
910
1112
  if (settingsContent !== originalContent) {
911
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
1113
+ await writeSettingsFileAtomic(settingsPath, settingsContent);
912
1114
  if (verbose) {
913
1115
  p.log.success(`Devflow hooks removed from settings.json (${scope})`);
914
1116
  }
@@ -923,6 +1125,10 @@ export async function runCleanupPhase(opts) {
923
1125
  // settings.json doesn't exist or can't be parsed — skip
924
1126
  }
925
1127
  }
1128
+ // Steps 4 and 5 edit the user's settings.json and shell profile: a legacy
1129
+ // local-only uninstall stops here (D-LEGACY-LOCAL-CLEANUP).
1130
+ if (!scopesToUninstall.includes('user'))
1131
+ return;
926
1132
  // 4. Security deny list
927
1133
  // Detect what's installed
928
1134
  let userSettingsJsonForSecurity = null;
@@ -952,7 +1158,7 @@ export async function runCleanupPhase(opts) {
952
1158
  });
953
1159
  let shouldRemoveSecurity = false;
954
1160
  if (securityDecision === 'prompt') {
955
- const removeDenyConfirm = await p.confirm({
1161
+ const removeDenyConfirm = await confirm({
956
1162
  message: `Remove Devflow security deny list from ${locationLabel}?`,
957
1163
  initialValue: false,
958
1164
  });
@@ -977,7 +1183,7 @@ export async function runCleanupPhase(opts) {
977
1183
  if (detectedSecurity.user && userSettingsJsonForSecurity !== null) {
978
1184
  const { json: stripped, removed } = stripUserDenyList(userSettingsJsonForSecurity, DEVFLOW_HISTORICAL_DENY);
979
1185
  if (removed.length > 0) {
980
- await writeFileAtomicExclusive(userSettingsPathForSecurity, stripped);
1186
+ await writeSettingsFileAtomic(userSettingsPathForSecurity, stripped);
981
1187
  p.log.success(`Security deny list removed from user settings (${removed.length} entries)`);
982
1188
  }
983
1189
  }
@@ -992,7 +1198,7 @@ export async function runCleanupPhase(opts) {
992
1198
  const profilePath = getProfilePath(shell);
993
1199
  if (profilePath && await isAlreadyInstalled(profilePath)) {
994
1200
  if (isTTY) {
995
- const removeSafeDelete = await p.confirm({
1201
+ const removeSafeDelete = await confirm({
996
1202
  message: `Remove safe-delete function from ${profilePath}?`,
997
1203
  initialValue: false,
998
1204
  });
@@ -1017,7 +1223,7 @@ export async function runCleanupPhase(opts) {
1017
1223
  export const uninstallCommand = new Command('uninstall')
1018
1224
  .description('Uninstall Devflow from Claude Code')
1019
1225
  .option('--keep-docs', 'Keep .devflow/ directory and project data')
1020
- .option('--scope <type>', 'Uninstall from specific scope only (default: auto-detect all)', /^(user|local)$/i)
1226
+ .option('--scope <type>', 'Uninstall from one scope only: user, or local to remove a legacy project-local install (default: auto-detect both)', /^(user|local)$/i)
1021
1227
  .option('--plugin <names>', 'Uninstall specific plugin(s), comma-separated (e.g., implement,code-review)')
1022
1228
  .option('--verbose', 'Show detailed uninstall output')
1023
1229
  .option('--dry-run', 'Show what would be removed without actually removing anything')
@@ -1054,24 +1260,27 @@ export const uninstallCommand = new Command('uninstall')
1054
1260
  : [];
1055
1261
  // Determine which scopes to uninstall
1056
1262
  let scopesToUninstall = [];
1263
+ const gitRoot = await getGitRoot();
1057
1264
  if (options.scope) {
1058
1265
  scopesToUninstall = [options.scope.toLowerCase()];
1266
+ // A local-only uninstall with no repo-local install to act on stops here,
1267
+ // before its cleanup phase can reach the cwd — HOME, in a repo rooted there.
1268
+ if (scopesToUninstall[0] === 'local' && await scopeInstallPaths('local', gitRoot) === null) {
1269
+ p.log.error('No legacy project-local install here: not in a git repository, or the repository root holds the machine-wide install');
1270
+ process.exit(1);
1271
+ }
1059
1272
  }
1060
1273
  else {
1061
- const userClaudeDir = getClaudeDirectory();
1062
- const gitRoot = await getGitRoot();
1063
- if (await isDevFlowInstalled(userClaudeDir)) {
1274
+ if (await isDevFlowInstalled(getClaudeDirectory())) {
1064
1275
  scopesToUninstall.push('user');
1065
1276
  }
1066
- if (gitRoot) {
1067
- const localClaudeDir = path.join(gitRoot, '.claude');
1068
- if (await isDevFlowInstalled(localClaudeDir)) {
1069
- scopesToUninstall.push('local');
1070
- }
1277
+ const legacyPaths = gitRoot === null ? null : await legacyLocalInstallPaths(gitRoot);
1278
+ if (legacyPaths !== null && await isDevFlowInstalled(legacyPaths.claudeDir)) {
1279
+ scopesToUninstall.push('local');
1071
1280
  }
1072
1281
  if (scopesToUninstall.length === 0) {
1073
1282
  p.log.error('No Devflow installation found');
1074
- p.log.info('Checked user scope (~/.claude/) and local scope (git-root/.claude/)');
1283
+ p.log.info(`Checked user scope (${getClaudeDirectory()}/) and legacy local scope (git-root/.claude/)`);
1075
1284
  process.exit(1);
1076
1285
  }
1077
1286
  if (scopesToUninstall.length > 1 && !dryRun) {
@@ -1080,7 +1289,7 @@ export const uninstallCommand = new Command('uninstall')
1080
1289
  message: 'Found Devflow in multiple scopes. Uninstall from:',
1081
1290
  options: [
1082
1291
  { value: 'both', label: 'Both', hint: 'user + local' },
1083
- { value: 'user', label: 'User scope', hint: '~/.claude/' },
1292
+ { value: 'user', label: 'User scope', hint: `${getClaudeDirectory()}/` },
1084
1293
  { value: 'local', label: 'Local scope', hint: 'git-root/.claude/' },
1085
1294
  ],
1086
1295
  });
@@ -1102,11 +1311,10 @@ export const uninstallCommand = new Command('uninstall')
1102
1311
  // One resolution, handed to the dry-run and (below) to the real removal,
1103
1312
  // so the preview and the outcome are computed from the same list.
1104
1313
  let dryRunInstalled = DEVFLOW_PLUGINS;
1105
- try {
1106
- const paths = await getInstallationPaths(scopesToUninstall[0]);
1107
- dryRunInstalled = await resolveInstalledPlugins(paths.devflowDir);
1314
+ const dryRunPaths = await scopeInstallPaths(scopesToUninstall[0], gitRoot);
1315
+ if (dryRunPaths !== null) {
1316
+ dryRunInstalled = await resolveInstalledPlugins(dryRunPaths.devflowDir);
1108
1317
  }
1109
- catch { /* scope path resolution failed — fall back to the registry */ }
1110
1318
  await runDryRunPhase({
1111
1319
  scopesToUninstall,
1112
1320
  isSelectiveUninstall,
@@ -1129,7 +1337,9 @@ export const uninstallCommand = new Command('uninstall')
1129
1337
  if (!isSelectiveUninstall) {
1130
1338
  for (const scope of scopesToUninstall) {
1131
1339
  try {
1132
- const paths = await getInstallationPaths(scope);
1340
+ const paths = await scopeInstallPaths(scope, gitRoot);
1341
+ if (paths === null)
1342
+ continue;
1133
1343
  if (await proxyJsonExists(paths.devflowDir)) {
1134
1344
  const proxyState = await readProxyState(paths.devflowDir);
1135
1345
  if (proxyState.ok)
@@ -1141,25 +1351,19 @@ export const uninstallCommand = new Command('uninstall')
1141
1351
  }
1142
1352
  // Uninstall from each scope
1143
1353
  for (const scope of scopesToUninstall) {
1144
- let claudeDir;
1145
- let devflowScriptsDir;
1146
- let devflowDir;
1147
- try {
1148
- const paths = await getInstallationPaths(scope);
1149
- claudeDir = paths.claudeDir;
1150
- devflowDir = paths.devflowDir;
1151
- devflowScriptsDir = path.join(paths.devflowDir, 'scripts');
1152
- if (scope === 'user') {
1153
- p.log.step('Uninstalling user scope (~/.claude/)');
1154
- }
1155
- else {
1156
- p.log.step('Uninstalling local scope (git-root/.claude/)');
1157
- }
1158
- }
1159
- catch (error) {
1160
- p.log.warn(`Cannot uninstall ${scope} scope: ${error instanceof Error ? error.message : error}`);
1354
+ const paths = await scopeInstallPaths(scope, gitRoot);
1355
+ if (paths === null) {
1356
+ p.log.warn(`Cannot uninstall ${scope} scope: no repo-local install here`);
1161
1357
  continue;
1162
1358
  }
1359
+ const { claudeDir, devflowDir } = paths;
1360
+ const devflowScriptsDir = path.join(devflowDir, 'scripts');
1361
+ if (scope === 'user') {
1362
+ p.log.step(`Uninstalling user scope (${claudeDir})`);
1363
+ }
1364
+ else {
1365
+ p.log.step('Uninstalling legacy local scope (git-root/.claude/)');
1366
+ }
1163
1367
  if (isSelectiveUninstall) {
1164
1368
  await runSelectivePhaseForScope({
1165
1369
  claudeDir,
@@ -1167,6 +1371,7 @@ export const uninstallCommand = new Command('uninstall')
1167
1371
  selectedPlugins,
1168
1372
  verbose,
1169
1373
  installedPlugins: await resolveInstalledPlugins(devflowDir),
1374
+ scope,
1170
1375
  });
1171
1376
  }
1172
1377
  else {