devflow-kit 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/dist/cli/agents-view/render.js +69 -15
- package/dist/cli/agents-view/state.js +40 -14
- package/dist/cli/commands/agents.js +135 -45
- package/dist/cli/commands/init.js +128 -53
- package/dist/cli/commands/learning.js +36 -8
- package/dist/cli/commands/memory.js +35 -14
- package/dist/cli/commands/uninstall.js +163 -39
- package/dist/commands/code-review.md +0 -2
- package/dist/commands/debug.md +14 -11
- package/dist/commands/dynamic-build.md +33 -43
- package/dist/commands/dynamic-plan.md +8 -2
- package/dist/commands/explore.md +9 -3
- package/dist/commands/implement.md +20 -16
- package/dist/commands/plan.md +13 -9
- package/dist/commands/release.md +8 -2
- package/dist/commands/research.md +8 -2
- package/dist/commands/resolve.md +1 -3
- package/dist/commands/self-review.md +0 -2
- package/dist/core/agent-frontmatter.js +25 -0
- package/dist/core/agent-models.js +198 -36
- package/dist/core/agent-state.js +27 -5
- package/dist/core/assets.js +1 -1
- package/dist/core/feature-config.js +68 -10
- package/dist/core/flags.js +24 -0
- package/dist/core/learning-queue-cleanup.js +10 -11
- package/dist/core/linked-path.js +46 -0
- package/dist/core/plugins.js +9 -3
- package/dist/core/queue-drain.js +31 -0
- package/dist/hud/components/learning-counts.js +54 -8
- package/dist/skills/git/references/tracker/github/create-release.md +2 -2
- package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
- package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
- package/dist/targets/claude-code/installer.js +36 -9
- package/dist/targets/claude-code/post-install.js +128 -38
- package/package.json +1 -1
- package/src/assets/agents/code.md +14 -17
- package/src/assets/agents/design.md +2 -0
- package/src/assets/agents/diagnose.md +2 -0
- package/src/assets/agents/evaluate.md +4 -0
- package/src/assets/agents/knowledge.md +2 -0
- package/src/assets/agents/research.md +2 -0
- package/src/assets/agents/review.md +2 -0
- package/src/assets/agents/scrutinize.md +4 -0
- package/src/assets/agents/simplify.md +4 -0
- package/src/assets/agents/skim.md +3 -1
- package/src/assets/agents/synthesize.md +6 -0
- package/src/assets/agents/test.md +18 -10
- package/src/assets/agents/triage.md +2 -0
- package/src/assets/agents/validate.md +14 -10
- package/src/assets/commands/_partials/_engine.mds +15 -31
- package/src/assets/commands/_partials/_knowledge.mds +0 -2
- package/src/assets/commands/_partials/_tracker.mds +1 -1
- package/src/assets/commands/code-review.mds +0 -2
- package/src/assets/commands/debug.mds +13 -8
- package/src/assets/commands/dynamic-build.mds +17 -11
- package/src/assets/commands/dynamic-plan.mds +7 -1
- package/src/assets/commands/explore.mds +9 -1
- package/src/assets/commands/implement.mds +19 -13
- package/src/assets/commands/plan.mds +12 -8
- package/src/assets/commands/release.md +8 -2
- package/src/assets/commands/research.mds +8 -2
- package/src/assets/commands/resolve.mds +1 -1
- package/src/assets/mds/tracker/_github.mds +2 -2
- package/src/assets/mds/tracker/_jira.mds +2 -2
- package/src/assets/mds/tracker/_linear.mds +2 -2
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +3 -2
- package/src/assets/scripts/hooks/background-memory-update +69 -11
- package/src/assets/scripts/hooks/capture-prompt +4 -3
- package/src/assets/scripts/hooks/capture-question +4 -3
- package/src/assets/scripts/hooks/capture-turn +4 -3
- package/src/assets/scripts/hooks/ensure-devflow-init +13 -1
- package/src/assets/scripts/hooks/ensure-root-gitignore +122 -10
- package/src/assets/scripts/hooks/git-marker +71 -0
- package/src/assets/scripts/hooks/json-helper.cjs +12 -145
- package/src/assets/scripts/hooks/json-parse +24 -129
- package/src/assets/scripts/hooks/lib/learning-store.cjs +169 -64
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +1 -1
- package/src/assets/scripts/hooks/memory-worker +10 -0
- package/src/assets/scripts/hooks/pre-compact-memory +66 -14
- package/src/assets/scripts/hooks/preamble +9 -1
- package/src/assets/scripts/hooks/queue-append +53 -21
- package/src/assets/scripts/hooks/session-start-context +108 -29
- package/src/assets/scripts/hooks/session-start-memory +33 -11
- package/src/assets/skills/test-driven-development/SKILL.md +6 -4
|
@@ -9,6 +9,8 @@ import { loadSettingsModule, narrowedSwitchLabel, personalConfigTrackedWarning }
|
|
|
9
9
|
import { getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
|
|
10
10
|
import { getLedgerRoot } from '../../core/ledger-root.js';
|
|
11
11
|
import { drainLearningQueue } from '../../core/learning-queue-cleanup.js';
|
|
12
|
+
import { firstSymbolicLink } from '../../core/linked-path.js';
|
|
13
|
+
import { formatRefusedDrain } from '../../core/queue-drain.js';
|
|
12
14
|
import { formatLearningStoreUnavailable, loadLearningStore, } from '../../core/learning-store.js';
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
14
16
|
// Shared helpers
|
|
@@ -255,6 +257,22 @@ async function handleRestore(anchor) {
|
|
|
255
257
|
}
|
|
256
258
|
p.log.success(`Restored ${restored.value.anchor_id} (${restored.value.status}); it is due for review again.`);
|
|
257
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* The scope choices of `devflow learning --configure`.
|
|
262
|
+
*
|
|
263
|
+
* D-LEARNING-MODEL-PRECEDENCE: the session-start hook takes the first layer that
|
|
264
|
+
* supplies a model — the project file, then a `devflow agents` Learning mapping,
|
|
265
|
+
* then the global file. A global file therefore has no effect for a user who has
|
|
266
|
+
* such a mapping, and its hint says so.
|
|
267
|
+
*/
|
|
268
|
+
export const CONFIGURE_SCOPE_OPTIONS = [
|
|
269
|
+
{ value: 'project', label: 'Project', hint: 'This project only (.devflow/learning/learning.json)' },
|
|
270
|
+
{
|
|
271
|
+
value: 'global',
|
|
272
|
+
label: 'Global',
|
|
273
|
+
hint: 'All projects (~/.devflow/learning.json); a devflow agents Learning mapping takes precedence over it',
|
|
274
|
+
},
|
|
275
|
+
];
|
|
258
276
|
async function handleConfigure() {
|
|
259
277
|
p.intro(color.bgCyan(color.black(' Learning Configuration ')));
|
|
260
278
|
const model = await p.select({
|
|
@@ -279,10 +297,7 @@ async function handleConfigure() {
|
|
|
279
297
|
}
|
|
280
298
|
const scope = await p.select({
|
|
281
299
|
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
|
-
],
|
|
300
|
+
options: [...CONFIGURE_SCOPE_OPTIONS],
|
|
286
301
|
});
|
|
287
302
|
if (p.isCancel(scope)) {
|
|
288
303
|
p.cancel('Configuration cancelled.');
|
|
@@ -304,8 +319,17 @@ async function handleConfigure() {
|
|
|
304
319
|
// from the ledger ($LEDGER_ROOT/.devflow/learning/), so write it there — the
|
|
305
320
|
// main checkout in a linked worktree; the current directory outside git.
|
|
306
321
|
const projectRoot = (await getLedgerRoot()) ?? process.cwd();
|
|
307
|
-
|
|
322
|
+
const learningDir = getLearningDir(projectRoot);
|
|
308
323
|
const projectConfigPath = getLearningTuningConfigPath(projectRoot);
|
|
324
|
+
// D-CLI-NO-SYMLINK (core/linked-path.ts): nothing is written through a .devflow,
|
|
325
|
+
// a learning folder or a learning.json that is a symbolic link.
|
|
326
|
+
const linked = await firstSymbolicLink([path.dirname(learningDir), learningDir, projectConfigPath]);
|
|
327
|
+
if (linked !== null) {
|
|
328
|
+
p.log.error(`Project config not written: ${linked} is a symbolic link, and devflow writes nothing through one`);
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
await fs.mkdir(learningDir, { recursive: true });
|
|
309
333
|
await fs.writeFile(projectConfigPath, configJson, 'utf-8');
|
|
310
334
|
p.log.success(`Project config written to ${color.dim(projectConfigPath)}`);
|
|
311
335
|
}
|
|
@@ -376,9 +400,11 @@ async function handleClear() {
|
|
|
376
400
|
}
|
|
377
401
|
// A mid-run Learning agent whose claimed batch vanishes stops without further
|
|
378
402
|
// writes — the desired outcome of clearing.
|
|
379
|
-
await drainLearningQueue(ledgerRoot);
|
|
403
|
+
const drain = await drainLearningQueue(ledgerRoot);
|
|
380
404
|
p.log.success(`Cleared ${counted(cleared.value.cleared, 'observation')} no entry uses and kept ${cleared.value.kept} ` +
|
|
381
|
-
|
|
405
|
+
`that entries use${drain.drained ? '; drained the learning queue.' : '.'}`);
|
|
406
|
+
if (!drain.drained)
|
|
407
|
+
p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
|
|
382
408
|
}
|
|
383
409
|
/**
|
|
384
410
|
* `--enable` / `--disable`: the machine-wide switch (D-FEATURES-NARROW-ONLY),
|
|
@@ -403,7 +429,9 @@ async function handleToggle(enabled) {
|
|
|
403
429
|
// batch vanishes aborts without changes — the desired outcome of disabling.
|
|
404
430
|
const ledgerRoot = await getLedgerRoot();
|
|
405
431
|
if (ledgerRoot) {
|
|
406
|
-
await drainLearningQueue(ledgerRoot);
|
|
432
|
+
const drain = await drainLearningQueue(ledgerRoot);
|
|
433
|
+
if (!drain.drained)
|
|
434
|
+
p.log.warn(formatRefusedDrain('learning', drain.linkedFolder));
|
|
407
435
|
}
|
|
408
436
|
p.log.success('Learning disabled in every project');
|
|
409
437
|
}
|
|
@@ -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`.
|
|
146
|
-
*
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
*
|
|
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.
|
|
206
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1486
|
-
const
|
|
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
|
|
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
|
|
1632
|
+
const cmdPath = path.join(commandsDir, mdFileName(cmd.replace(/^\//, '')));
|
|
1633
|
+
if (!(await mayChange(cmdPath)))
|
|
1634
|
+
continue;
|
|
1520
1635
|
try {
|
|
1521
|
-
await fs.rm(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
@@ -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`):
|