devflow-kit 2.4.0 → 3.0.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.
Files changed (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -4,9 +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 { DEVFLOW_PLUGINS, SKILL_NAMESPACE, getAllSkillNames, getAllAgentNames, getAllCommandNames, parsePluginSelection, resolveFeatureRedirect, prefixSkillName, unprefixSkillName, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
9
+ import { isSameLocation } from '../../core/same-location.js';
10
+ import { DEVFLOW_PLUGINS, SKILL_NAMESPACE, getAllSkillNames, getAllAgentNames, getAllCommandNames, parsePluginSelection, resolveFeatureRedirect, prefixSkillName, unprefixSkillName, skillsOf, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
11
+ import { readManifest } from '../../core/manifest.js';
10
12
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
11
13
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
12
14
  import { removeAmbientHook } from './ambient.js';
@@ -18,28 +20,99 @@ import { removeContextHook } from './context.js';
18
20
  import { applyProxyTeardownToSettings } from './proxy.js';
19
21
  import { readProxyState, proxyJsonExists } from '../../core/proxy-state.js';
20
22
  import { hudCacheDir } from '../../core/cache.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';
21
24
  import { revertExternalAgents } from '../../core/agent-models.js';
22
25
  import { detectShell, getProfilePath } from '../../core/safe-delete.js';
23
26
  import { isAlreadyInstalled, removeFromProfile } from '../../core/safe-delete-install.js';
24
- import { removeManagedSettings, stripUserDenyList, detectDenyState, DEVFLOW_HISTORICAL_DENY } from '../../targets/claude-code/post-install.js';
25
- 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';
26
29
  import { stripFlags } from '../../core/flags.js';
27
30
  import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js';
28
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
+ }
71
+ /**
72
+ * The plugins the manifest records as installed, as registry definitions.
73
+ *
74
+ * Falls back to the whole registry when there is no readable manifest, or when
75
+ * it names nothing this registry still has: that is the pre-manifest and the
76
+ * corrupt-manifest case, and retaining too much is the safe direction for a
77
+ * removal. Names the manifest carries that the registry has since dropped are
78
+ * skipped rather than invented — a definition is what the retained-set
79
+ * arithmetic needs, and there is none for a deleted plugin.
80
+ */
81
+ export async function resolveInstalledPlugins(devflowDir) {
82
+ const manifest = await readManifest(devflowDir).catch(() => null);
83
+ const names = new Set(manifest?.plugins ?? []);
84
+ if (names.size === 0)
85
+ return DEVFLOW_PLUGINS;
86
+ const resolved = DEVFLOW_PLUGINS.filter(plugin => names.has(plugin.name));
87
+ return resolved.length === 0 ? DEVFLOW_PLUGINS : resolved;
88
+ }
29
89
  /**
30
90
  * Compute which assets should be removed during selective plugin uninstall.
31
91
  * Skills and agents shared by remaining plugins are retained.
32
92
  * Rules shared by remaining plugins are also retained.
93
+ *
94
+ * D-RETAIN-FROM-MANIFEST: `installedPlugins` is what the MANIFEST records as
95
+ * installed, not the whole registry. Once skills are plugin-scoped the two stop
96
+ * agreeing, and taking the registry retains assets on behalf of plugins the user
97
+ * never installed — so `devflow uninstall --plugin=X` keeps X's skills alive
98
+ * because some unselected plugin also declares them, and the user is left with
99
+ * exactly the files they asked to remove. Skills are retained across the CLOSURE
100
+ * (`skills ∪ requires`) of the remaining plugins, for the same reason the
101
+ * install set is a closure: a skill another installed plugin merely requires is
102
+ * still a skill it needs.
103
+ *
104
+ * @param installedPlugins - The plugins the manifest records. Callers pass the
105
+ * registry only when there is no manifest to read, and the dry-run and the
106
+ * real removal must always be given the SAME list or they describe different
107
+ * outcomes.
33
108
  */
34
- export function computeAssetsToRemove(selectedPlugins, allPlugins) {
109
+ export function computeAssetsToRemove(selectedPlugins, installedPlugins) {
35
110
  const selectedNames = new Set(selectedPlugins.map(p => p.name));
36
- const remainingPlugins = allPlugins.filter(p => !selectedNames.has(p.name));
37
- const retainedSkills = new Set();
111
+ const remainingPlugins = installedPlugins.filter(p => !selectedNames.has(p.name));
112
+ const retainedSkills = skillsOf(remainingPlugins);
38
113
  const retainedAgents = new Set();
39
114
  const retainedRules = new Set();
40
115
  for (const rp of remainingPlugins) {
41
- for (const s of rp.skills)
42
- retainedSkills.add(s);
43
116
  for (const a of rp.agents)
44
117
  retainedAgents.add(a);
45
118
  for (const r of rp.rules)
@@ -49,11 +122,11 @@ export function computeAssetsToRemove(selectedPlugins, allPlugins) {
49
122
  const agents = [];
50
123
  const commands = [];
51
124
  const rules = [];
125
+ for (const skill of skillsOf(selectedPlugins)) {
126
+ if (!retainedSkills.has(skill))
127
+ skills.push(skill);
128
+ }
52
129
  for (const plugin of selectedPlugins) {
53
- for (const skill of plugin.skills) {
54
- if (!retainedSkills.has(skill))
55
- skills.push(skill);
56
- }
57
130
  for (const agent of plugin.agents) {
58
131
  if (!retainedAgents.has(agent))
59
132
  agents.push(agent);
@@ -130,6 +203,103 @@ export function resolveSecurityRemovalDecision(opts) {
130
203
  export function resolveProjectDataCleanup(answer) {
131
204
  return answer === true;
132
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
+ }
133
303
  /**
134
304
  * Determine the appropriate cleanup action for the user-scope devflow directory on
135
305
  * full uninstall. Mirrors the resolveSecurityRemovalDecision pattern.
@@ -141,7 +311,7 @@ export function resolveProjectDataCleanup(answer) {
141
311
  * 1. basename(devflowDir) must be '.devflow'
142
312
  * 2. devflowDir must not equal homeDir
143
313
  * 3. devflowDir must not be the filesystem root '/'
144
- * 4. devflowDir must reside inside $HOME (guards DEVFLOW_DIR env overrides)
314
+ * 4. devflowDir must reside inside $HOME
145
315
  *
146
316
  * Returns:
147
317
  * - 'artifacts-only' — remove only manifest.json; leave the directory intact
@@ -158,14 +328,11 @@ export function resolveProjectDataCleanup(answer) {
158
328
  * --keep-docs from triggering prompts about skill shadows or preference-profile.md.
159
329
  */
160
330
  export function resolveDevflowDirCleanup(opts) {
161
- // Local scope never removes project data — only install artifacts.
162
- if (opts.scope !== 'user')
163
- return 'artifacts-only';
164
331
  // --keep-docs: suppress the full cleanup prompt entirely; artifacts-only.
165
332
  if (opts.keepDocs)
166
333
  return 'artifacts-only';
167
334
  // Precondition guard: devflowDir must be a well-known, safe-to-rm path.
168
- // Any anomalous value (DEVFLOW_DIR override, bare homedir, filesystem root)
335
+ // Any anomalous value (bare homedir, filesystem root, a path outside HOME)
169
336
  // resolves to artifacts-only — never throw in business logic (engineering rule).
170
337
  const isBasenameValid = path.basename(opts.devflowDir) === '.devflow';
171
338
  const isNotHomeDir = opts.devflowDir !== opts.homeDir;
@@ -183,21 +350,82 @@ export function resolveDevflowDirCleanup(opts) {
183
350
  return 'prompt';
184
351
  }
185
352
  /**
186
- * Enumerate user-authored content in devflowDir that would be deleted by a
187
- * full cleanup of the directory.
353
+ * Single source of truth for user-authored content under `devflowDir`.
188
354
  *
189
- * Checks for items that exist on disk and are worth backing up:
190
- * devflowDir/skills/ — skill shadow overrides (user-maintained)
191
- * devflowDir/rules/ — rule shadow overrides (user-maintained)
192
- * devflowDir/preference-profile.md — dynamic-plan preference profile
193
- * devflowDir/learning.json — global learning agent tuning config
194
- * devflowDir/hud.json — HUD enable/disable preference and display config
355
+ * The counterpart to `installArtifactPaths`: together the two lists describe
356
+ * every Devflow-created entry in `~/.devflow`, and they never overlap (@D8).
357
+ * Membership here means "survives every path but a confirmed full-dir rm";
358
+ * membership there means "removed on decline, cancel, non-interactive and
359
+ * --keep-docs alike".
195
360
  *
196
361
  * NOT listed here: agent-models.json — reclassified as an INSTALL ARTIFACT (AC-P1-F4).
197
362
  * Stale per-agent model overrides silently re-apply to renamed/deleted agents on reinstall,
198
363
  * so it must be cleaned up by removeDevFlowInstallArtifacts, not preserved behind a confirm gate.
199
364
  *
200
- * Returns labels for each item that actually exists. Empty array means nothing
365
+ * @param devflowDir - Absolute path to ~/.devflow (labels quote the shadow dirs).
366
+ */
367
+ export function userContentPaths(devflowDir) {
368
+ return [
369
+ // Skill shadow overrides (~/.devflow/skills/{name}/)
370
+ { relPath: 'skills', isDir: true, label: `skill shadows (${path.join(devflowDir, 'skills')})` },
371
+ // Rule shadow overrides (~/.devflow/rules/{name}.md)
372
+ { relPath: 'rules', isDir: true, label: `rule shadows (${path.join(devflowDir, 'rules')})` },
373
+ // preference-profile.md — user-curated decision-preference profile
374
+ { relPath: 'preference-profile.md', label: 'preference-profile.md' },
375
+ // tracker/ — the inferred, hand-editable issue-tracker conventions, one file
376
+ // per provider (D-TRACKER-PER-PROVIDER-CONVENTIONS).
377
+ //
378
+ // USER CONTENT (OD-15), classified the same way as preference-profile.md above
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.
383
+ //
384
+ // REVERSAL CONDITION, recorded: this classification is CONDITIONAL on the
385
+ // provider-mismatch guard shipping. agent-models.json was reclassified to an
386
+ // artifact precisely because stale overrides re-apply *silently*; "silently" is
387
+ // the load-bearing word. A conventions file whose frontmatter provider
388
+ // disagrees with the resolved provider produces
389
+ // `TRACEABILITY: DEGRADED (tracker configuration mismatch (conventions file))`
390
+ // and no tracker call — that is what removes the silence, and the reason names
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
+ }),
417
+ // learning.json — global learning agent tuning config
418
+ { relPath: 'learning.json', label: 'learning.json' },
419
+ // hud.json — user HUD enable/disable preference and display config
420
+ { relPath: 'hud.json', label: 'hud.json (HUD configuration)' },
421
+ ];
422
+ }
423
+ /**
424
+ * Enumerate user-authored content in devflowDir that would be deleted by a
425
+ * full cleanup of the directory.
426
+ *
427
+ * Walks `userContentPaths` and returns the label of every entry that exists on
428
+ * disk — a directory counts only when it is non-empty. Empty array means nothing
201
429
  * user-authored is present in the directory.
202
430
  *
203
431
  * Pure I/O — no side effects, no output, fully testable.
@@ -207,41 +435,54 @@ export function resolveDevflowDirCleanup(opts) {
207
435
  */
208
436
  export async function enumerateUserDevFlowContent(devflowDir) {
209
437
  const items = [];
210
- // Skill shadow overrides (~/.devflow/skills/{name}/)
211
- try {
212
- const entries = await fs.readdir(path.join(devflowDir, 'skills'));
213
- if (entries.length > 0) {
214
- items.push(`skill shadows (${path.join(devflowDir, 'skills')})`);
438
+ for (const entry of userContentPaths(devflowDir)) {
439
+ const fullPath = path.join(devflowDir, entry.relPath);
440
+ try {
441
+ if (entry.isDir) {
442
+ const children = await fs.readdir(fullPath);
443
+ if (children.length === 0)
444
+ continue;
445
+ }
446
+ else {
447
+ await fs.access(fullPath);
448
+ }
449
+ items.push(entry.label);
215
450
  }
451
+ catch { /* absent or unreadable */ }
216
452
  }
217
- catch { /* dir absent or unreadable */ }
218
- // Rule shadow overrides (~/.devflow/rules/{name}.md)
219
- try {
220
- const entries = await fs.readdir(path.join(devflowDir, 'rules'));
221
- if (entries.length > 0) {
222
- items.push(`rule shadows (${path.join(devflowDir, 'rules')})`);
453
+ return items;
454
+ }
455
+ /**
456
+ * Expand `installArtifactPaths` against real disk.
457
+ *
458
+ * Exact entries pass through untouched; a prefix entry becomes one entry per
459
+ * matching direct child of `devflowDir`. This is the ONE place a prefix turns
460
+ * into paths, so the removal loop and the dry-run preview cannot disagree about
461
+ * what a prefix covers. An unreadable or absent `devflowDir` yields the exact
462
+ * entries alone — absent is the ordinary case here, never an error.
463
+ */
464
+ export async function resolveInstallArtifactPaths(devflowDir) {
465
+ const resolved = [];
466
+ let children = null;
467
+ for (const entry of installArtifactPaths(devflowDir)) {
468
+ if (entry.isPrefix !== true) {
469
+ resolved.push({ relPath: entry.relPath, isDir: entry.isDir });
470
+ continue;
471
+ }
472
+ if (children === null) {
473
+ try {
474
+ children = await fs.readdir(devflowDir);
475
+ }
476
+ catch {
477
+ children = [];
478
+ }
479
+ }
480
+ for (const child of children) {
481
+ if (child.startsWith(entry.relPath))
482
+ resolved.push({ relPath: child, isDir: entry.isDir });
223
483
  }
224
484
  }
225
- catch { /* dir absent or unreadable */ }
226
- // preference-profile.md — user-curated decision-preference profile
227
- try {
228
- await fs.access(path.join(devflowDir, 'preference-profile.md'));
229
- items.push('preference-profile.md');
230
- }
231
- catch { /* absent */ }
232
- // learning.json — global learning agent tuning config
233
- try {
234
- await fs.access(path.join(devflowDir, 'learning.json'));
235
- items.push('learning.json');
236
- }
237
- catch { /* absent */ }
238
- // hud.json — user HUD enable/disable preference and display config
239
- try {
240
- await fs.access(path.join(devflowDir, 'hud.json'));
241
- items.push('hud.json (HUD configuration)');
242
- }
243
- catch { /* absent */ }
244
- return items;
485
+ return resolved;
245
486
  }
246
487
  /**
247
488
  * Single source of truth for Devflow-owned install artifacts under `devflowDir`.
@@ -252,9 +493,18 @@ export async function enumerateUserDevFlowContent(devflowDir) {
252
493
  * lets callers (dry-run display, tests) enumerate the artifact set without
253
494
  * duplicating the list.
254
495
  *
255
- * @D8 Nothing returned here may overlap with the items enumerated by
256
- * `enumerateUserDevFlowContent` — the disjointness invariant is tested by test 9f
257
- * and enforced by keeping both lists in one place.
496
+ * @D8 Nothing returned here may overlap with `userContentPaths` — the two lists
497
+ * are the whole of what Devflow puts in `~/.devflow`, and a name in both is
498
+ * deleted whatever the user answers to the full-wipe prompt. Checked two ways:
499
+ * mechanically, by intersecting the `relPath` sets of the two functions and
500
+ * checking no user path falls UNDER a prefix entry (so an entry added to either
501
+ * list is covered the day it lands), and behaviourally, by test 9f — every
502
+ * enumerated user item survives an artifact-only removal.
503
+ *
504
+ * Entries marked `isPrefix` name a FAMILY of per-invocation basenames rather
505
+ * than one path; `resolveInstallArtifactPaths` is what turns them into paths.
506
+ * Every consumer goes through that resolver, so a prefix entry is never treated
507
+ * as a literal filename.
258
508
  *
259
509
  * @param devflowDir - Absolute path to ~/.devflow (used to resolve cache dir).
260
510
  */
@@ -274,6 +524,22 @@ export function installArtifactPaths(devflowDir) {
274
524
  { relPath: 'proxy-routing.json' },
275
525
  { relPath: 'proxy.pid' },
276
526
  { relPath: '.proxy-spawn.lock', isDir: true },
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).
534
+ { relPath: TRACKER_CLAIM_FILE },
535
+ ...TRACKER_ATTEMPTS_NAMES.map(name => ({ relPath: name })),
536
+ { relPath: TRACKER_LEGACY_ATTEMPTS_FILE },
537
+ { relPath: TRACKER_ENABLED_FILE },
538
+ // The agent's scrubbed staging file, one per invocation under a mktemp name
539
+ // it removes from a trap — a SIGKILL outruns the trap and leaves it behind.
540
+ // A prefix, because the names exist only on disk. Content is a scrubbed copy
541
+ // that was never placed, so it is machine state like the three above.
542
+ { relPath: TRACKER_STAGED_PREFIX, isPrefix: true },
277
543
  // per-project hook logs (logs/{project-slug}/) AND global logs — remove the
278
544
  // whole logs/ tree; covers proxy.log, debug logs, and any project-slug dirs.
279
545
  { relPath: 'logs', isDir: true },
@@ -295,8 +561,9 @@ export function installArtifactPaths(devflowDir) {
295
561
  * @D8 Nothing enumerated by enumerateUserDevFlowContent may appear in this list.
296
562
  * This function runs on the decline, cancel, non-interactive AND --keep-docs paths,
297
563
  * so an entry here is deleted even when the user answers "no" to the full wipe.
298
- * User-authored state (skill/rule shadows, preference-profile.md, learning.json,
299
- * hud.json) is removed only by the confirmed full-dir rm.
564
+ * User-authored state (everything in `userContentPaths`: skill/rule shadows,
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.
300
567
  * agent-models.json is an INSTALL ARTIFACT (stale per-agent overrides silently
301
568
  * re-apply to renamed/deleted agents on reinstall — AC-P1-F4) and therefore
302
569
  * belongs in this list, not in enumerateUserDevFlowContent.
@@ -327,8 +594,10 @@ export async function removeDevFlowInstallArtifacts(devflowDir, verbose) {
327
594
  }
328
595
  }
329
596
  catch { /* proxy.pid absent or unreadable — non-fatal */ }
330
- // All install artifacts removed non-fatally (avoids PF-009).
331
- for (const artifact of installArtifactPaths(devflowDir)) {
597
+ // All install artifacts removed non-fatally (avoids PF-009). Resolved against
598
+ // disk first, so a per-run staging basename is a real path by the time the
599
+ // containment guard below sees it.
600
+ for (const artifact of await resolveInstallArtifactPaths(devflowDir)) {
332
601
  const fullPath = path.join(devflowDir, artifact.relPath);
333
602
  // Containment invariant: every artifact must resolve to a path STRICTLY inside
334
603
  // devflowDir. A derived relPath that ever collapsed to '' or '..' would turn the
@@ -456,7 +725,7 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir) {
456
725
  // Guard with fs.access so files that never existed don't pollute the preview.
457
726
  // (F7: previously pushed unconditionally, inflating the dry-run list with
458
727
  // paths that were never on disk.)
459
- for (const artifact of installArtifactPaths(devflowDir)) {
728
+ for (const artifact of await resolveInstallArtifactPaths(devflowDir)) {
460
729
  const fullPath = path.join(devflowDir, artifact.relPath);
461
730
  try {
462
731
  await fs.access(fullPath);
@@ -476,13 +745,18 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir) {
476
745
  * @param opts.scopesToUninstall - Scopes detected in the setup phase.
477
746
  * @param opts.isSelectiveUninstall - true when --plugin was given.
478
747
  * @param opts.selectedPlugins - The plugin subset for selective mode.
748
+ * @param opts.installedPlugins - What the manifest records as installed. The
749
+ * dry-run and the real removal MUST receive the same list, or the preview
750
+ * describes an outcome the removal does not produce.
479
751
  */
480
752
  export async function runDryRunPhase(opts) {
481
- const { scopesToUninstall, isSelectiveUninstall, selectedPlugins } = opts;
753
+ const { scopesToUninstall, isSelectiveUninstall, selectedPlugins, installedPlugins } = opts;
482
754
  p.log.info(`Scope(s): ${[...scopesToUninstall].join(', ')} (dry-run shows all detected scopes)`);
483
755
  if (isSelectiveUninstall) {
484
- // Selective: compute from registry — this accurately reflects what would be removed.
485
- const assets = computeAssetsToRemove(selectedPlugins, DEVFLOW_PLUGINS);
756
+ // Selective: computed against the INSTALLED list, the same argument the real
757
+ // removal is given below — a preview computed from a different list is a
758
+ // preview of a different uninstall.
759
+ const assets = computeAssetsToRemove(selectedPlugins, installedPlugins);
486
760
  const plan = formatDryRunPlan(assets);
487
761
  for (const line of plan.split('\n')) {
488
762
  p.log.info(line);
@@ -493,22 +767,31 @@ export async function runDryRunPhase(opts) {
493
767
  // skills sweep. Enumerate what is actually on disk for each detected scope
494
768
  // rather than computing from the registry (which misses legacy/orphaned assets).
495
769
  const extras = [];
770
+ const gitRoot = scopesToUninstall.includes('local') ? await getGitRoot() : null;
496
771
  for (const scope of [...scopesToUninstall]) {
497
- try {
498
- const paths = await getInstallationPaths(scope);
499
- const { claudeDir: cd, devflowDir: dd } = paths;
500
- const moreExtras = await enumerateDryRunExtras(cd, dd);
501
- 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)`);
502
788
  }
503
- catch { /* scope path resolution failed */ }
504
789
  }
505
- // Project .devflow/ data dir
506
- const devflowDataDir = path.join(process.cwd(), '.devflow');
507
- try {
508
- await fs.access(devflowDataDir);
509
- extras.push(`${devflowDataDir} (if confirmed)`);
790
+ else {
791
+ const skipped = formatProjectDataSkip(projectData.reason, projectRoot);
792
+ if (skipped !== null)
793
+ extras.push(skipped);
510
794
  }
511
- catch { /* noop */ }
512
795
  extras.push('hooks removed from settings.json');
513
796
  for (const line of extras) {
514
797
  p.log.info(` ${line}`);
@@ -527,6 +810,8 @@ export async function runDryRunPhase(opts) {
527
810
  */
528
811
  export async function runSelectivePhaseForScope(opts) {
529
812
  const { claudeDir, devflowDir, selectedPlugins, verbose } = opts;
813
+ const scope = opts.scope ?? 'user';
814
+ const installedPlugins = opts.installedPlugins ?? DEVFLOW_PLUGINS;
530
815
  // Revert GPT agent frontmatter BEFORE removing agent files — strips GPT model
531
816
  // lines from installed agent frontmatter while the files are still present.
532
817
  // Non-fatal: tolerate missing agents dir or revert errors.
@@ -543,15 +828,15 @@ export async function runSelectivePhaseForScope(opts) {
543
828
  }
544
829
  catch { /* agents dir absent or revert failed — non-fatal */ }
545
830
  }
546
- await removeSelectedPlugins(claudeDir, selectedPlugins, verbose);
831
+ await removeSelectedPlugins(claudeDir, selectedPlugins, verbose, installedPlugins);
547
832
  // Clean up ambient hook if ambient plugin is being removed
548
833
  if (selectedPlugins.some(sp => sp.name === 'devflow-ambient')) {
549
834
  const settingsPath = path.join(claudeDir, 'settings.json');
550
835
  try {
551
836
  const settings = await fs.readFile(settingsPath, 'utf-8');
552
- const updated = await removeAmbientHook(settings);
837
+ const updated = await removeAmbientHook(settings, { purgeLegacyRule: scope === 'user' });
553
838
  if (updated !== settings) {
554
- await fs.writeFile(settingsPath, updated, 'utf-8');
839
+ await writeSettingsFileAtomic(settingsPath, updated);
555
840
  if (verbose) {
556
841
  p.log.success('Ambient mode hooks removed from settings.json');
557
842
  }
@@ -569,7 +854,7 @@ export async function runSelectivePhaseForScope(opts) {
569
854
  * User scope: interactive TTY with user-authored content → confirm before wiping
570
855
  * ~/.devflow/; non-interactive or no user content → artifacts-only.
571
856
  *
572
- * @param opts.scope - 'user' or 'local'.
857
+ * @param opts.scope - 'user' or a legacy 'local' install.
573
858
  * @param opts.claudeDir - Target Claude Code directory for this scope.
574
859
  * @param opts.devflowDir - Devflow data directory for this scope.
575
860
  * @param opts.devflowScriptsDir - scripts/ sub-directory removed by removeAllDevFlow.
@@ -614,7 +899,6 @@ export async function runFullPhaseForScope(opts) {
614
899
  // Non-interactive, no user content, or precondition guard failure → artifacts-only.
615
900
  const userContent = await enumerateUserDevFlowContent(devflowDir);
616
901
  const cleanupDecision = resolveDevflowDirCleanup({
617
- scope: 'user',
618
902
  isTTY,
619
903
  userContent,
620
904
  devflowDir,
@@ -655,11 +939,73 @@ export async function runFullPhaseForScope(opts) {
655
939
  }
656
940
  }
657
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
+ }
658
1002
  /**
659
1003
  * CLEANUP PHASE: post-loop extras run only on full uninstall.
660
1004
  *
661
1005
  * Steps (all non-fatal, every interactive path gated on opts.isTTY):
662
- * 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)
663
1009
  * 2. .claudeignore
664
1010
  * 3. settings.json — remove all Devflow hooks and flags
665
1011
  * 4. Security deny list
@@ -671,58 +1017,39 @@ export async function runFullPhaseForScope(opts) {
671
1017
  * would leave every destructive prompt gated on a global the caller cannot set,
672
1018
  * which under a TTY test runner points the prompts at the developer's real files.
673
1019
  *
674
- * @param opts.scopesToUninstall - Scopes processed by the scope loop.
675
- * @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.
676
1026
  * @param opts.verbose - Whether to emit verbose log lines.
677
- * @param opts.cwd - Working directory for project-local path resolution
678
- * (.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).
679
1030
  * @param opts.isTTY - Whether the session is interactive. Every confirm
680
1031
  * prompt in this phase is gated on it.
681
1032
  */
682
1033
  export async function runCleanupPhase(opts) {
683
1034
  const { scopesToUninstall, keepDocs, verbose, cwd, isTTY } = opts;
1035
+ const confirm = opts.confirm ?? p.confirm;
684
1036
  // Resolve the git root from the injected cwd, not process.cwd(): otherwise
685
1037
  // .devflow/ resolves under `cwd` while .claudeignore resolves under the process
686
1038
  // directory, and the two halves of this phase act on different repositories.
687
1039
  const gitRoot = await getGitRoot(cwd);
688
- // 1. .devflow/ project data directory (contains docs/, memory/, learning/, features/, etc.)
689
- const devflowDataDir = path.join(cwd, '.devflow');
690
- let devflowDataExists = false;
691
- try {
692
- await fs.access(devflowDataDir);
693
- devflowDataExists = true;
694
- }
695
- catch { /* .devflow doesn't exist */ }
696
- if (devflowDataExists) {
697
- let shouldRemoveDevflow = false;
698
- // Tracks whether a specific "preserved" log was already emitted (e.g. the
699
- // cancel-path message below) to avoid printing the generic one twice. (F10)
700
- let preservedLogged = false;
701
- if (keepDocs) {
702
- shouldRemoveDevflow = false;
703
- }
704
- else if (isTTY) {
705
- const removeDevflow = await p.confirm({
706
- message: '.devflow/ directory found. Remove project data (docs, memory, learning)?',
707
- initialValue: false,
708
- });
709
- if (p.isCancel(removeDevflow)) {
710
- // Treat cancel as decline: preserve .devflow/ and continue cleanup.
711
- // avoids PF-014: process.exit() here would skip claudeignore, hooks,
712
- // and safe-delete removal — removeAllDevFlow has already run.
713
- // applies ADR-003: clean end-state on every path.
714
- p.log.info('.devflow/ preserved (prompt cancelled — continuing cleanup)');
715
- preservedLogged = true;
716
- }
717
- shouldRemoveDevflow = resolveProjectDataCleanup(removeDevflow);
718
- }
719
- if (shouldRemoveDevflow) {
720
- await fs.rm(devflowDataDir, { recursive: true, force: true });
721
- p.log.success('.devflow/ removed');
722
- }
723
- else if (!preservedLogged) {
724
- p.log.info('.devflow/ preserved');
725
- }
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);
726
1053
  }
727
1054
  // 2. .claudeignore
728
1055
  const claudeignorePath = gitRoot
@@ -736,7 +1063,7 @@ export async function runCleanupPhase(opts) {
736
1063
  catch { /* doesn't exist */ }
737
1064
  if (claudeignoreExists) {
738
1065
  if (isTTY) {
739
- const removeClaudeignore = await p.confirm({
1066
+ const removeClaudeignore = await confirm({
740
1067
  message: '.claudeignore found. Remove it? (may contain custom rules)',
741
1068
  initialValue: false,
742
1069
  });
@@ -755,11 +1082,13 @@ export async function runCleanupPhase(opts) {
755
1082
  // 3. settings.json (Devflow hooks)
756
1083
  for (const scope of [...scopesToUninstall]) {
757
1084
  try {
758
- const paths = await getInstallationPaths(scope);
1085
+ const paths = await scopeInstallPaths(scope, gitRoot);
1086
+ if (paths === null)
1087
+ continue;
759
1088
  const settingsPath = path.join(paths.claudeDir, 'settings.json');
760
1089
  const originalContent = await fs.readFile(settingsPath, 'utf-8');
761
1090
  // Remove all Devflow hooks and flags in one pass (idempotent)
762
- let settingsContent = await removeAmbientHook(originalContent);
1091
+ let settingsContent = await removeAmbientHook(originalContent, { purgeLegacyRule: scope === 'user' });
763
1092
  settingsContent = removeMemoryHooks(settingsContent);
764
1093
  settingsContent = removeCaptureHooks(settingsContent);
765
1094
  settingsContent = removeDreamHook(settingsContent);
@@ -781,7 +1110,7 @@ export async function runCleanupPhase(opts) {
781
1110
  settingsContent = JSON.stringify(parsedSettings, null, 2) + '\n';
782
1111
  }
783
1112
  if (settingsContent !== originalContent) {
784
- await fs.writeFile(settingsPath, settingsContent, 'utf-8');
1113
+ await writeSettingsFileAtomic(settingsPath, settingsContent);
785
1114
  if (verbose) {
786
1115
  p.log.success(`Devflow hooks removed from settings.json (${scope})`);
787
1116
  }
@@ -796,6 +1125,10 @@ export async function runCleanupPhase(opts) {
796
1125
  // settings.json doesn't exist or can't be parsed — skip
797
1126
  }
798
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;
799
1132
  // 4. Security deny list
800
1133
  // Detect what's installed
801
1134
  let userSettingsJsonForSecurity = null;
@@ -825,7 +1158,7 @@ export async function runCleanupPhase(opts) {
825
1158
  });
826
1159
  let shouldRemoveSecurity = false;
827
1160
  if (securityDecision === 'prompt') {
828
- const removeDenyConfirm = await p.confirm({
1161
+ const removeDenyConfirm = await confirm({
829
1162
  message: `Remove Devflow security deny list from ${locationLabel}?`,
830
1163
  initialValue: false,
831
1164
  });
@@ -850,7 +1183,7 @@ export async function runCleanupPhase(opts) {
850
1183
  if (detectedSecurity.user && userSettingsJsonForSecurity !== null) {
851
1184
  const { json: stripped, removed } = stripUserDenyList(userSettingsJsonForSecurity, DEVFLOW_HISTORICAL_DENY);
852
1185
  if (removed.length > 0) {
853
- await writeFileAtomicExclusive(userSettingsPathForSecurity, stripped);
1186
+ await writeSettingsFileAtomic(userSettingsPathForSecurity, stripped);
854
1187
  p.log.success(`Security deny list removed from user settings (${removed.length} entries)`);
855
1188
  }
856
1189
  }
@@ -865,7 +1198,7 @@ export async function runCleanupPhase(opts) {
865
1198
  const profilePath = getProfilePath(shell);
866
1199
  if (profilePath && await isAlreadyInstalled(profilePath)) {
867
1200
  if (isTTY) {
868
- const removeSafeDelete = await p.confirm({
1201
+ const removeSafeDelete = await confirm({
869
1202
  message: `Remove safe-delete function from ${profilePath}?`,
870
1203
  initialValue: false,
871
1204
  });
@@ -890,7 +1223,7 @@ export async function runCleanupPhase(opts) {
890
1223
  export const uninstallCommand = new Command('uninstall')
891
1224
  .description('Uninstall Devflow from Claude Code')
892
1225
  .option('--keep-docs', 'Keep .devflow/ directory and project data')
893
- .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)
894
1227
  .option('--plugin <names>', 'Uninstall specific plugin(s), comma-separated (e.g., implement,code-review)')
895
1228
  .option('--verbose', 'Show detailed uninstall output')
896
1229
  .option('--dry-run', 'Show what would be removed without actually removing anything')
@@ -927,24 +1260,27 @@ export const uninstallCommand = new Command('uninstall')
927
1260
  : [];
928
1261
  // Determine which scopes to uninstall
929
1262
  let scopesToUninstall = [];
1263
+ const gitRoot = await getGitRoot();
930
1264
  if (options.scope) {
931
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
+ }
932
1272
  }
933
1273
  else {
934
- const userClaudeDir = getClaudeDirectory();
935
- const gitRoot = await getGitRoot();
936
- if (await isDevFlowInstalled(userClaudeDir)) {
1274
+ if (await isDevFlowInstalled(getClaudeDirectory())) {
937
1275
  scopesToUninstall.push('user');
938
1276
  }
939
- if (gitRoot) {
940
- const localClaudeDir = path.join(gitRoot, '.claude');
941
- if (await isDevFlowInstalled(localClaudeDir)) {
942
- scopesToUninstall.push('local');
943
- }
1277
+ const legacyPaths = gitRoot === null ? null : await legacyLocalInstallPaths(gitRoot);
1278
+ if (legacyPaths !== null && await isDevFlowInstalled(legacyPaths.claudeDir)) {
1279
+ scopesToUninstall.push('local');
944
1280
  }
945
1281
  if (scopesToUninstall.length === 0) {
946
1282
  p.log.error('No Devflow installation found');
947
- 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/)`);
948
1284
  process.exit(1);
949
1285
  }
950
1286
  if (scopesToUninstall.length > 1 && !dryRun) {
@@ -953,7 +1289,7 @@ export const uninstallCommand = new Command('uninstall')
953
1289
  message: 'Found Devflow in multiple scopes. Uninstall from:',
954
1290
  options: [
955
1291
  { value: 'both', label: 'Both', hint: 'user + local' },
956
- { value: 'user', label: 'User scope', hint: '~/.claude/' },
1292
+ { value: 'user', label: 'User scope', hint: `${getClaudeDirectory()}/` },
957
1293
  { value: 'local', label: 'Local scope', hint: 'git-root/.claude/' },
958
1294
  ],
959
1295
  });
@@ -972,7 +1308,19 @@ export const uninstallCommand = new Command('uninstall')
972
1308
  }
973
1309
  // === DRY RUN: show plan and exit ===
974
1310
  if (dryRun) {
975
- await runDryRunPhase({ scopesToUninstall, isSelectiveUninstall, selectedPlugins });
1311
+ // One resolution, handed to the dry-run and (below) to the real removal,
1312
+ // so the preview and the outcome are computed from the same list.
1313
+ let dryRunInstalled = DEVFLOW_PLUGINS;
1314
+ const dryRunPaths = await scopeInstallPaths(scopesToUninstall[0], gitRoot);
1315
+ if (dryRunPaths !== null) {
1316
+ dryRunInstalled = await resolveInstalledPlugins(dryRunPaths.devflowDir);
1317
+ }
1318
+ await runDryRunPhase({
1319
+ scopesToUninstall,
1320
+ isSelectiveUninstall,
1321
+ selectedPlugins,
1322
+ installedPlugins: dryRunInstalled,
1323
+ });
976
1324
  p.outro(color.dim('No changes made (dry run)'));
977
1325
  return;
978
1326
  }
@@ -989,7 +1337,9 @@ export const uninstallCommand = new Command('uninstall')
989
1337
  if (!isSelectiveUninstall) {
990
1338
  for (const scope of scopesToUninstall) {
991
1339
  try {
992
- const paths = await getInstallationPaths(scope);
1340
+ const paths = await scopeInstallPaths(scope, gitRoot);
1341
+ if (paths === null)
1342
+ continue;
993
1343
  if (await proxyJsonExists(paths.devflowDir)) {
994
1344
  const proxyState = await readProxyState(paths.devflowDir);
995
1345
  if (proxyState.ok)
@@ -1001,27 +1351,28 @@ export const uninstallCommand = new Command('uninstall')
1001
1351
  }
1002
1352
  // Uninstall from each scope
1003
1353
  for (const scope of scopesToUninstall) {
1004
- let claudeDir;
1005
- let devflowScriptsDir;
1006
- let devflowDir;
1007
- try {
1008
- const paths = await getInstallationPaths(scope);
1009
- claudeDir = paths.claudeDir;
1010
- devflowDir = paths.devflowDir;
1011
- devflowScriptsDir = path.join(paths.devflowDir, 'scripts');
1012
- if (scope === 'user') {
1013
- p.log.step('Uninstalling user scope (~/.claude/)');
1014
- }
1015
- else {
1016
- p.log.step('Uninstalling local scope (git-root/.claude/)');
1017
- }
1018
- }
1019
- catch (error) {
1020
- 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`);
1021
1357
  continue;
1022
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
+ }
1023
1367
  if (isSelectiveUninstall) {
1024
- await runSelectivePhaseForScope({ claudeDir, devflowDir, selectedPlugins, verbose });
1368
+ await runSelectivePhaseForScope({
1369
+ claudeDir,
1370
+ devflowDir,
1371
+ selectedPlugins,
1372
+ verbose,
1373
+ installedPlugins: await resolveInstalledPlugins(devflowDir),
1374
+ scope,
1375
+ });
1025
1376
  }
1026
1377
  else {
1027
1378
  await runFullPhaseForScope({ scope, claudeDir, devflowDir, devflowScriptsDir, verbose, keepDocs: !!options.keepDocs, isTTY: !!process.stdin.isTTY });
@@ -1161,8 +1512,8 @@ export async function sweepDevflowNamespaces(claudeDir, verbose) {
1161
1512
  * registry (retired agents/commands survive indefinitely without the sweep).
1162
1513
  * For skills: only remove skills that are NOT used by any remaining plugin.
1163
1514
  */
1164
- export async function removeSelectedPlugins(claudeDir, plugins, verbose) {
1165
- const { skills, agents, commands, rules } = computeAssetsToRemove(plugins, DEVFLOW_PLUGINS);
1515
+ export async function removeSelectedPlugins(claudeDir, plugins, verbose, installedPlugins = DEVFLOW_PLUGINS) {
1516
+ const { skills, agents, commands, rules } = computeAssetsToRemove(plugins, installedPlugins);
1166
1517
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1167
1518
  for (const cmd of commands) {
1168
1519
  const cmdFileName = mdFileName(cmd.replace(/^\//, ''));