devflow-kit 3.1.0 → 3.3.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 (138) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +2 -2
  3. package/dist/cli/agents-view/render.js +69 -15
  4. package/dist/cli/agents-view/state.js +40 -14
  5. package/dist/cli/commands/agents.js +135 -45
  6. package/dist/cli/commands/init.js +128 -53
  7. package/dist/cli/commands/learning.js +61 -13
  8. package/dist/cli/commands/memory.js +35 -14
  9. package/dist/cli/commands/uninstall.js +163 -39
  10. package/dist/commands/code-review.md +1 -3
  11. package/dist/commands/debug.md +15 -12
  12. package/dist/commands/dynamic-build.md +172 -135
  13. package/dist/commands/dynamic-plan.md +9 -3
  14. package/dist/commands/explore.md +10 -4
  15. package/dist/commands/implement.md +149 -145
  16. package/dist/commands/plan.md +13 -9
  17. package/dist/commands/release.md +8 -2
  18. package/dist/commands/research.md +8 -2
  19. package/dist/commands/resolve.md +28 -19
  20. package/dist/commands/self-review.md +16 -13
  21. package/dist/core/agent-frontmatter.js +25 -0
  22. package/dist/core/agent-models.js +201 -36
  23. package/dist/core/agent-state.js +27 -5
  24. package/dist/core/assets.js +1 -1
  25. package/dist/core/feature-config.js +68 -10
  26. package/dist/core/flags.js +24 -0
  27. package/dist/core/learning-queue-cleanup.js +10 -11
  28. package/dist/core/learning-tuning-config.js +8 -0
  29. package/dist/core/linked-path.js +46 -0
  30. package/dist/core/plugins.js +16 -5
  31. package/dist/core/queue-drain.js +31 -0
  32. package/dist/hud/components/learning-counts.js +54 -8
  33. package/dist/skills/git/references/tracker/github/create-release.md +2 -2
  34. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +1 -1
  35. package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
  36. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +1 -1
  37. package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
  38. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +1 -1
  39. package/dist/targets/claude-code/installer.js +36 -9
  40. package/dist/targets/claude-code/post-install.js +128 -38
  41. package/package.json +1 -1
  42. package/src/assets/agents/code.md +85 -35
  43. package/src/assets/agents/design.md +12 -0
  44. package/src/assets/agents/diagnose.md +18 -11
  45. package/src/assets/agents/evaluate.md +17 -24
  46. package/src/assets/agents/knowledge.md +7 -3
  47. package/src/assets/agents/learning.md +4 -6
  48. package/src/assets/agents/research.md +21 -0
  49. package/src/assets/agents/review.md +12 -0
  50. package/src/assets/agents/scrutinize.md +37 -9
  51. package/src/assets/agents/simplify.md +24 -0
  52. package/src/assets/agents/skim.md +6 -2
  53. package/src/assets/agents/synthesize.md +18 -0
  54. package/src/assets/agents/test.md +19 -11
  55. package/src/assets/agents/triage.md +8 -0
  56. package/src/assets/agents/validate.md +20 -11
  57. package/src/assets/commands/_partials/_engine.mds +36 -55
  58. package/src/assets/commands/_partials/_knowledge.mds +1 -3
  59. package/src/assets/commands/_partials/_plan_contract.mds +1 -1
  60. package/src/assets/commands/_partials/_tracker.mds +1 -1
  61. package/src/assets/commands/_partials/_wave.mds +8 -6
  62. package/src/assets/commands/code-review.mds +1 -3
  63. package/src/assets/commands/debug.mds +13 -8
  64. package/src/assets/commands/dynamic-build.mds +126 -72
  65. package/src/assets/commands/dynamic-plan.mds +7 -1
  66. package/src/assets/commands/explore.mds +9 -1
  67. package/src/assets/commands/implement.mds +147 -141
  68. package/src/assets/commands/plan.mds +12 -8
  69. package/src/assets/commands/release.md +8 -2
  70. package/src/assets/commands/research.mds +8 -2
  71. package/src/assets/commands/resolve.mds +27 -16
  72. package/src/assets/commands/self-review.mds +15 -10
  73. package/src/assets/mds/tracker/_common.mds +1 -1
  74. package/src/assets/mds/tracker/_github.mds +2 -2
  75. package/src/assets/mds/tracker/_jira.mds +2 -2
  76. package/src/assets/mds/tracker/_linear.mds +2 -2
  77. package/src/assets/scripts/ci-wait.cjs +636 -0
  78. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  79. package/src/assets/scripts/hooks/background-memory-update +356 -17
  80. package/src/assets/scripts/hooks/capture-prompt +4 -3
  81. package/src/assets/scripts/hooks/capture-question +4 -3
  82. package/src/assets/scripts/hooks/capture-turn +4 -3
  83. package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
  84. package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
  85. package/src/assets/scripts/hooks/git-marker +71 -0
  86. package/src/assets/scripts/hooks/json-helper.cjs +12 -145
  87. package/src/assets/scripts/hooks/json-parse +24 -129
  88. package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
  89. package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
  90. package/src/assets/scripts/hooks/memory-worker +10 -0
  91. package/src/assets/scripts/hooks/pre-compact-memory +66 -14
  92. package/src/assets/scripts/hooks/preamble +9 -1
  93. package/src/assets/scripts/hooks/queue-append +53 -21
  94. package/src/assets/scripts/hooks/session-start-context +108 -29
  95. package/src/assets/scripts/hooks/session-start-memory +33 -11
  96. package/src/assets/scripts/release-trace.cjs +27 -10
  97. package/src/assets/skills/accessibility/SKILL.md +1 -1
  98. package/src/assets/skills/apply-decisions/SKILL.md +12 -82
  99. package/src/assets/skills/apply-feature-knowledge/SKILL.md +8 -42
  100. package/src/assets/skills/architecture/SKILL.md +1 -1
  101. package/src/assets/skills/boundary-validation/SKILL.md +1 -1
  102. package/src/assets/skills/complexity/SKILL.md +1 -1
  103. package/src/assets/skills/compliance/SKILL.md +1 -1
  104. package/src/assets/skills/consistency/SKILL.md +1 -1
  105. package/src/assets/skills/database/SKILL.md +1 -1
  106. package/src/assets/skills/dependencies/SKILL.md +1 -1
  107. package/src/assets/skills/dependency-research/SKILL.md +3 -6
  108. package/src/assets/skills/design-review/SKILL.md +1 -1
  109. package/src/assets/skills/docs-framework/SKILL.md +1 -1
  110. package/src/assets/skills/documentation/SKILL.md +1 -1
  111. package/src/assets/skills/gap-analysis/SKILL.md +1 -1
  112. package/src/assets/skills/git/SKILL.md +1 -1
  113. package/src/assets/skills/go/SKILL.md +1 -1
  114. package/src/assets/skills/java/SKILL.md +1 -1
  115. package/src/assets/skills/patterns/SKILL.md +1 -1
  116. package/src/assets/skills/performance/SKILL.md +1 -1
  117. package/src/assets/skills/python/SKILL.md +1 -1
  118. package/src/assets/skills/qa/SKILL.md +1 -3
  119. package/src/assets/skills/quality-gates/SKILL.md +9 -12
  120. package/src/assets/skills/quality-gates/references/report-template.md +20 -20
  121. package/src/assets/skills/react/SKILL.md +1 -1
  122. package/src/assets/skills/regression/SKILL.md +1 -1
  123. package/src/assets/skills/reliability/SKILL.md +1 -1
  124. package/src/assets/skills/research-codebase/SKILL.md +1 -1
  125. package/src/assets/skills/research-competitor/SKILL.md +1 -1
  126. package/src/assets/skills/research-external/SKILL.md +1 -1
  127. package/src/assets/skills/research-technology/SKILL.md +1 -1
  128. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  129. package/src/assets/skills/rust/SKILL.md +1 -1
  130. package/src/assets/skills/security/SKILL.md +1 -1
  131. package/src/assets/skills/software-design/SKILL.md +1 -1
  132. package/src/assets/skills/test-driven-development/SKILL.md +15 -33
  133. package/src/assets/skills/testing/SKILL.md +1 -1
  134. package/src/assets/skills/typescript/SKILL.md +1 -1
  135. package/src/assets/skills/ui-design/SKILL.md +1 -1
  136. package/src/assets/skills/worktree-support/SKILL.md +3 -55
  137. package/src/assets/skills/worktree-support/references/discovery.md +48 -0
  138. package/src/assets/skills/worktree-support/references/roots.md +2 -2
@@ -4,11 +4,14 @@ import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getLearningDir, getLearningTuningConfigPath, } from '../../core/project-paths.js';
7
+ import { loadShippedAgentDefaults } from '../../core/agent-models.js';
7
8
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
8
9
  import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
9
10
  import { getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
10
11
  import { getLedgerRoot } from '../../core/ledger-root.js';
11
12
  import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
13
+ import { firstSymbolicLink } from '../../core/linked-path.js';
14
+ import { formatRefusedDrain } from '../../core/queue-drain.js';
12
15
  import { formatLearningStoreUnavailable, loadLearningStore, } from '../../core/learning-store.js';
13
16
  // ---------------------------------------------------------------------------
14
17
  // Shared helpers
@@ -255,15 +258,50 @@ async function handleRestore(anchor) {
255
258
  }
256
259
  p.log.success(`Restored ${restored.value.anchor_id} (${restored.value.status}); it is due for review again.`);
257
260
  }
261
+ /**
262
+ * The scope choices of `devflow learning --configure`.
263
+ *
264
+ * D-LEARNING-MODEL-PRECEDENCE: the session-start hook takes the first layer that
265
+ * supplies a model — the project file, then a `devflow agents` Learning mapping,
266
+ * then the global file. A global file therefore has no effect for a user who has
267
+ * such a mapping, and its hint says so.
268
+ */
269
+ export const CONFIGURE_SCOPE_OPTIONS = [
270
+ { value: 'project', label: 'Project', hint: 'This project only (.devflow/learning/learning.json)' },
271
+ {
272
+ value: 'global',
273
+ label: 'Global',
274
+ hint: 'All projects (~/.devflow/learning.json); a devflow agents Learning mapping takes precedence over it',
275
+ },
276
+ ];
277
+ /** The models `devflow learning --configure` offers, each with what it is good for. */
278
+ const LEARNING_MODEL_CHOICES = [
279
+ { value: 'opus', label: 'Opus', note: 'highest quality for detection + curation judgment' },
280
+ { value: 'sonnet', label: 'Sonnet', note: 'good balance of quality and speed' },
281
+ { value: 'haiku', label: 'Haiku', note: 'fastest, lowest cost' },
282
+ ];
283
+ /**
284
+ * D-LEARNING-SHIPPED-ROW: the model offered as "Recommended" is the one the Learning
285
+ * agent ships with, the `model:` line of its frontmatter. It is read from there and
286
+ * written nowhere else, so a change of the shipped tier moves the recommendation with
287
+ * it and this list never has to be edited. A shipped model outside the three offered
288
+ * (or none) recommends nothing.
289
+ */
290
+ export function learningModelOptions(shippedModel) {
291
+ return LEARNING_MODEL_CHOICES.map(choice => ({
292
+ value: choice.value,
293
+ label: choice.label,
294
+ hint: choice.value === shippedModel
295
+ ? `Recommended — ${choice.note}`
296
+ : choice.note.charAt(0).toUpperCase() + choice.note.slice(1),
297
+ }));
298
+ }
258
299
  async function handleConfigure() {
259
300
  p.intro(color.bgCyan(color.black(' Learning Configuration ')));
301
+ const shippedModel = (await loadShippedAgentDefaults()).learning?.model;
260
302
  const model = await p.select({
261
303
  message: 'Model for decision detection',
262
- options: [
263
- { value: 'opus', label: 'Opus', hint: 'Recommended — highest quality for detection + curation judgment' },
264
- { value: 'sonnet', label: 'Sonnet', hint: 'Good balance of quality and speed' },
265
- { value: 'haiku', label: 'Haiku', hint: 'Fastest, lowest cost' },
266
- ],
304
+ options: learningModelOptions(shippedModel),
267
305
  });
268
306
  if (p.isCancel(model)) {
269
307
  p.cancel('Configuration cancelled.');
@@ -279,10 +317,7 @@ async function handleConfigure() {
279
317
  }
280
318
  const scope = await p.select({
281
319
  message: 'Configuration scope',
282
- options: [
283
- { value: 'project', label: 'Project', hint: 'This project only (.devflow/learning/learning.json)' },
284
- { value: 'global', label: 'Global', hint: 'All projects (~/.devflow/learning.json)' },
285
- ],
320
+ options: [...CONFIGURE_SCOPE_OPTIONS],
286
321
  });
287
322
  if (p.isCancel(scope)) {
288
323
  p.cancel('Configuration cancelled.');
@@ -304,8 +339,17 @@ async function handleConfigure() {
304
339
  // from the ledger ($LEDGER_ROOT/.devflow/learning/), so write it there — the
305
340
  // main checkout in a linked worktree; the current directory outside git.
306
341
  const projectRoot = (await getLedgerRoot()) ?? process.cwd();
307
- await fs.mkdir(getLearningDir(projectRoot), { recursive: true });
342
+ const learningDir = getLearningDir(projectRoot);
308
343
  const projectConfigPath = getLearningTuningConfigPath(projectRoot);
344
+ // D-CLI-NO-SYMLINK (core/linked-path.ts): nothing is written through a .devflow,
345
+ // a learning folder or a learning.json that is a symbolic link.
346
+ const linked = await firstSymbolicLink([path.dirname(learningDir), learningDir, projectConfigPath]);
347
+ if (linked !== null) {
348
+ p.log.error(`Project config not written: ${linked} is a symbolic link, and devflow writes nothing through one`);
349
+ process.exitCode = 1;
350
+ return;
351
+ }
352
+ await fs.mkdir(learningDir, { recursive: true });
309
353
  await fs.writeFile(projectConfigPath, configJson, 'utf-8');
310
354
  p.log.success(`Project config written to ${color.dim(projectConfigPath)}`);
311
355
  }
@@ -376,9 +420,11 @@ async function handleClear() {
376
420
  }
377
421
  // A mid-run Learning agent whose claimed batch vanishes stops without further
378
422
  // writes — the desired outcome of clearing.
379
- await drainLearningQueue(ledgerRoot);
423
+ const drain = await drainLearningQueue(ledgerRoot);
380
424
  p.log.success(`Cleared ${counted(cleared.value.cleared, 'observation')} no entry uses and kept ${cleared.value.kept} ` +
381
- 'that entries use; drained the learning queue.');
425
+ `that entries use${drain.drained ? '; drained the learning queue.' : '.'}`);
426
+ if (!drain.drained)
427
+ p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
382
428
  }
383
429
  /**
384
430
  * `--enable` / `--disable`: the machine-wide switch (D-FEATURES-NARROW-ONLY),
@@ -403,7 +449,9 @@ async function handleToggle(enabled) {
403
449
  // batch vanishes aborts without changes — the desired outcome of disabling.
404
450
  const ledgerRoot = await getLedgerRoot();
405
451
  if (ledgerRoot) {
406
- await drainLearningQueue(ledgerRoot);
452
+ const drain = await drainLearningQueue(ledgerRoot);
453
+ if (!drain.drained)
454
+ p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
407
455
  }
408
456
  p.log.success('Learning disabled in every project');
409
457
  }
@@ -7,7 +7,9 @@ import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-co
7
7
  import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
8
8
  import { discoverProjectGitRoots } from '../../targets/claude-code/post-install.js';
9
9
  import { getGitRoot } from '../../core/git.js';
10
+ import { firstSymbolicLink } from '../../core/linked-path.js';
10
11
  import { getMemoryDir, getPendingTurnsPath, getPendingTurnsProcessingPath, } from '../../core/project-paths.js';
12
+ import { drainQueueFiles, formatRefusedDrain } from '../../core/queue-drain.js';
11
13
  import { HOOKS_DIR_SUFFIX, devflowHookOwner, endsWithAny, ensureHook, hasHook, removeHooks, runHookCommand, runHookSuffix, } from '../../targets/claude-code/hooks.js';
12
14
  import { readMachineFeature, writeMachineFeature } from '../../core/feature-switch.js';
13
15
  import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning } from '../../core/evidence-policy.js';
@@ -142,15 +144,16 @@ export function convergeMemoryHooks(settingsJson, enabled, devflowDir) {
142
144
  /**
143
145
  * Drain a project's pending memory queue (and a claimed batch) so stale turns
144
146
  * are not processed when memory is next switched on. Shared by `devflow init
145
- * --no-memory` and `devflow memory --disable`. ENOENT-tolerant; any other
146
- * error propagates to the command boundary, like drainLearningQueue.
147
+ * --no-memory` and `devflow memory --disable`. Refused, deleting nothing, when
148
+ * `.devflow` or `.devflow/memory` under `projectRoot` is a symbolic link
149
+ * (D-CLI-NO-SYMLINK). ENOENT-tolerant; any other error propagates to the command
150
+ * boundary, like drainLearningQueue.
147
151
  */
148
152
  export async function drainMemoryQueue(projectRoot) {
149
- const ignoreMissing = (e) => { if (e.code !== 'ENOENT')
150
- throw e; };
151
- await Promise.all([
152
- fs.unlink(getPendingTurnsPath(projectRoot)).catch(ignoreMissing),
153
- fs.unlink(getPendingTurnsProcessingPath(projectRoot)).catch(ignoreMissing),
153
+ const memoryDir = getMemoryDir(projectRoot);
154
+ return drainQueueFiles([path.dirname(memoryDir), memoryDir], [
155
+ getPendingTurnsPath(projectRoot),
156
+ getPendingTurnsProcessingPath(projectRoot),
154
157
  ]);
155
158
  }
156
159
  /**
@@ -181,12 +184,24 @@ export async function filterProjectsWithMemory(gitRoots) {
181
184
  }
182
185
  /**
183
186
  * Clean up memory queue files from the given project paths.
184
- * Skips projects where the background updater lock is held to avoid data loss.
185
- * Returns the count of projects from which at least one file was removed.
187
+ * Skips projects where the background updater lock is held to avoid data loss,
188
+ * and refuses, deleting nothing there, a project whose `.devflow` or
189
+ * `.devflow/memory` is a symbolic link (D-CLI-NO-SYMLINK); `refused` names each
190
+ * such link. Returns the count of projects from which at least one file was removed.
186
191
  */
187
192
  export async function cleanQueueFiles(projectPaths) {
188
193
  const results = await Promise.all(projectPaths.map(async (project) => {
189
194
  const memDir = getMemoryDir(project);
195
+ let linkedFolder;
196
+ try {
197
+ linkedFolder = await firstSymbolicLink([path.dirname(memDir), memDir]);
198
+ }
199
+ catch {
200
+ // The folders cannot be checked: delete nothing there, and go on to the others.
201
+ return null;
202
+ }
203
+ if (linkedFolder !== null)
204
+ return { refused: linkedFolder };
190
205
  const lockDir = path.join(memDir, '.working-memory.lock');
191
206
  try {
192
207
  await fs.access(lockDir);
@@ -200,10 +215,11 @@ export async function cleanQueueFiles(projectPaths) {
200
215
  fs.unlink(getPendingTurnsPath(project)).then(() => true).catch(() => false),
201
216
  fs.unlink(getPendingTurnsProcessingPath(project)).then(() => true).catch(() => false),
202
217
  ]);
203
- return (q || pr) ? project : null;
218
+ return (q || pr) ? { cleaned: project } : null;
204
219
  }));
205
- const cleanedProjects = results.filter((p) => p !== null);
206
- return { cleaned: cleanedProjects.length, projects: cleanedProjects };
220
+ const cleanedProjects = results.flatMap((r) => (r !== null && 'cleaned' in r ? [r.cleaned] : []));
221
+ const refused = results.flatMap((r) => (r !== null && 'refused' in r ? [r.refused] : []));
222
+ return { cleaned: cleanedProjects.length, projects: cleanedProjects, refused };
207
223
  }
208
224
  export const memoryCommand = new Command('memory')
209
225
  .description('Enable, disable, or clean up working memory (session context preservation)')
@@ -263,10 +279,13 @@ export const memoryCommand = new Command('memory')
263
279
  }
264
280
  targets = scope === 'local' && currentProject ? [currentProject] : allProjects;
265
281
  }
266
- const { cleaned, projects: cleanedProjects } = await cleanQueueFiles(targets);
282
+ const { cleaned, projects: cleanedProjects, refused } = await cleanQueueFiles(targets);
267
283
  for (const project of cleanedProjects) {
268
284
  p.log.info(color.dim(`Cleaned: ${project}`));
269
285
  }
286
+ for (const linkedFolder of refused) {
287
+ p.log.warn(formatRefusedDrain('memory', linkedFolder));
288
+ }
270
289
  p.log.success(cleaned > 0
271
290
  ? `Cleaned queue files from ${cleaned} project${cleaned > 1 ? 's' : ''}`
272
291
  : 'No queue files found to clean');
@@ -341,7 +360,9 @@ export const memoryCommand = new Command('memory')
341
360
  // there is no project queue to drain, and the switch itself still applies.
342
361
  const gitRoot = await getGitRoot();
343
362
  if (gitRoot) {
344
- await drainMemoryQueue(gitRoot);
363
+ const drain = await drainMemoryQueue(gitRoot);
364
+ if (!drain.drained)
365
+ p.log.warn(formatRefusedDrain('memory', drain.linkedFolder));
345
366
  }
346
367
  p.log.success('Working memory disabled in every project');
347
368
  });
@@ -29,6 +29,7 @@ import { writeSettingsFileAtomic } from '../../core/fs-atomic.js';
29
29
  import { stripFlags } from '../../core/flags.js';
30
30
  import { stripDevflowTeammateModeFromJson } from '../../core/teammate-mode-cleanup.js';
31
31
  import { getPackageRoot, isContainedIn } from '../../core/paths.js';
32
+ import { firstSymbolicLink } from '../../core/linked-path.js';
32
33
  /**
33
34
  * Where a retired repo-local install lives: `<gitRoot>/.claude` and `<gitRoot>/.devflow`.
34
35
  *
@@ -68,6 +69,77 @@ async function scopeInstallPaths(scope, gitRoot) {
68
69
  return getInstallationPaths();
69
70
  return gitRoot === null ? null : legacyLocalInstallPaths(gitRoot);
70
71
  }
72
+ /**
73
+ * The user scope's guard, which passes everything: `~/.claude` and `~/.devflow` are
74
+ * the user's own, so a symbolic link there is the user's choice.
75
+ */
76
+ const changeAnything = async () => true;
77
+ /**
78
+ * `folder` and every path below it down to `target`, the target included, outermost
79
+ * first, for whichever of the two folders holds `target`; null for a target in
80
+ * neither. PURE.
81
+ */
82
+ function pathsDownTo(folders, target) {
83
+ for (const folder of [folders.claudeDir, folders.devflowDir]) {
84
+ const rel = path.relative(folder, target);
85
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
86
+ continue;
87
+ const chain = [folder];
88
+ for (const part of rel === '' ? [] : rel.split(path.sep)) {
89
+ chain.push(path.join(chain[chain.length - 1], part));
90
+ }
91
+ return chain;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * The guard for a legacy local install's removals and settings rewrites.
97
+ *
98
+ * D-CLI-NO-SYMLINK (firstSymbolicLink): a repository can commit `.claude`, `.devflow`
99
+ * or any folder or file in them as a symbolic link, and enough of `.claude` for a
100
+ * plain `uninstall` to detect a legacy install there. `fs.rm` and the settings
101
+ * rewrite act inside whatever folder a link on the way names, so a target is acted
102
+ * on only where neither `<gitRoot>/.claude` or `<gitRoot>/.devflow` nor anything
103
+ * below it on the way to the target, the target included, is a link. Otherwise the
104
+ * target is skipped, the link is left as it was, and one warning per link, opened
105
+ * by `skipped`, names it. A target that cannot be checked, or lies in neither
106
+ * folder, is skipped and reported the same way. The rest of the install is still
107
+ * removed, so a real, unlinked legacy install comes out whole.
108
+ */
109
+ function legacyLocalChangeGuard(folders, skipped) {
110
+ const reported = new Set();
111
+ const report = (subject, reason) => {
112
+ if (reported.has(subject))
113
+ return;
114
+ reported.add(subject);
115
+ p.log.warn(`${skipped}: ${reason}`);
116
+ };
117
+ return async (target) => {
118
+ const chain = pathsDownTo(folders, target);
119
+ if (chain === null) {
120
+ report(target, `${target} is outside the legacy install`);
121
+ return false;
122
+ }
123
+ let link;
124
+ try {
125
+ link = await firstSymbolicLink(chain);
126
+ }
127
+ catch (error) {
128
+ report(target, `${target} could not be checked for a symbolic link (${error instanceof Error ? error.message : String(error)})`);
129
+ return false;
130
+ }
131
+ if (link === null)
132
+ return true;
133
+ report(link, `${link} is a symbolic link, and devflow removes or changes nothing through one`);
134
+ return false;
135
+ };
136
+ }
137
+ /** The guard for one scope's full or selective phase: checked for a legacy local install, open for the user's. */
138
+ function scopeChangeGuard(scope, folders) {
139
+ return scope === 'local'
140
+ ? legacyLocalChangeGuard(folders, 'Legacy local install not fully removed')
141
+ : changeAnything;
142
+ }
71
143
  /**
72
144
  * The plugins the manifest records as installed, as registry definitions.
73
145
  *
@@ -567,17 +639,22 @@ export function installArtifactPaths(devflowDir) {
567
639
  * agent-models.json is an INSTALL ARTIFACT (stale per-agent overrides silently
568
640
  * re-apply to renamed/deleted agents on reinstall — AC-P1-F4) and therefore
569
641
  * belongs in this list, not in enumerateUserDevFlowContent.
642
+ *
643
+ * Each removal passes `mayChange` first, which skips one a symbolic link would
644
+ * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
570
645
  */
571
- export async function removeDevFlowInstallArtifacts(devflowDir, verbose) {
646
+ export async function removeDevFlowInstallArtifacts(devflowDir, verbose, mayChange = changeAnything) {
572
647
  const manifestPath = path.join(devflowDir, 'manifest.json');
573
- try {
574
- await fs.rm(manifestPath, { force: true });
575
- if (verbose) {
576
- p.log.success('Removed manifest.json');
648
+ if (await mayChange(manifestPath)) {
649
+ try {
650
+ await fs.rm(manifestPath, { force: true });
651
+ if (verbose) {
652
+ p.log.success('Removed manifest.json');
653
+ }
654
+ }
655
+ catch (error) {
656
+ p.log.warn(`Could not remove manifest.json: ${error}`);
577
657
  }
578
- }
579
- catch (error) {
580
- p.log.warn(`Could not remove manifest.json: ${error}`);
581
658
  }
582
659
  // Proxy install artifacts — remove non-fatally (per-item failure isolation)
583
660
  // Inform the user if a proxy relay process is still running (never kill — informational only).
@@ -607,6 +684,8 @@ export async function removeDevFlowInstallArtifacts(devflowDir, verbose) {
607
684
  if (!isContainedIn(devflowDir, artifact.relPath)) {
608
685
  continue;
609
686
  }
687
+ if (!(await mayChange(fullPath)))
688
+ continue;
610
689
  try {
611
690
  await fs.rm(fullPath, { force: true, recursive: artifact.isDir === true });
612
691
  if (verbose)
@@ -812,6 +891,8 @@ export async function runSelectivePhaseForScope(opts) {
812
891
  const { claudeDir, devflowDir, selectedPlugins, verbose } = opts;
813
892
  const scope = opts.scope ?? 'user';
814
893
  const installedPlugins = opts.installedPlugins ?? DEVFLOW_PLUGINS;
894
+ // D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first.
895
+ const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir });
815
896
  // Revert GPT agent frontmatter BEFORE removing agent files — strips GPT model
816
897
  // lines from installed agent frontmatter while the files are still present.
817
898
  // Non-fatal: tolerate missing agents dir or revert errors.
@@ -819,23 +900,25 @@ export async function runSelectivePhaseForScope(opts) {
819
900
  const agentsInstallDir = path.join(claudeDir, 'agents', 'devflow');
820
901
  try {
821
902
  await fs.access(agentsInstallDir);
822
- await revertExternalAgents({
823
- installDir: agentsInstallDir,
824
- devflowDir,
825
- onWarning: (msg) => { if (verbose)
826
- p.log.warn(msg); },
827
- });
903
+ if (await mayChange(agentsInstallDir)) {
904
+ await revertExternalAgents({
905
+ installDir: agentsInstallDir,
906
+ devflowDir,
907
+ onWarning: (msg) => { if (verbose)
908
+ p.log.warn(msg); },
909
+ });
910
+ }
828
911
  }
829
912
  catch { /* agents dir absent or revert failed — non-fatal */ }
830
913
  }
831
- await removeSelectedPlugins(claudeDir, selectedPlugins, verbose, installedPlugins);
914
+ await removeSelectedPlugins(claudeDir, selectedPlugins, verbose, installedPlugins, mayChange);
832
915
  // Clean up ambient hook if ambient plugin is being removed
833
916
  if (selectedPlugins.some(sp => sp.name === 'devflow-ambient')) {
834
917
  const settingsPath = path.join(claudeDir, 'settings.json');
835
918
  try {
836
919
  const settings = await fs.readFile(settingsPath, 'utf-8');
837
920
  const updated = await removeAmbientHook(settings, { purgeLegacyRule: scope === 'user' });
838
- if (updated !== settings) {
921
+ if (updated !== settings && await mayChange(settingsPath)) {
839
922
  await writeSettingsFileAtomic(settingsPath, updated);
840
923
  if (verbose) {
841
924
  p.log.success('Ambient mode hooks removed from settings.json');
@@ -866,6 +949,8 @@ export async function runSelectivePhaseForScope(opts) {
866
949
  */
867
950
  export async function runFullPhaseForScope(opts) {
868
951
  const { scope, claudeDir, devflowDir, devflowScriptsDir, verbose, keepDocs, isTTY } = opts;
952
+ // D-CLI-NO-SYMLINK: a legacy local install's removals and rewrites pass this first.
953
+ const mayChange = scopeChangeGuard(scope, { claudeDir, devflowDir });
869
954
  // Revert GPT agent frontmatter before removing agents — ensures no orphaned
870
955
  // GPT model lines remain if agents dir is preserved by a later partial flow.
871
956
  // Non-fatal: tolerate missing agents dir or revert errors.
@@ -873,24 +958,26 @@ export async function runFullPhaseForScope(opts) {
873
958
  const agentsInstallDir = path.join(claudeDir, 'agents', 'devflow');
874
959
  try {
875
960
  await fs.access(agentsInstallDir);
876
- await revertExternalAgents({
877
- installDir: agentsInstallDir,
878
- devflowDir,
879
- onWarning: (msg) => { if (verbose)
880
- p.log.warn(msg); },
881
- });
961
+ if (await mayChange(agentsInstallDir)) {
962
+ await revertExternalAgents({
963
+ installDir: agentsInstallDir,
964
+ devflowDir,
965
+ onWarning: (msg) => { if (verbose)
966
+ p.log.warn(msg); },
967
+ });
968
+ }
882
969
  }
883
970
  catch { /* agents dir absent or revert failed — non-fatal */ }
884
971
  }
885
972
  // removeAllDevFlow removes Claude Code assets (commands, agents, rules, skills)
886
973
  // and devflowDir/scripts/. Scope-aware cleanup handles the rest of devflowDir.
887
- await removeAllDevFlow(claudeDir, devflowScriptsDir, verbose);
974
+ await removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, mayChange);
888
975
  if (scope === 'local') {
889
976
  // Local scope: devflowDir is gitRoot/.devflow/ which holds project data
890
977
  // (memory, learning, features, docs, config.json). Never remove those —
891
978
  // only remove install artifacts: scripts/ (done above) + manifest.json and
892
979
  // the other Devflow-generated artifacts (see removeDevFlowInstallArtifacts).
893
- await removeDevFlowInstallArtifacts(devflowDir, verbose);
980
+ await removeDevFlowInstallArtifacts(devflowDir, verbose, mayChange);
894
981
  p.log.info('Local project data (memory, learning, features, docs) preserved');
895
982
  }
896
983
  else {
@@ -1109,7 +1196,12 @@ export async function runCleanupPhase(opts) {
1109
1196
  applyProxyTeardownToSettings(parsedSettings, opts.managedProxyPorts?.get(scope));
1110
1197
  settingsContent = JSON.stringify(parsedSettings, null, 2) + '\n';
1111
1198
  }
1112
- if (settingsContent !== originalContent) {
1199
+ // D-CLI-NO-SYMLINK: a legacy local install's settings.json is rewritten only
1200
+ // where no symbolic link leads it out of `<gitRoot>/.claude`.
1201
+ const mayChange = scope === 'local'
1202
+ ? legacyLocalChangeGuard(paths, 'settings.json not edited')
1203
+ : changeAnything;
1204
+ if (settingsContent !== originalContent && await mayChange(settingsPath)) {
1113
1205
  await writeSettingsFileAtomic(settingsPath, settingsContent);
1114
1206
  if (verbose) {
1115
1207
  p.log.success(`Devflow hooks removed from settings.json (${scope})`);
@@ -1391,8 +1483,11 @@ export const uninstallCommand = new Command('uninstall')
1391
1483
  });
1392
1484
  /**
1393
1485
  * Remove all Devflow assets (full uninstall).
1486
+ *
1487
+ * Each removal passes `mayChange` first, which skips one a symbolic link would
1488
+ * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
1394
1489
  */
1395
- export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1490
+ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose, mayChange = changeAnything) {
1396
1491
  const devflowDirectories = [
1397
1492
  { path: path.join(claudeDir, 'commands', 'devflow'), name: 'commands' },
1398
1493
  { path: path.join(claudeDir, 'agents', 'devflow'), name: 'agents' },
@@ -1400,6 +1495,8 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1400
1495
  { path: devflowScriptsDir, name: 'scripts' }
1401
1496
  ];
1402
1497
  for (const dir of devflowDirectories) {
1498
+ if (!(await mayChange(dir.path)))
1499
+ continue;
1403
1500
  try {
1404
1501
  await fs.rm(dir.path, { recursive: true, force: true });
1405
1502
  if (verbose) {
@@ -1430,6 +1527,8 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1430
1527
  const prefixedPath = path.join(skillsDir, prefixSkillName(skillName));
1431
1528
  try {
1432
1529
  await fs.stat(prefixedPath);
1530
+ if (!(await mayChange(prefixedPath)))
1531
+ continue;
1433
1532
  await fs.rm(prefixedPath, { recursive: true, force: true });
1434
1533
  skillsRemoved++;
1435
1534
  }
@@ -1440,6 +1539,8 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1440
1539
  const barePath = path.join(skillsDir, skillName);
1441
1540
  try {
1442
1541
  await fs.stat(barePath);
1542
+ if (!(await mayChange(barePath)))
1543
+ continue;
1443
1544
  await fs.rm(barePath, { recursive: true, force: true });
1444
1545
  skillsRemoved++;
1445
1546
  }
@@ -1449,8 +1550,12 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1449
1550
  p.log.success(`Removed ${skillsRemoved} Devflow skill directories`);
1450
1551
  }
1451
1552
  // Also remove old nested skills structure if it exists
1553
+ const nestedSkillsDir = path.join(claudeDir, 'skills', 'devflow');
1452
1554
  try {
1453
- await fs.rm(path.join(claudeDir, 'skills', 'devflow'), { recursive: true, force: true });
1555
+ await fs.stat(nestedSkillsDir);
1556
+ if (await mayChange(nestedSkillsDir)) {
1557
+ await fs.rm(nestedSkillsDir, { recursive: true, force: true });
1558
+ }
1454
1559
  }
1455
1560
  catch {
1456
1561
  // Old structure doesn't exist
@@ -1459,7 +1564,7 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1459
1564
  // registry (retired, renamed, or deleted). The loop above walks the static
1460
1565
  // registry + LEGACY list; this sweep walks the actual directory, so orphaned
1461
1566
  // dirs from older versions are also cleaned up. (F9)
1462
- await sweepDevflowNamespaces(claudeDir, verbose);
1567
+ await sweepDevflowNamespaces(claudeDir, verbose, mayChange);
1463
1568
  }
1464
1569
  /**
1465
1570
  * Registry-diff sweep of all Devflow-owned namespaces in `claudeDir`:
@@ -1477,18 +1582,24 @@ export async function removeAllDevFlow(claudeDir, devflowScriptsDir, verbose) {
1477
1582
  * Removals are announced only under `verbose`, but removal FAILURES always warn:
1478
1583
  * a swept-but-not-actually-removed agent or command keeps loading in Claude Code,
1479
1584
  * and the user has no other signal that it is still there.
1585
+ *
1586
+ * A folder `mayChange` refuses is not swept at all (D-CLI-NO-SYMLINK,
1587
+ * legacyLocalChangeGuard). An entry in a swept folder that is itself a link is
1588
+ * only unlinked: `fs.rm` never follows the link it is given.
1480
1589
  */
1481
- export async function sweepDevflowNamespaces(claudeDir, verbose) {
1590
+ export async function sweepDevflowNamespaces(claudeDir, verbose, mayChange = changeAnything) {
1482
1591
  const agentsDir = path.join(claudeDir, 'agents', 'devflow');
1483
1592
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1484
1593
  const skillsDir = path.join(claudeDir, 'skills');
1485
- const agentsSweep = await sweepOrphanedAssets(agentsDir, new Set(getAllAgentNames()), mdEntryName);
1486
- const commandsSweep = await sweepOrphanedAssets(commandsDir, new Set(getAllCommandNames()), mdEntryName);
1594
+ const noSweep = { scanned: 0, removed: [], failed: [] };
1595
+ const sweep = async (...args) => (await mayChange(args[0])) ? sweepOrphanedAssets(...args) : noSweep;
1596
+ const agentsSweep = await sweep(agentsDir, new Set(getAllAgentNames()), mdEntryName);
1597
+ const commandsSweep = await sweep(commandsDir, new Set(getAllCommandNames()), mdEntryName);
1487
1598
  // FEATURE_OWNED_SKILLS (compliance) unioned into knownNames so a selective
1488
1599
  // uninstall of another plugin does not sweep devflow:compliance — nothing
1489
1600
  // converges after selective uninstall (only installViaFileCopy + convergeComplianceArtifacts
1490
1601
  // on full/partial init re-materialize it). D-FO-1 applies.
1491
- const skillsSweep = await sweepOrphanedAssets(skillsDir, new Set([...getAllSkillNames(), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null);
1602
+ const skillsSweep = await sweep(skillsDir, new Set([...getAllSkillNames(), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null);
1492
1603
  const byKind = [
1493
1604
  { kind: 'agent', sweep: agentsSweep },
1494
1605
  { kind: 'command', sweep: commandsSweep },
@@ -1511,14 +1622,18 @@ export async function sweepDevflowNamespaces(claudeDir, verbose) {
1511
1622
  * sweep the install directory for any orphaned assets whose names left the
1512
1623
  * registry (retired agents/commands survive indefinitely without the sweep).
1513
1624
  * For skills: only remove skills that are NOT used by any remaining plugin.
1625
+ * Each removal passes `mayChange` first, which skips one a symbolic link would
1626
+ * redirect in a legacy local install (D-CLI-NO-SYMLINK, legacyLocalChangeGuard).
1514
1627
  */
1515
- export async function removeSelectedPlugins(claudeDir, plugins, verbose, installedPlugins = DEVFLOW_PLUGINS) {
1628
+ export async function removeSelectedPlugins(claudeDir, plugins, verbose, installedPlugins = DEVFLOW_PLUGINS, mayChange = changeAnything) {
1516
1629
  const { skills, agents, commands, rules } = computeAssetsToRemove(plugins, installedPlugins);
1517
1630
  const commandsDir = path.join(claudeDir, 'commands', 'devflow');
1518
1631
  for (const cmd of commands) {
1519
- const cmdFileName = mdFileName(cmd.replace(/^\//, ''));
1632
+ const cmdPath = path.join(commandsDir, mdFileName(cmd.replace(/^\//, '')));
1633
+ if (!(await mayChange(cmdPath)))
1634
+ continue;
1520
1635
  try {
1521
- await fs.rm(path.join(commandsDir, cmdFileName), { force: true });
1636
+ await fs.rm(cmdPath, { force: true });
1522
1637
  if (verbose) {
1523
1638
  p.log.success(`Removed command ${cmd}`);
1524
1639
  }
@@ -1529,8 +1644,11 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1529
1644
  }
1530
1645
  const agentsDir = path.join(claudeDir, 'agents', 'devflow');
1531
1646
  for (const agent of agents) {
1647
+ const agentPath = path.join(agentsDir, mdFileName(agent));
1648
+ if (!(await mayChange(agentPath)))
1649
+ continue;
1532
1650
  try {
1533
- await fs.rm(path.join(agentsDir, mdFileName(agent)), { force: true });
1651
+ await fs.rm(agentPath, { force: true });
1534
1652
  if (verbose) {
1535
1653
  p.log.success(`Removed agent ${agent}`);
1536
1654
  }
@@ -1547,8 +1665,11 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1547
1665
  // in init.ts; live-registry skills never had bare installs (the devflow:
1548
1666
  // namespace shipped in dcecda3, 2026-03-30), so a bare dir for a current
1549
1667
  // registry name is by construction foreign.
1668
+ const skillPath = path.join(skillsDir, prefixSkillName(skill));
1669
+ if (!(await mayChange(skillPath)))
1670
+ continue;
1550
1671
  try {
1551
- await fs.rm(path.join(skillsDir, prefixSkillName(skill)), { recursive: true, force: true });
1672
+ await fs.rm(skillPath, { recursive: true, force: true });
1552
1673
  }
1553
1674
  catch { /* Skill might not exist */ }
1554
1675
  if (verbose) {
@@ -1557,8 +1678,11 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1557
1678
  }
1558
1679
  const rulesDir = path.join(claudeDir, 'rules', 'devflow');
1559
1680
  for (const rule of rules) {
1681
+ const rulePath = path.join(rulesDir, mdFileName(rule));
1682
+ if (!(await mayChange(rulePath)))
1683
+ continue;
1560
1684
  try {
1561
- await fs.rm(path.join(rulesDir, mdFileName(rule)), { force: true });
1685
+ await fs.rm(rulePath, { force: true });
1562
1686
  if (verbose) {
1563
1687
  p.log.success(`Removed rule ${rule}`);
1564
1688
  }
@@ -1569,6 +1693,6 @@ export async function removeSelectedPlugins(claudeDir, plugins, verbose, install
1569
1693
  // Removes any orphaned files whose names left the registry (retired, renamed,
1570
1694
  // or deleted from all plugins). Spans ALL plugins so assets belonging to
1571
1695
  // non-selected plugins are never swept.
1572
- await sweepDevflowNamespaces(claudeDir, verbose);
1696
+ await sweepDevflowNamespaces(claudeDir, verbose, mayChange);
1573
1697
  }
1574
1698
  //# sourceMappingURL=uninstall.js.map
@@ -24,7 +24,7 @@ Run a comprehensive code review of the current branch by spawning parallel revie
24
24
 
25
25
  1. **Discover reviewable worktrees** using the `devflow:worktree-support` skill discovery algorithm:
26
26
  - Run `git worktree list --porcelain` → parse, filter (skip protected/detached/mid-rebase), dedup by branch, sort by recent commit
27
- - See the `devflow:worktree-support` skill for the full 7-step algorithm and canonical protected branch list
27
+ - Invoke the `devflow:worktree-support` skill, then Read its `references/discovery.md` from the skill's base directory for the full 7-step algorithm; the canonical protected branch list stays in the skill
28
28
  2. **If `--path` flag provided:** use only that worktree, skip discovery
29
29
  **`--path` validation**: Before proceeding, verify the path exists as a directory and appears in `git worktree list` output. If not: report error and stop.
30
30
  3. **If only 1 reviewable worktree** (the common case): proceed as single-worktree flow — zero behavior change
@@ -228,8 +228,6 @@ Only `exit=0` means the skill is installed. On any other result, do NOT add that
228
228
 
229
229
  **Produces:** DECISIONS_CONTEXT, FEATURE_KNOWLEDGE, COMPLIANCE_FRAMEWORKS (carried from Step 0b)
230
230
 
231
- **Load Companion Skills** — Load via Skill tool: `devflow:quality-gates`, `devflow:software-design`. If a skill fails to load, continue without it.
232
-
233
231
  ### Load DECISIONS_CONTEXT
234
232
 
235
233
  The decisions ledger belongs to the repository, not to one checkout: in a linked worktree it lives in the main worktree, and a session started in a subdirectory reads the copy at the repository root. Locate it with ONE git call, run from the start directory — `WORKTREE_PATH` if provided, otherwise cwd (`devflow:worktree-support`):