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.
- package/CHANGELOG.md +229 -0
- package/README.md +111 -18
- package/dist/agents/git.md +822 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/ambient.js +160 -145
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/capture.js +29 -55
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +48 -55
- package/dist/cli/commands/context.js +17 -32
- package/dist/cli/commands/debug.js +65 -26
- package/dist/cli/commands/flags.js +3 -3
- package/dist/cli/commands/hud.js +34 -10
- package/dist/cli/commands/init-seed.js +61 -27
- package/dist/cli/commands/init.js +649 -240
- package/dist/cli/commands/install-report.js +200 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +35 -37
- package/dist/cli/commands/learning.js +79 -57
- package/dist/cli/commands/legacy-hooks.js +11 -14
- package/dist/cli/commands/memory.js +134 -135
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/proxy.js +23 -41
- package/dist/cli/commands/security.js +81 -29
- package/dist/cli/commands/skills.js +71 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +277 -0
- package/dist/cli/commands/uninstall.js +520 -169
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +58 -14
- package/dist/commands/code-review.md +110 -32
- package/dist/commands/debug.md +55 -11
- package/dist/commands/dynamic-build.md +344 -73
- package/dist/commands/dynamic-plan.md +77 -27
- package/dist/commands/dynamic-profile.md +25 -11
- package/dist/commands/dynamic-tickets.md +76 -15
- package/dist/commands/explore.md +37 -7
- package/dist/commands/implement.md +314 -62
- package/dist/commands/plan.md +146 -32
- package/dist/commands/release.md +64 -17
- package/dist/commands/research.md +34 -8
- package/dist/commands/resolve.md +196 -68
- package/dist/commands/self-review.md +45 -9
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/compliance-compose.js +27 -27
- package/dist/core/evidence-policy.js +363 -0
- package/dist/core/feature-config.js +200 -65
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +34 -6
- package/dist/core/fs-atomic.js +27 -0
- package/dist/core/hook-log-dirs.js +104 -0
- package/dist/core/learning-tuning-config.js +5 -3
- package/dist/core/ledger-root.js +102 -0
- package/dist/core/manifest.js +38 -10
- package/dist/core/mds-variants.js +798 -0
- package/dist/core/migrations.js +49 -23
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +361 -12
- package/dist/core/project-paths.js +1 -18
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/same-location.js +25 -0
- package/dist/core/tracker.js +494 -0
- package/dist/hud/components/config-counts.js +15 -4
- package/dist/hud/components/learning-counts.js +14 -0
- package/dist/hud/config.js +2 -1
- package/dist/hud/cost-history.js +2 -4
- package/dist/hud/git.js +52 -7
- package/dist/hud/index.js +7 -9
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/claude-paths.js +59 -57
- package/dist/targets/claude-code/compliance-install.js +49 -65
- package/dist/targets/claude-code/hooks.js +108 -3
- package/dist/targets/claude-code/installer.js +1187 -32
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +366 -151
- package/dist/targets/claude-code/tracker-install.js +134 -0
- package/package.json +8 -6
- package/src/assets/agents/code.md +45 -6
- package/src/assets/agents/design.md +2 -1
- package/src/assets/agents/git.mds +825 -0
- package/src/assets/agents/knowledge.md +3 -3
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/review.md +3 -1
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +474 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_compliance.mds +19 -1
- package/src/assets/commands/_partials/_decisions.mds +15 -3
- package/src/assets/commands/_partials/_docs_root.mds +35 -0
- package/src/assets/commands/_partials/_engine.mds +13 -11
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_factory.mds +1 -1
- package/src/assets/commands/_partials/_knowledge.mds +27 -9
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +2 -2
- package/src/assets/commands/_partials/_publication.mds +8 -2
- package/src/assets/commands/_partials/_settings.mds +28 -0
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +31 -19
- package/src/assets/commands/code-review.mds +67 -41
- package/src/assets/commands/debug.mds +13 -7
- package/src/assets/commands/dynamic-build.mds +274 -66
- package/src/assets/commands/dynamic-plan.mds +50 -23
- package/src/assets/commands/dynamic-profile.mds +24 -11
- package/src/assets/commands/dynamic-tickets.mds +63 -16
- package/src/assets/commands/explore.mds +4 -5
- package/src/assets/commands/implement.mds +234 -67
- package/src/assets/commands/plan.mds +91 -33
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/research.mds +11 -9
- package/src/assets/commands/resolve.mds +150 -78
- package/src/assets/commands/self-review.mds +24 -25
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +305 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +40 -19
- package/src/assets/scripts/hooks/capture-prompt +18 -8
- package/src/assets/scripts/hooks/capture-question +18 -8
- package/src/assets/scripts/hooks/capture-turn +27 -13
- package/src/assets/scripts/hooks/debug-trace +11 -6
- package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
- package/src/assets/scripts/hooks/ensure-proxy +9 -8
- package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
- package/src/assets/scripts/hooks/git-marker +48 -0
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +228 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
- package/src/assets/scripts/hooks/log-paths +80 -0
- package/src/assets/scripts/hooks/memory-worker +22 -13
- package/src/assets/scripts/hooks/pre-compact-memory +44 -15
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +146 -28
- package/src/assets/scripts/hooks/resolve-project-root +101 -7
- package/src/assets/scripts/hooks/session-start-context +534 -20
- package/src/assets/scripts/hooks/session-start-memory +38 -15
- package/src/assets/scripts/lib/project-config.cjs +633 -0
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
- package/src/assets/scripts/resolve-settings.cjs +1054 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +4 -2
- package/src/assets/skills/docs-framework/SKILL.md +11 -10
- package/src/assets/skills/docs-framework/references/patterns.md +10 -17
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- package/src/assets/skills/worktree-support/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +29 -0
- package/src/targets/claude-code/templates/managed-settings.json +25 -9
- 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 {
|
|
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 {
|
|
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,
|
|
109
|
+
export function computeAssetsToRemove(selectedPlugins, installedPlugins) {
|
|
35
110
|
const selectedNames = new Set(selectedPlugins.map(p => p.name));
|
|
36
|
-
const remainingPlugins =
|
|
37
|
-
const retainedSkills =
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
187
|
-
* full cleanup of the directory.
|
|
353
|
+
* Single source of truth for user-authored content under `devflowDir`.
|
|
188
354
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
256
|
-
*
|
|
257
|
-
*
|
|
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,
|
|
299
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
485
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
678
|
-
*
|
|
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.
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
935
|
-
const gitRoot = await getGitRoot();
|
|
936
|
-
if (await isDevFlowInstalled(userClaudeDir)) {
|
|
1274
|
+
if (await isDevFlowInstalled(getClaudeDirectory())) {
|
|
937
1275
|
scopesToUninstall.push('user');
|
|
938
1276
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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({
|
|
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,
|
|
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(/^\//, ''));
|