devflow-kit 3.9.0 → 3.10.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 (157) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/compliance-prompts.js +9 -4
  9. package/dist/cli/commands/compliance.js +171 -24
  10. package/dist/cli/commands/curation.js +12 -7
  11. package/dist/cli/commands/gate-level-prompts.js +39 -25
  12. package/dist/cli/commands/gates.js +5 -7
  13. package/dist/cli/commands/init.js +142 -138
  14. package/dist/cli/commands/install-report.js +34 -0
  15. package/dist/cli/commands/legacy-sweep.js +64 -0
  16. package/dist/cli/commands/proxy.js +6 -1
  17. package/dist/cli/commands/rules.js +29 -12
  18. package/dist/cli/commands/skills.js +42 -9
  19. package/dist/cli/commands/tracker.js +9 -3
  20. package/dist/cli/commands/uninstall.js +277 -51
  21. package/dist/commands/bug-analysis.md +69 -120
  22. package/dist/commands/dynamic-build.md +23 -23
  23. package/dist/commands/implement.md +46 -32
  24. package/dist/commands/release.md +1 -1
  25. package/dist/commands/resolve.md +35 -31
  26. package/dist/commands/self-review.md +8 -7
  27. package/dist/compliance-off/agents/git.md +120 -0
  28. package/dist/compliance-off/agents/triage.md +180 -0
  29. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  30. package/dist/compliance-off/commands/code-review.md +520 -0
  31. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  32. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  33. package/dist/compliance-off/commands/implement.md +736 -0
  34. package/dist/compliance-off/commands/plan.md +671 -0
  35. package/dist/compliance-off/commands/release.md +309 -0
  36. package/dist/compliance-off/commands/resolve.md +742 -0
  37. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  38. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  39. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  41. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  42. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  43. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  45. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  52. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  59. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  66. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  67. package/dist/core/agent-models.js +19 -4
  68. package/dist/core/assets.js +126 -17
  69. package/dist/core/compliance-off-files.js +27 -0
  70. package/dist/core/curation-variants.js +460 -112
  71. package/dist/core/flags.js +2 -2
  72. package/dist/core/installed-variants.js +63 -0
  73. package/dist/core/manifest.js +48 -5
  74. package/dist/core/mds-variants.js +700 -3
  75. package/dist/core/orphan-sweep.js +11 -2
  76. package/dist/core/plugins.js +65 -8
  77. package/dist/curation-compliance-off/agents/triage.md +176 -0
  78. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  79. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  80. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  81. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  82. package/dist/curation-compliance-off/commands/implement.md +689 -0
  83. package/dist/curation-compliance-off/commands/plan.md +631 -0
  84. package/dist/curation-compliance-off/commands/release.md +279 -0
  85. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  86. package/dist/curation-off/agents/code.md +6 -6
  87. package/dist/curation-off/agents/fix.md +6 -6
  88. package/dist/curation-off/agents/harden.md +7 -6
  89. package/dist/curation-off/agents/refactor.md +6 -6
  90. package/dist/curation-off/commands/bug-analysis.md +69 -119
  91. package/dist/curation-off/commands/dynamic-build.md +23 -23
  92. package/dist/curation-off/commands/implement.md +46 -32
  93. package/dist/curation-off/commands/release.md +1 -1
  94. package/dist/curation-off/commands/resolve.md +35 -31
  95. package/dist/curation-off/commands/self-review.md +8 -7
  96. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  97. package/dist/targets/claude-code/behavior-install.js +23 -9
  98. package/dist/targets/claude-code/charter-render.js +374 -0
  99. package/dist/targets/claude-code/compliance-install.js +32 -12
  100. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  101. package/dist/targets/claude-code/curation-install.js +570 -100
  102. package/dist/targets/claude-code/installer.js +467 -62
  103. package/dist/targets/claude-code/language-stamp.js +6 -0
  104. package/dist/targets/claude-code/linked-folder.js +182 -0
  105. package/dist/targets/claude-code/reference-converge.js +118 -0
  106. package/dist/targets/claude-code/tracker-install.js +21 -8
  107. package/dist/targets/claude-code/tracker-servers.js +20 -6
  108. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  109. package/dist/variant-axes.json +109 -0
  110. package/package.json +1 -1
  111. package/src/assets/agents/_partials/_code.mds +6 -6
  112. package/src/assets/agents/evaluate.md +1 -1
  113. package/src/assets/agents/git.mds +32 -0
  114. package/src/assets/agents/harden.mds +7 -6
  115. package/src/assets/agents/test.md +21 -12
  116. package/src/assets/agents/triage.mds +2 -0
  117. package/src/assets/agents/validate.md +10 -10
  118. package/src/assets/commands/_partials/_compliance.mds +5 -3
  119. package/src/assets/commands/_partials/_engine.mds +9 -8
  120. package/src/assets/commands/_partials/_publication.mds +10 -1
  121. package/src/assets/commands/bug-analysis.mds +80 -126
  122. package/src/assets/commands/code-review.mds +43 -0
  123. package/src/assets/commands/dynamic-build.mds +109 -15
  124. package/src/assets/commands/dynamic-tickets.mds +13 -0
  125. package/src/assets/commands/implement.mds +145 -33
  126. package/src/assets/commands/plan.mds +19 -0
  127. package/src/assets/commands/release.mds +50 -1
  128. package/src/assets/commands/resolve.mds +217 -32
  129. package/src/assets/commands/self-review.mds +8 -7
  130. package/src/assets/mds/git/_ops.mds +23 -0
  131. package/src/assets/mds/git/_ops_steps.mds +17 -0
  132. package/src/assets/mds/git/_pr.mds +1 -1
  133. package/src/assets/mds/git/_references.mds +6 -0
  134. package/src/assets/mds/tracker/_common.mds +4 -0
  135. package/src/assets/mds/tracker/_github.mds +42 -0
  136. package/src/assets/mds/tracker/_jira.mds +43 -0
  137. package/src/assets/mds/tracker/_linear.mds +43 -0
  138. package/src/assets/rules/quality.md +2 -0
  139. package/src/assets/scripts/ci-wait.cjs +204 -126
  140. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  141. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  142. package/src/assets/scripts/hooks/preamble +18 -8
  143. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  144. package/src/assets/scripts/pr-evidence.cjs +41 -2
  145. package/src/assets/scripts/verify-evidence.cjs +408 -56
  146. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  147. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  148. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  149. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  150. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  151. package/src/assets/skills/git/SKILL.md +3 -3
  152. package/src/assets/skills/git/references/github-api.md +2 -139
  153. package/src/assets/skills/git/references/github-posting.md +158 -0
  154. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  155. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  156. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  157. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -7,8 +7,8 @@ import color from 'picocolors';
7
7
  import { getInstallationPaths, getClaudeDirectory, getHomeDirectory, getManagedSettingsPath } from '../../targets/claude-code/claude-paths.js';
8
8
  import { getGitRoot } from '../../core/git.js';
9
9
  import { isSameLocation } from '../../core/same-location.js';
10
- import { DEVFLOW_PLUGINS, SKILL_NAMESPACE, buildAssetMaps, getAllSkillNames, getAllAgentNames, getAllCommandNames, parsePluginSelection, resolveFeatureRedirect, prefixSkillName, unprefixSkillName, skillsOf, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
11
- import { readManifest, removeManifestPlugins } from '../../core/manifest.js';
10
+ import { DEVFLOW_PLUGINS, SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, registeredPluginNames, buildAssetMaps, getAllSkillNames, getAllAgentNames, getAllCommandNames, parsePluginSelection, resolveFeatureRedirect, prefixSkillName, unprefixSkillName, skillsOf, selectedWorkflowFolders, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
11
+ import { readManifest, readManifestPlugins, removeManifestPlugins } from '../../core/manifest.js';
12
12
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
13
13
  import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
14
14
  import { removeAmbientHook } from './ambient.js';
@@ -35,7 +35,10 @@ import { MANIFEST_LOCK_NAME, MANIFEST_LOCK_STALE_MARK } from '../../core/manifes
35
35
  import { CURATION_DIR_NAME, CURATION_TUNING_FILE_NAME, LEGACY_NAMES, MOVE_JOURNAL_SUFFIX, MOVE_LOCK_PREFIX, MOVE_LOCK_SUFFIX, getMemoryDir } from '../../core/project-paths.js';
36
36
  import { probeTrackedArea } from '../../core/tracked-areas.js';
37
37
  import { restampInstalledCommands } from '../../targets/claude-code/language-stamp.js';
38
+ import { installRenderedCharter, removeHandoffSentinel } from '../../targets/claude-code/charter-render.js';
38
39
  import { installCommandsAndAgents } from '../../targets/claude-code/installer.js';
40
+ import { isUnrealFolder, newLinkReport, refuseUnrealFolder, refusedFolderStatement } from '../../targets/claude-code/linked-folder.js';
41
+ import { formatReplacedLinksSummary } from './install-report.js';
39
42
  /**
40
43
  * Where a retired repo-local install lives: `<gitRoot>/.claude` and `<gitRoot>/.devflow`.
41
44
  *
@@ -76,10 +79,121 @@ async function scopeInstallPaths(scope, gitRoot) {
76
79
  return gitRoot === null ? null : legacyLocalInstallPaths(gitRoot);
77
80
  }
78
81
  /**
79
- * The user scope's guard, which passes everything: `~/.claude` and `~/.devflow` are
80
- * the user's own, so a symbolic link there is the user's choice.
82
+ * The user scope's guard for a path that is not inside a Devflow install folder, and for the removal
83
+ * of a whole entry. It passes everything: `~/.claude` and `~/.devflow` and their other folders, the
84
+ * manifest and `settings.json` are the user's own, so a symbolic link there is followed. `fs.rm` of an
85
+ * entry that is a link removes the link and never what it points to, so a full uninstall removes a
86
+ * linked Devflow folder as it removes a real one.
87
+ *
88
+ * A read, write or removal inside a Devflow install folder never uses this guard: it uses
89
+ * {@link devflowFolderGuard}, which refuses a link.
90
+ */
91
+ const changeUserOwned = async () => true;
92
+ /** The folders directly under `~/.claude` that hold a `devflow` folder of Devflow's own. */
93
+ const DEVFLOW_FILE_FOLDERS = ['commands', 'agents', 'rules'];
94
+ /**
95
+ * The Devflow install folder that holds `target` and every folder below it down to the parent of
96
+ * `target`, outermost first; null for a target outside every Devflow install folder. A target that is
97
+ * itself a Devflow install folder (a `skills/devflow:*` folder) gives that folder alone. PURE.
98
+ *
99
+ * The Devflow install folders are `commands/devflow`, `agents/devflow`, `rules/devflow` and each
100
+ * `skills/devflow:*` folder. `~/.claude`, `~/.claude/skills` and the other folders above them are
101
+ * not among them.
81
102
  */
82
- const changeAnything = async () => true;
103
+ function devflowFolderChain(claudeDir, target) {
104
+ const rel = path.relative(claudeDir, target);
105
+ if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
106
+ return null;
107
+ const parts = rel.split(path.sep);
108
+ const inFileFolder = DEVFLOW_FILE_FOLDERS.includes(parts[0]) && parts[1] === 'devflow';
109
+ const inSkillFolder = parts[0] === 'skills' && parts[1] !== undefined && parts[1].startsWith(SKILL_NAMESPACE);
110
+ if (!inFileFolder && !inSkillFolder)
111
+ return null;
112
+ const chain = [path.join(claudeDir, parts[0], parts[1])];
113
+ for (let depth = 2; depth < parts.length - 1; depth++) {
114
+ chain.push(path.join(chain[chain.length - 1], parts[depth]));
115
+ }
116
+ return chain;
117
+ }
118
+ /**
119
+ * The guard for a change inside the Devflow install folders of `claudeDir`.
120
+ *
121
+ * Devflow never reads, writes or removes through a link at or below its own install folders. A
122
+ * target is let through only when each Devflow folder on the way to it is a real folder or absent;
123
+ * a folder that is a link, or not a folder, refuses the target. The refusal is recorded once in
124
+ * `report.refusedFolders` and the link is left as it was. A target outside every Devflow install
125
+ * folder passes, and a link to a file that is the target itself is not followed (`fs.rm` removes
126
+ * the link). Never throws.
127
+ */
128
+ function devflowFolderGuard(claudeDir, report) {
129
+ return async (target) => {
130
+ const chain = devflowFolderChain(claudeDir, target);
131
+ if (chain === null)
132
+ return true;
133
+ for (const folder of chain) {
134
+ if (report.refusedFolders.includes(folder))
135
+ return false;
136
+ if (await refuseUnrealFolder(report, folder))
137
+ return false;
138
+ }
139
+ return true;
140
+ };
141
+ }
142
+ /**
143
+ * Turn the refused-folder half of an uninstall into summary lines, one per folder.
144
+ *
145
+ * An uninstall removes and changes nothing at a path that is a link, or not a folder where it needs
146
+ * one, and leaves the entry as it was. That looks the same on disk as an uninstall that found nothing
147
+ * to remove, so the line says what was skipped and what to do about it.
148
+ *
149
+ * Pure function — returns lines, logs nothing.
150
+ */
151
+ export function formatUninstallRefusedSummary(report) {
152
+ return report.refusedFolders.map(folder => ({
153
+ level: 'warn',
154
+ message: `${refusedFolderStatement(folder)} Devflow removed and changed nothing there. ` +
155
+ `Remove the link or replace it with a real folder, then run the uninstall again.`,
156
+ }));
157
+ }
158
+ /** How many folders an uninstall left as they are, in a sentence that agrees with the count. */
159
+ function leftAsTheyAre(count) {
160
+ return count === 1 ? '1 folder was left as it is' : `${count} folders were left as they are`;
161
+ }
162
+ /**
163
+ * The line for one scope's removal. With no refused folder the plugin is removed. With any, the
164
+ * removal is partial: the line says so and counts the folders left as they are, because a refused
165
+ * folder looks the same on disk as a folder that had nothing to remove.
166
+ *
167
+ * @param pluginLabel - Empty, or the selected plugin names as ` (a, b)`.
168
+ *
169
+ * Pure function — returns the line, logs nothing.
170
+ */
171
+ export function formatPluginRemovedLine(pluginLabel, refusedCount) {
172
+ if (refusedCount === 0)
173
+ return { level: 'success', message: `Plugin removed${pluginLabel}` };
174
+ return { level: 'warn', message: `Plugin removed in part${pluginLabel}: ${leftAsTheyAre(refusedCount)}.` };
175
+ }
176
+ /**
177
+ * The last line of an uninstall. It says success only when no folder was refused in any scope;
178
+ * otherwise it says the uninstall was done in part and counts the folders left as they are.
179
+ *
180
+ * Pure function — returns the line, logs nothing.
181
+ */
182
+ export function formatUninstallOutro(refusedCount) {
183
+ if (refusedCount === 0) {
184
+ return `${color.green('Devflow uninstalled successfully')}${color.dim(' Reinstall: npx devflow-kit init')}`;
185
+ }
186
+ return color.yellow(`Devflow uninstalled in part: ${leftAsTheyAre(refusedCount)}.`);
187
+ }
188
+ /** Print the links an uninstall replaced or refused, one line each, once per path. */
189
+ function logLinkReport(report) {
190
+ const lines = [
191
+ ...formatReplacedLinksSummary({ replacedLinks: [...new Set(report.replacedLinks)] }),
192
+ ...formatUninstallRefusedSummary({ refusedFolders: [...new Set(report.refusedFolders)] }),
193
+ ];
194
+ for (const line of lines)
195
+ p.log[line.level](line.message);
196
+ }
83
197
  /**
84
198
  * `folder` and every path below it down to `target`, the target included, outermost
85
199
  * first, for whichever of the two folders holds `target`; null for a target in
@@ -140,11 +254,17 @@ function legacyLocalChangeGuard(folders, skipped) {
140
254
  return false;
141
255
  };
142
256
  }
143
- /** The guard for one scope's full or selective phase: checked for a legacy local install, open for the user's. */
144
- function scopeChangeGuard(scope, folders) {
145
- return scope === 'local'
146
- ? legacyLocalChangeGuard(folders, 'Legacy local install not fully removed')
147
- : changeAnything;
257
+ /**
258
+ * The guard for one scope's full or selective phase.
259
+ *
260
+ * A legacy local install is checked for a link anywhere on the way to a target. In the user scope the
261
+ * full phase removes whole entries and passes ({@link changeUserOwned}). The selective phase gives
262
+ * its `links` report and gets {@link devflowFolderGuard}, which refuses a linked Devflow folder.
263
+ */
264
+ function scopeChangeGuard(scope, folders, links) {
265
+ if (scope === 'local')
266
+ return legacyLocalChangeGuard(folders, 'Legacy local install not fully removed');
267
+ return links === undefined ? changeUserOwned : devflowFolderGuard(folders.claudeDir, links);
148
268
  }
149
269
  /**
150
270
  * The plugins the manifest records as installed, as registry definitions.
@@ -164,6 +284,24 @@ export async function resolveInstalledPlugins(devflowDir) {
164
284
  const resolved = DEVFLOW_PLUGINS.filter(plugin => names.has(plugin.name));
165
285
  return resolved.length === 0 ? DEVFLOW_PLUGINS : resolved;
166
286
  }
287
+ /**
288
+ * The plugin names the orchestrator charter is re-rendered for after `uninstall --plugin`, or null when there
289
+ * is no manifest record to render from. Pure.
290
+ *
291
+ * D-CHARTER-INSTALL-RENDER: the set is the manifest record (`readManifestPlugins`) minus the plugins being
292
+ * removed, with a legacy name mapped and only names this registry has kept ({@link registeredPluginNames}).
293
+ * It is deliberately NOT {@link resolveInstalledPlugins}: that falls back to the whole registry when the
294
+ * manifest is missing or names nothing, which is the safe direction for retaining assets, and the wrong one
295
+ * here. The registry would render a charter that routes to every workflow on a machine that installed none
296
+ * of them. With no record the caller leaves the installed charter as it is and takes only the sentinel away.
297
+ * A record that lists no plugin is a record of nothing installed, so it yields the empty set rather than null.
298
+ */
299
+ export function charterPluginsAfterRemoval(recordedPlugins, removedPluginNames) {
300
+ if (recordedPlugins === null)
301
+ return null;
302
+ const removed = new Set(removedPluginNames);
303
+ return registeredPluginNames(recordedPlugins).filter(name => !removed.has(name));
304
+ }
167
305
  /**
168
306
  * Compute which assets should be removed during selective plugin uninstall.
169
307
  * Skills and agents shared by remaining plugins are retained.
@@ -183,8 +321,11 @@ export async function resolveInstalledPlugins(devflowDir) {
183
321
  * registry only when there is no manifest to read, and the dry-run and the
184
322
  * real removal must always be given the SAME list or they describe different
185
323
  * outcomes.
324
+ * @param folderWorkflows - The target-layer set (D-WORKFLOW-FOLDER-INSTALL). A folder
325
+ * workflow of a selected plugin is a skill to remove, unless a remaining plugin
326
+ * owns it too, and never a command: it has no command file.
186
327
  */
187
- export function computeAssetsToRemove(selectedPlugins, installedPlugins) {
328
+ export function computeAssetsToRemove(selectedPlugins, installedPlugins, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
188
329
  const selectedNames = new Set(selectedPlugins.map(p => p.name));
189
330
  const remainingPlugins = installedPlugins.filter(p => !selectedNames.has(p.name));
190
331
  const retainedSkills = skillsOf(remainingPlugins);
@@ -204,6 +345,11 @@ export function computeAssetsToRemove(selectedPlugins, installedPlugins) {
204
345
  if (!retainedSkills.has(skill))
205
346
  skills.push(skill);
206
347
  }
348
+ const retainedFolders = selectedWorkflowFolders(remainingPlugins, folderWorkflows);
349
+ for (const name of selectedWorkflowFolders(selectedPlugins, folderWorkflows)) {
350
+ if (!retainedFolders.has(name))
351
+ skills.push(name);
352
+ }
207
353
  for (const plugin of selectedPlugins) {
208
354
  for (const agent of plugin.agents) {
209
355
  if (!retainedAgents.has(agent))
@@ -213,7 +359,7 @@ export function computeAssetsToRemove(selectedPlugins, installedPlugins) {
213
359
  if (!retainedRules.has(rule))
214
360
  rules.push(rule);
215
361
  }
216
- commands.push(...plugin.commands);
362
+ commands.push(...plugin.commands.filter(cmd => !folderWorkflows.has(cmd.replace(/^\//, ''))));
217
363
  }
218
364
  return { skills, agents, commands, rules };
219
365
  }
@@ -785,7 +931,7 @@ export function installArtifactPaths(devflowDir) {
785
931
  * Each removal passes `mayChange` first, which skips one a symbolic link would
786
932
  * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
787
933
  */
788
- export async function removeDevFlowInstallArtifacts(devflowDir, verbose, mayChange = changeAnything) {
934
+ export async function removeDevFlowInstallArtifacts(devflowDir, verbose, mayChange = changeUserOwned) {
789
935
  const manifestPath = path.join(devflowDir, 'manifest.json');
790
936
  if (await mayChange(manifestPath)) {
791
937
  try {
@@ -893,7 +1039,7 @@ export async function isDevFlowInstalled(claudeDir) {
893
1039
  * @param claudeDir - Target Claude Code directory (e.g. ~/.claude)
894
1040
  * @param devflowDir - Devflow data directory (e.g. ~/.devflow)
895
1041
  */
896
- export async function enumerateDryRunExtras(claudeDir, devflowDir) {
1042
+ export async function enumerateDryRunExtras(claudeDir, devflowDir, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
897
1043
  const extras = [];
898
1044
  // 1. Whole directories removed by removeAllDevFlow
899
1045
  for (const dir of [
@@ -915,7 +1061,8 @@ export async function enumerateDryRunExtras(claudeDir, devflowDir) {
915
1061
  // dirs are by construction foreign to Devflow.
916
1062
  // FEATURE_OWNED_SKILLS (compliance) is included so full uninstall removes
917
1063
  // devflow:compliance even though it left the plugin registry (step 1.5).
918
- const prefixedSkillNames = new Set([...getAllSkillNames(), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
1064
+ // The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) join through getAllSkillNames.
1065
+ const prefixedSkillNames = new Set([...getAllSkillNames(folderWorkflows), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
919
1066
  const skillsDir = path.join(claudeDir, 'skills');
920
1067
  for (const skillName of prefixedSkillNames) {
921
1068
  const prefixedPath = path.join(skillsDir, prefixSkillName(skillName));
@@ -1036,13 +1183,17 @@ export async function runDryRunPhase(opts) {
1036
1183
  * settings.json, so the notice tells the user to run `devflow init` for those.
1037
1184
  *
1038
1185
  * Never runs in a legacy local scope (D-CLI-NO-SYMLINK): a repository can commit a command
1039
- * file as a symbolic link, and the copy writes through it. The folder guard below does not
1040
- * check files. `devflow init` can not update a legacy local install either, so the warning
1041
- * tells the user to remove it in full.
1186
+ * file as a symbolic link, and the copy writes through it. `devflow init` can not update a
1187
+ * legacy local install either, so the warning tells the user to remove it in full.
1188
+ *
1189
+ * In the user scope the copy writes into neither folder when either is a link or not a folder,
1190
+ * whatever the caller's guard says: writing only one folder would pair old commands with new
1191
+ * agents. The refused folder is recorded in `links`. A command or agent file that is a link is
1192
+ * replaced by a real file and recorded in `links` too; what it pointed to is left as it was.
1042
1193
  *
1043
1194
  * Skipped, with a warning, when there is no readable manifest or it lists no known plugin (the
1044
- * retained set would be a guess), or when a folder is behind a link the caller's guard
1045
- * refuses. Those warnings name `devflow init` as the remedy. When the copy fails, it stops
1195
+ * retained set would be a guess), or when a folder is a link, not a folder, or behind a link the
1196
+ * caller's guard refuses. Those warnings name `devflow init` as the remedy. When the copy fails, it stops
1046
1197
  * at the first failure: files it wrote before then stay, and the warning names the same
1047
1198
  * remedy. Never throws: an uninstall must finish.
1048
1199
  * Agent model lines are reverted again after the copy, as the selective phase does before
@@ -1053,7 +1204,8 @@ export async function refreshRetainedCommandsAndAgents(opts) {
1053
1204
  const { claudeDir, devflowDir, retained, retiredAgents } = opts;
1054
1205
  const scope = opts.scope ?? 'user';
1055
1206
  const verbose = opts.verbose ?? false;
1056
- const mayChange = opts.mayChange ?? changeAnything;
1207
+ const mayChange = opts.mayChange ?? changeUserOwned;
1208
+ const links = opts.links ?? newLinkReport();
1057
1209
  const agentsDir = path.join(claudeDir, 'agents', 'devflow');
1058
1210
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1059
1211
  const retired = `Removed the agent file(s) of an older Devflow release: ${retiredAgents.join(', ')}.`;
@@ -1080,7 +1232,14 @@ export async function refreshRetainedCommandsAndAgents(opts) {
1080
1232
  p.log.warn(`${retired} The manifest lists no known plugin, so Devflow did not rewrite the remaining files. ${remedy}`);
1081
1233
  return;
1082
1234
  }
1083
- if (!(await mayChange(agentsDir)) || !(await mayChange(commandsDir))) {
1235
+ let blocked = !(await mayChange(agentsDir)) || !(await mayChange(commandsDir));
1236
+ if (!blocked) {
1237
+ // The caller's guard may pass everything. Neither folder is written when it is a link or not a folder.
1238
+ const agentsRefused = await refuseUnrealFolder(links, agentsDir);
1239
+ const commandsRefused = await refuseUnrealFolder(links, commandsDir);
1240
+ blocked = agentsRefused || commandsRefused;
1241
+ }
1242
+ if (blocked) {
1084
1243
  p.log.warn(`${retired} A linked folder blocks the rewrite. ${remedy}`);
1085
1244
  return;
1086
1245
  }
@@ -1090,7 +1249,9 @@ export async function refreshRetainedCommandsAndAgents(opts) {
1090
1249
  agentsMap: buildAssetMaps(retained).agentsMap,
1091
1250
  claudeDir,
1092
1251
  curation: manifest.features.curation,
1252
+ compliance: manifest.features.compliance.enabled,
1093
1253
  trackerServers: effectiveTrackerServers(manifest.features.tracker),
1254
+ links,
1094
1255
  warn: (msg) => p.log.warn(msg),
1095
1256
  });
1096
1257
  }
@@ -1105,6 +1266,30 @@ export async function refreshRetainedCommandsAndAgents(opts) {
1105
1266
  catch { /* the revert is best effort, as in the selective phase */ }
1106
1267
  p.log.info(`${retired} Devflow wrote the current commands and agents of the plugins that stay installed. Run \`devflow init\` to update the hooks and scripts.`);
1107
1268
  }
1269
+ /**
1270
+ * Re-render the installed orchestrator charter for the plugins that stay after a selective removal, so it
1271
+ * stops routing to a workflow whose skill was just removed, and take the implement sentinel away when
1272
+ * implement is among the removed.
1273
+ *
1274
+ * D-CHARTER-INSTALL-RENDER: the set comes from the MANIFEST record, read without a heal so the file stays
1275
+ * byte-identical, which still lists the removed plugins until the caller drops the record, never from the
1276
+ * registry fallback of {@link resolveInstalledPlugins}. The record is read only where the change guard lets
1277
+ * a change through. With no usable record there is nothing to render from and the installed charter stays
1278
+ * as it is, but the sentinel needs no record and goes when implement is among the removed. An uninstall
1279
+ * never creates a file, so a machine whose charter is not installed gets none. A failure is a warning,
1280
+ * never a failure of the uninstall.
1281
+ */
1282
+ async function rerenderCharterAfterRemoval(devflowDir, removedPluginNames, mayChange) {
1283
+ const scriptsDir = path.join(devflowDir, 'scripts');
1284
+ const warn = (msg) => p.log.warn(msg);
1285
+ const recorded = (await mayChange(path.join(devflowDir, 'manifest.json'))) ? await readManifestPlugins(devflowDir) : null;
1286
+ const retained = charterPluginsAfterRemoval(recorded, removedPluginNames);
1287
+ if (retained === null) {
1288
+ await removeHandoffSentinel({ scriptsDir, removedPlugins: removedPluginNames, warn, mayChange });
1289
+ return;
1290
+ }
1291
+ await installRenderedCharter({ scriptsDir, plugins: retained, warn, mayChange, mode: 'uninstall' });
1292
+ }
1108
1293
  /**
1109
1294
  * SELECTIVE PHASE (per scope): revert agent model overrides, remove the
1110
1295
  * selected plugins' assets, and clean up the ambient hook if the ambient
@@ -1114,13 +1299,19 @@ export async function refreshRetainedCommandsAndAgents(opts) {
1114
1299
  * @param opts.devflowDir - Devflow data directory for this scope.
1115
1300
  * @param opts.selectedPlugins - Plugin subset to remove.
1116
1301
  * @param opts.verbose - Whether to emit verbose log lines.
1302
+ *
1303
+ * Nothing is read, written or removed through a link at or below a Devflow install folder. A
1304
+ * Devflow folder that is a link, or not a folder, is left as it was; the phase prints one line for
1305
+ * each and returns them, with the file links it replaced, in the {@link LinkReport}.
1117
1306
  */
1118
1307
  export async function runSelectivePhaseForScope(opts) {
1119
1308
  const { claudeDir, devflowDir, selectedPlugins, verbose } = opts;
1120
1309
  const scope = opts.scope ?? 'user';
1121
1310
  const installedPlugins = opts.installedPlugins ?? DEVFLOW_PLUGINS;
1122
- // D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first.
1123
- const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir });
1311
+ const links = newLinkReport();
1312
+ // D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first. In the user
1313
+ // scope the guard refuses a linked Devflow folder and records it in `links`.
1314
+ const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir }, links);
1124
1315
  // Revert GPT agent frontmatter BEFORE removing agent files — strips GPT model
1125
1316
  // lines from installed agent frontmatter while the files are still present.
1126
1317
  // Non-fatal: tolerate missing agents dir or revert errors.
@@ -1139,7 +1330,9 @@ export async function runSelectivePhaseForScope(opts) {
1139
1330
  }
1140
1331
  catch { /* agents dir absent or revert failed — non-fatal */ }
1141
1332
  }
1142
- const sweeps = await removeSelectedPlugins(claudeDir, selectedPlugins, verbose, installedPlugins, mayChange);
1333
+ // In the user scope the removal builds its own guard over the same report, which also checks each
1334
+ // orphan it sweeps; a legacy local install keeps the guard above.
1335
+ const sweeps = await removeSelectedPlugins(claudeDir, selectedPlugins, verbose, installedPlugins, scope === 'local' ? mayChange : undefined, links);
1143
1336
  // A retired agent file means the install is from an older release, and the commands of the
1144
1337
  // plugins that stay installed still spawn the name that was just removed. Write their
1145
1338
  // current commands and agents so the install stays whole. Runs before the language stamp
@@ -1154,6 +1347,7 @@ export async function runSelectivePhaseForScope(opts) {
1154
1347
  scope,
1155
1348
  verbose,
1156
1349
  mayChange,
1350
+ links,
1157
1351
  });
1158
1352
  }
1159
1353
  // D-LANGUAGE-FOCUS-STAMP: re-stamp the installed /code-review from the selection that REMAINS.
@@ -1172,6 +1366,9 @@ export async function runSelectivePhaseForScope(opts) {
1172
1366
  mayChange,
1173
1367
  });
1174
1368
  }
1369
+ // D-CHARTER-INSTALL-RENDER: the charter stops routing to the removed workflows. It reads the manifest
1370
+ // record, so it runs before the record is dropped below.
1371
+ await rerenderCharterAfterRemoval(devflowDir, selectedPlugins.map(sp => sp.name), mayChange);
1175
1372
  // D-UNINSTALL-DROPS-PLUGIN: the manifest is the install record `devflow init` seeds its plugin
1176
1373
  // selection from (a re-init keeps the prior selection), so a plugin removed here but still listed
1177
1374
  // would be installed again by the next plain init. Take the selected names off the list;
@@ -1204,6 +1401,8 @@ export async function runSelectivePhaseForScope(opts) {
1204
1401
  }
1205
1402
  catch { /* settings.json may not exist */ }
1206
1403
  }
1404
+ logLinkReport(links);
1405
+ return links;
1207
1406
  }
1208
1407
  /**
1209
1408
  * FULL PHASE (per scope): revert agent model overrides, remove all Devflow
@@ -1231,11 +1430,13 @@ export async function runFullPhaseForScope(opts) {
1231
1430
  // Revert GPT agent frontmatter before removing agents — ensures no orphaned
1232
1431
  // GPT model lines remain if agents dir is preserved by a later partial flow.
1233
1432
  // Non-fatal: tolerate missing agents dir or revert errors.
1433
+ // The revert rewrites the files in the folder, so a folder that is a link is not reverted: the
1434
+ // removal below takes the link away whole and the files it led to stay as they are.
1234
1435
  {
1235
1436
  const agentsInstallDir = path.join(claudeDir, 'agents', 'devflow');
1236
1437
  try {
1237
1438
  await fs.access(agentsInstallDir);
1238
- if (await mayChange(agentsInstallDir)) {
1439
+ if (await mayChange(agentsInstallDir) && !(await isUnrealFolder(agentsInstallDir))) {
1239
1440
  await revertExternalAgents({
1240
1441
  installDir: agentsInstallDir,
1241
1442
  devflowDir,
@@ -1481,7 +1682,7 @@ export async function runCleanupPhase(opts) {
1481
1682
  // where no symbolic link leads it out of `<gitRoot>/.claude`.
1482
1683
  const mayChange = scope === 'local'
1483
1684
  ? legacyLocalChangeGuard(paths, 'settings.json not edited')
1484
- : changeAnything;
1685
+ : changeUserOwned;
1485
1686
  if (settingsContent !== originalContent && await mayChange(settingsPath)) {
1486
1687
  await writeSettingsFileAtomic(settingsPath, settingsContent);
1487
1688
  if (verbose) {
@@ -1722,7 +1923,10 @@ export const uninstallCommand = new Command('uninstall')
1722
1923
  catch { /* non-fatal */ }
1723
1924
  }
1724
1925
  }
1725
- // Uninstall from each scope
1926
+ // Uninstall from each scope. The folders a selective uninstall refused are counted across the
1927
+ // scopes, so the last line can say the removal was done in part. A refusal is reported, not a
1928
+ // failure: the exit status stays 0, as it does for a legacy install whose links were skipped.
1929
+ const refusedFolders = new Set();
1726
1930
  for (const scope of scopesToUninstall) {
1727
1931
  const paths = await scopeInstallPaths(scope, gitRoot);
1728
1932
  if (paths === null) {
@@ -1737,8 +1941,9 @@ export const uninstallCommand = new Command('uninstall')
1737
1941
  else {
1738
1942
  p.log.step('Uninstalling legacy local scope (git-root/.claude/)');
1739
1943
  }
1944
+ const scopeRefused = new Set();
1740
1945
  if (isSelectiveUninstall) {
1741
- await runSelectivePhaseForScope({
1946
+ const links = await runSelectivePhaseForScope({
1742
1947
  claudeDir,
1743
1948
  devflowDir,
1744
1949
  selectedPlugins,
@@ -1746,6 +1951,10 @@ export const uninstallCommand = new Command('uninstall')
1746
1951
  installedPlugins: await resolveInstalledPlugins(devflowDir),
1747
1952
  scope,
1748
1953
  });
1954
+ for (const folder of links.refusedFolders) {
1955
+ scopeRefused.add(folder);
1956
+ refusedFolders.add(folder);
1957
+ }
1749
1958
  }
1750
1959
  else {
1751
1960
  await runFullPhaseForScope({ scope, claudeDir, devflowDir, devflowScriptsDir, verbose, keepDocs: !!options.keepDocs, isTTY: !!process.stdin.isTTY });
@@ -1753,14 +1962,14 @@ export const uninstallCommand = new Command('uninstall')
1753
1962
  const pluginLabel = isSelectiveUninstall
1754
1963
  ? ` (${selectedPluginNames.join(', ')})`
1755
1964
  : '';
1756
- p.log.success(`Plugin removed${pluginLabel}`);
1965
+ const removed = formatPluginRemovedLine(pluginLabel, scopeRefused.size);
1966
+ p.log[removed.level](removed.message);
1757
1967
  }
1758
1968
  // === CLEANUP EXTRAS (only for full uninstall) ===
1759
1969
  if (!isSelectiveUninstall) {
1760
1970
  await runCleanupPhase({ scopesToUninstall, keepDocs: !!options.keepDocs, verbose, cwd: process.cwd(), isTTY: !!process.stdin.isTTY, managedProxyPorts });
1761
1971
  }
1762
- const status = color.green('Devflow uninstalled successfully');
1763
- p.outro(`${status}${color.dim(' Reinstall: npx devflow-kit init')}`);
1972
+ p.outro(formatUninstallOutro(refusedFolders.size));
1764
1973
  });
1765
1974
  /**
1766
1975
  * Remove all Devflow assets (full uninstall).
@@ -1768,7 +1977,7 @@ export const uninstallCommand = new Command('uninstall')
1768
1977
  * Each removal passes `mayChange` first, which skips one a symbolic link would
1769
1978
  * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
1770
1979
  */
1771
- export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, mayChange = changeAnything) {
1980
+ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, mayChange = changeUserOwned, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
1772
1981
  const devflowDirectories = [
1773
1982
  { path: path.join(claudeDir, 'commands', 'devflow'), name: 'commands' },
1774
1983
  { path: path.join(claudeDir, 'agents', 'devflow'), name: 'agents' },
@@ -1800,7 +2009,8 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
1800
2009
  // A bare dir whose name matches a live-registry skill is by construction
1801
2010
  // foreign to Devflow (the devflow: namespace shipped in dcecda3, 2026-03-30).
1802
2011
  // A bare 'compliance/' dir is also foreign (not in LEGACY_SKILL_NAMES).
1803
- const prefixedSkillNames = new Set([...getAllSkillNames(), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
2012
+ // The workflow skill folders (D-WORKFLOW-FOLDER-INSTALL) are in getAllSkillNames, so the folder is removed.
2013
+ const prefixedSkillNames = new Set([...getAllSkillNames(folderWorkflows), ...LEGACY_SKILL_NAMES, ...FEATURE_OWNED_SKILLS]);
1804
2014
  const skillsDir = path.join(claudeDir, 'skills');
1805
2015
  let skillsRemoved = 0;
1806
2016
  for (const skillName of prefixedSkillNames) {
@@ -1845,7 +2055,7 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
1845
2055
  // registry (retired, renamed, or deleted). The loop above walks the static
1846
2056
  // registry + LEGACY list; this sweep walks the actual directory, so orphaned
1847
2057
  // dirs from older versions are also cleaned up. (F9)
1848
- await sweepDevflowNamespaces(claudeDir, verbose, mayChange);
2058
+ await sweepDevflowNamespaces(claudeDir, verbose, mayChange, undefined, folderWorkflows);
1849
2059
  }
1850
2060
  /**
1851
2061
  * Registry-diff sweep of all Devflow-owned namespaces in `claudeDir`:
@@ -1865,25 +2075,38 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, ma
1865
2075
  * and the user has no other signal that it is still there.
1866
2076
  *
1867
2077
  * A folder `mayChange` refuses is not swept at all (D-CLI-NO-SYMLINK,
1868
- * legacyLocalChangeGuard). An entry in a swept folder that is itself a link is
1869
- * only unlinked: `fs.rm` never follows the link it is given.
2078
+ * legacyLocalChangeGuard). The agents and commands folders are swept only when each is a real folder
2079
+ * or absent, whatever `mayChange` says, because the sweep reads and removes inside whatever the
2080
+ * folder names; a folder that is a link or not a folder is recorded in `links` and left as it was.
2081
+ *
2082
+ * With no `mayChange`, the sweep uses the Devflow folder guard, which also refuses each orphan that
2083
+ * is a link (a linked `skills/devflow:*` folder): the orphan stays and is recorded in `links`. With a
2084
+ * `mayChange` given, an orphan that is itself a link is only unlinked: `fs.rm` never follows the
2085
+ * link it is given.
1870
2086
  *
1871
2087
  * Returns what each sweep removed and failed to remove, so a caller can tell that
1872
2088
  * a retired agent was removed (see {@link refreshRetainedCommandsAndAgents}).
1873
2089
  */
1874
- export async function sweepDevflowNamespaces(claudeDir, verbose, mayChange = changeAnything) {
2090
+ export async function sweepDevflowNamespaces(claudeDir, verbose, mayChange, links = { refusedFolders: [] }, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
1875
2091
  const agentsDir = path.join(claudeDir, 'agents', 'devflow');
1876
2092
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1877
2093
  const skillsDir = path.join(claudeDir, 'skills');
1878
2094
  const noSweep = { scanned: 0, removed: [], failed: [] };
1879
- const sweep = async (...args) => (await mayChange(args[0])) ? sweepOrphanedAssets(...args) : noSweep;
1880
- const agentsSweep = await sweep(agentsDir, new Set(getAllAgentNames()), mdEntryName);
1881
- const commandsSweep = await sweep(commandsDir, new Set(getAllCommandNames()), mdEntryName);
2095
+ const guard = mayChange ?? devflowFolderGuard(claudeDir, links);
2096
+ const mayRemove = mayChange === undefined ? guard : undefined;
2097
+ const sweep = async (...args) => (await guard(args[0])) ? sweepOrphanedAssets(args[0], args[1], args[2], mayRemove) : noSweep;
2098
+ const sweepDevflowFolder = async (...args) => (await guard(args[0])) && !(await refuseUnrealFolder(links, args[0]))
2099
+ ? sweepOrphanedAssets(args[0], args[1], args[2], mayRemove)
2100
+ : noSweep;
2101
+ const agentsSweep = await sweepDevflowFolder(agentsDir, new Set(getAllAgentNames()), mdEntryName);
2102
+ const commandsSweep = await sweepDevflowFolder(commandsDir, new Set(getAllCommandNames(folderWorkflows)), mdEntryName);
1882
2103
  // FEATURE_OWNED_SKILLS (compliance) unioned into knownNames so a selective
1883
2104
  // uninstall of another plugin does not sweep devflow:compliance — nothing
1884
2105
  // converges after selective uninstall (only installViaFileCopy + convergeComplianceArtifacts
1885
2106
  // on full/partial init re-materialize it). D-FO-1 applies.
1886
- const skillsSweep = await sweep(skillsDir, new Set([...getAllSkillNames(), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null);
2107
+ // D-WORKFLOW-FOLDER-INSTALL: a workflow skill folder is a known skill, so a selective uninstall of
2108
+ // another plugin keeps it, and a stale command file of a folder workflow is an orphan here.
2109
+ const skillsSweep = await sweep(skillsDir, new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null);
1887
2110
  const byKind = [
1888
2111
  { kind: 'agent', sweep: agentsSweep },
1889
2112
  { kind: 'command', sweep: commandsSweep },
@@ -1907,17 +2130,20 @@ export async function sweepDevflowNamespaces(claudeDir, verbose, mayChange = cha
1907
2130
  * sweep the install directory for any orphaned assets whose names left the
1908
2131
  * registry (retired agents/commands survive indefinitely without the sweep).
1909
2132
  * For skills: only remove skills that are NOT used by any remaining plugin.
1910
- * Each removal passes `mayChange` first, which skips one a symbolic link would
1911
- * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
2133
+ * Each removal passes the guard first. A legacy local install passes `mayChange`
2134
+ * (D-CLI-NO-SYMLINK, legacyLocalChangeGuard). With none given, the removal uses the
2135
+ * Devflow folder guard: nothing is removed in or from a Devflow folder that is a link or
2136
+ * not a folder, and each such folder is recorded in `links` and left as it was.
1912
2137
  *
1913
2138
  * Returns what the closing registry-diff sweep removed.
1914
2139
  */
1915
- export async function removeSelectedPlugins(claudeDir, plugins, verbose, installedPlugins = DEVFLOW_PLUGINS, mayChange = changeAnything) {
1916
- const { skills, agents, commands, rules } = computeAssetsToRemove(plugins, installedPlugins);
2140
+ export async function removeSelectedPlugins(claudeDir, plugins, verbose, installedPlugins = DEVFLOW_PLUGINS, mayChange, links = { refusedFolders: [] }, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
2141
+ const { skills, agents, commands, rules } = computeAssetsToRemove(plugins, installedPlugins, folderWorkflows);
2142
+ const guard = mayChange ?? devflowFolderGuard(claudeDir, links);
1917
2143
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1918
2144
  for (const cmd of commands) {
1919
2145
  const cmdPath = path.join(commandsDir, mdFileName(cmd.replace(/^\//, '')));
1920
- if (!(await mayChange(cmdPath)))
2146
+ if (!(await guard(cmdPath)))
1921
2147
  continue;
1922
2148
  try {
1923
2149
  await fs.rm(cmdPath, { force: true });
@@ -1932,7 +2158,7 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1932
2158
  const agentsDir = path.join(claudeDir, 'agents', 'devflow');
1933
2159
  for (const agent of agents) {
1934
2160
  const agentPath = path.join(agentsDir, mdFileName(agent));
1935
- if (!(await mayChange(agentPath)))
2161
+ if (!(await guard(agentPath)))
1936
2162
  continue;
1937
2163
  try {
1938
2164
  await fs.rm(agentPath, { force: true });
@@ -1953,7 +2179,7 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1953
2179
  // namespace shipped in dcecda3, 2026-03-30), so a bare dir for a current
1954
2180
  // registry name is by construction foreign.
1955
2181
  const skillPath = path.join(skillsDir, prefixSkillName(skill));
1956
- if (!(await mayChange(skillPath)))
2182
+ if (!(await guard(skillPath)))
1957
2183
  continue;
1958
2184
  try {
1959
2185
  await fs.rm(skillPath, { recursive: true, force: true });
@@ -1966,7 +2192,7 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1966
2192
  const rulesDir = path.join(claudeDir, 'rules', 'devflow');
1967
2193
  for (const rule of rules) {
1968
2194
  const rulePath = path.join(rulesDir, mdFileName(rule));
1969
- if (!(await mayChange(rulePath)))
2195
+ if (!(await guard(rulePath)))
1970
2196
  continue;
1971
2197
  try {
1972
2198
  await fs.rm(rulePath, { force: true });
@@ -1980,6 +2206,6 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1980
2206
  // Removes any orphaned files whose names left the registry (retired, renamed,
1981
2207
  // or deleted from all plugins). Spans ALL plugins so assets belonging to
1982
2208
  // non-selected plugins are never swept.
1983
- return sweepDevflowNamespaces(claudeDir, verbose, mayChange);
2209
+ return sweepDevflowNamespaces(claudeDir, verbose, mayChange, links, folderWorkflows);
1984
2210
  }
1985
2211
  //# sourceMappingURL=uninstall.js.map