devflow-kit 3.10.0 → 3.11.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.
@@ -14,7 +14,9 @@ import * as path from 'path';
14
14
  * two providers may legitimately both carry a `comment.md`, so the registry name has to
15
15
  * be the relative PATH, and the walk has to descend.
16
16
  *
17
- * Never writes, only removes — no delete-then-write window.
17
+ * Never writes. The sweep names what stays and what goes; a {@link ReferenceSweepDelegate}
18
+ * acts on every entry the manifest does not name, and the sweep itself never removes a
19
+ * folder recursively. No delete-then-write window.
18
20
  */
19
21
  /**
20
22
  * Descent bound for every walk over the generated reference tree — this sweep and the
@@ -42,24 +44,27 @@ export const MAX_REFERENCE_SWEEP_DEPTH = 8;
42
44
  * @param root - Directory to converge (the installed `references/tracker/` tree).
43
45
  * @param knownRelPaths - POSIX paths relative to `root` that must survive. Derived from
44
46
  * the build's own module registries by the caller — never hand-listed.
47
+ * @param delegate - Acts on every entry the manifest does not name. Required: a sweep with
48
+ * no delegate would have to delete what it cannot name.
45
49
  *
46
50
  * @returns A {@link SweepResult} whose `removed` entries are POSIX relative paths. A
47
- * directory into which no manifest path descends is removed WHOLE and reported by its
48
- * own relative path — leaving it empty would be a convergence that stops one step
49
- * short, and an empty provider directory is indistinguishable from a provider whose
50
- * references failed to install. A subtree left unswept because it breached
51
+ * directory into which no manifest path descends is handed to the delegate WHOLE and
52
+ * reported by its own relative path once the delegate removed it — leaving it empty
53
+ * would be a convergence that stops one step short, and an empty provider directory is
54
+ * indistinguishable from a provider whose references failed to install. A subtree left
55
+ * unswept because it breached
51
56
  * {@link MAX_REFERENCE_SWEEP_DEPTH} is reported in `failed` under its own relative
52
57
  * path, for the same reason: everything this sweep did not converge is named.
53
58
  *
54
59
  * A missing or unreadable `root` is a no-op, not an error: the overlay creates the tree
55
60
  * it converges, so an absent one simply means there is nothing to prune yet.
56
61
  */
57
- export async function sweepOrphanedReferences(root, knownRelPaths) {
62
+ export async function sweepOrphanedReferences(root, knownRelPaths, delegate) {
58
63
  const acc = { scanned: 0, removed: [], failed: [] };
59
- await sweepDirectory(root, '', 0, knownRelPaths, directoryPrefixes(knownRelPaths), acc);
64
+ await sweepDirectory(root, '', 0, knownRelPaths, directoryPrefixes(knownRelPaths), delegate, acc);
60
65
  return { scanned: acc.scanned, removed: acc.removed, failed: acc.failed };
61
66
  }
62
- async function sweepDirectory(dir, prefix, depth, known, knownDirPrefixes, acc) {
67
+ async function sweepDirectory(dir, prefix, depth, known, knownDirPrefixes, delegate, acc) {
63
68
  if (depth > MAX_REFERENCE_SWEEP_DEPTH) {
64
69
  // A breached bound means this subtree is never visited, so any orphan inside it
65
70
  // survives. Report it through the same channel as a failed removal: a silent return
@@ -82,35 +87,47 @@ async function sweepDirectory(dir, prefix, depth, known, knownDirPrefixes, acc)
82
87
  const relPath = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
83
88
  const fullPath = path.join(dir, entry.name);
84
89
  // A real subdirectory is either an ancestor of something the manifest names — in
85
- // which case descend — or dead weight, in which case take the whole subtree.
86
- // isDirectory() is false for a symlink-to-dir, so a planted link is treated as a
87
- // leaf and removed rather than followed.
90
+ // which case descend — or dead weight, in which case the delegate takes the whole
91
+ // subtree. isDirectory() is false for a symlink-to-dir, so a planted link is treated
92
+ // as a leaf and handed over rather than followed.
88
93
  if (entry.isDirectory()) {
89
94
  if (knownDirPrefixes.has(`${relPath}/`)) {
90
- await sweepDirectory(fullPath, relPath, depth + 1, known, knownDirPrefixes, acc);
95
+ await sweepDirectory(fullPath, relPath, depth + 1, known, knownDirPrefixes, delegate, acc);
91
96
  continue;
92
97
  }
93
98
  acc.scanned++;
94
- try {
95
- await fs.rm(fullPath, { recursive: true, force: true });
96
- acc.removed.push(relPath);
97
- }
98
- catch (err) {
99
- acc.failed.push({ name: relPath, error: err }); /* per-item isolation */
100
- }
99
+ await dispose(fullPath, relPath, true, delegate, acc);
101
100
  continue;
102
101
  }
103
102
  acc.scanned++;
104
103
  if (known.has(relPath))
105
104
  continue;
106
- try {
107
- await fs.rm(fullPath, { force: true });
108
- acc.removed.push(relPath);
109
- }
110
- catch (err) {
111
- acc.failed.push({ name: relPath, error: err }); /* per-item isolation */
105
+ await dispose(fullPath, relPath, false, delegate, acc);
106
+ }
107
+ }
108
+ /** Hand one entry to the delegate and record the outcome; a throw from the delegate is a per-item failure. */
109
+ async function dispose(fullPath, relPath, isDirectory, delegate, acc) {
110
+ try {
111
+ const disposition = await delegate.dispose(fullPath, isDirectory);
112
+ switch (disposition.outcome) {
113
+ case 'removed':
114
+ acc.removed.push(relPath);
115
+ break;
116
+ case 'failed':
117
+ acc.failed.push({ name: relPath, error: disposition.error });
118
+ break;
119
+ case 'moved':
120
+ case 'kept':
121
+ break;
122
+ default: {
123
+ const _exhaustive = disposition;
124
+ void _exhaustive;
125
+ }
112
126
  }
113
127
  }
128
+ catch (err) {
129
+ acc.failed.push({ name: relPath, error: err }); /* per-item isolation */
130
+ }
114
131
  }
115
132
  /**
116
133
  * Every directory prefix some manifest path sits under, trailing slash included:
@@ -0,0 +1,278 @@
1
+ import { createPathSet } from './path-set.js';
2
+ const names = (list) => Object.freeze([...list]);
3
+ export const RETIRED_SHIPPED = Object.freeze({
4
+ commands: names([
5
+ 'ambient',
6
+ 'audit-claude',
7
+ 'brainstorm',
8
+ 'breakdown',
9
+ 'catch-up',
10
+ 'commit',
11
+ 'design',
12
+ 'devlog',
13
+ 'get-issue',
14
+ 'plan-next-steps',
15
+ 'pre-commit',
16
+ 'pre-pr',
17
+ 'pull-request',
18
+ 'resolve-comments',
19
+ 'specify',
20
+ ]),
21
+ agents: names([
22
+ 'audit-architecture',
23
+ 'audit-complexity',
24
+ 'audit-database',
25
+ 'audit-dependencies',
26
+ 'audit-documentation',
27
+ 'audit-performance',
28
+ 'audit-security',
29
+ 'audit-tests',
30
+ 'audit-typescript',
31
+ 'brainstorm',
32
+ 'catch-up',
33
+ 'claude-md-auditor',
34
+ 'code-edit',
35
+ 'code-fix',
36
+ 'code-review',
37
+ 'coder',
38
+ 'commit',
39
+ 'diagnose',
40
+ 'get-issue',
41
+ 'learning',
42
+ 'pr-comments',
43
+ 'project-state',
44
+ 'pull-request',
45
+ 'release',
46
+ 'resolver',
47
+ 'reviewer',
48
+ 'scrutinize',
49
+ 'scrutinizer',
50
+ 'shepherd',
51
+ 'simplifier',
52
+ 'simplify',
53
+ 'skimmer',
54
+ 'synthesizer',
55
+ 'tech-debt',
56
+ 'validator',
57
+ ]),
58
+ rules: names([]),
59
+ skills: names([
60
+ 'accessibility',
61
+ 'agent-teams',
62
+ 'ambient-router',
63
+ 'apply-feature-kb',
64
+ 'apply-knowledge',
65
+ 'architecture-patterns',
66
+ 'code-smell',
67
+ 'codebase-navigation',
68
+ 'commit',
69
+ 'complexity-patterns',
70
+ 'consistency-patterns',
71
+ 'core-patterns',
72
+ 'database-patterns',
73
+ 'debug',
74
+ 'debug-orchestration',
75
+ 'debug:guided',
76
+ 'debug:orch',
77
+ 'debug:triage',
78
+ 'decisions-format',
79
+ 'dependencies-patterns',
80
+ 'devflow',
81
+ 'devflow-accessibility',
82
+ 'devflow-agent-teams',
83
+ 'devflow-architecture-patterns',
84
+ 'devflow-code-smell',
85
+ 'devflow-codebase-navigation',
86
+ 'devflow-commit',
87
+ 'devflow-complexity-patterns',
88
+ 'devflow-consistency-patterns',
89
+ 'devflow-core-patterns',
90
+ 'devflow-database-patterns',
91
+ 'devflow-debug',
92
+ 'devflow-dependencies-patterns',
93
+ 'devflow-docs-framework',
94
+ 'devflow-documentation-patterns',
95
+ 'devflow-error-handling',
96
+ 'devflow-frontend-design',
97
+ 'devflow-git-safety',
98
+ 'devflow-git-workflow',
99
+ 'devflow-github-patterns',
100
+ 'devflow-implementation-patterns',
101
+ 'devflow-input-validation',
102
+ 'devflow-pattern-check',
103
+ 'devflow-performance-patterns',
104
+ 'devflow-pull-request',
105
+ 'devflow-react',
106
+ 'devflow-regression-patterns',
107
+ 'devflow-review-methodology',
108
+ 'devflow-security-patterns',
109
+ 'devflow-self-review',
110
+ 'devflow-test-design',
111
+ 'devflow-tests-patterns',
112
+ 'devflow-typescript',
113
+ 'devflow:agent-teams',
114
+ 'devflow:ambient-router',
115
+ 'devflow:apply-feature-kb',
116
+ 'devflow:apply-knowledge',
117
+ 'devflow:architecture-patterns',
118
+ 'devflow:complexity-patterns',
119
+ 'devflow:consistency-patterns',
120
+ 'devflow:core-patterns',
121
+ 'devflow:database-patterns',
122
+ 'devflow:debug',
123
+ 'devflow:debug-orchestration',
124
+ 'devflow:debug:guided',
125
+ 'devflow:debug:orch',
126
+ 'devflow:debug:triage',
127
+ 'devflow:dependencies-patterns',
128
+ 'devflow:documentation-patterns',
129
+ 'devflow:dream-curation',
130
+ 'devflow:dream-decisions',
131
+ 'devflow:dream-knowledge',
132
+ 'devflow:dream-memory',
133
+ 'devflow:explore',
134
+ 'devflow:explore:guided',
135
+ 'devflow:explore:orch',
136
+ 'devflow:explore:triage',
137
+ 'devflow:feature-kb',
138
+ 'devflow:frontend-design',
139
+ 'devflow:git-safety',
140
+ 'devflow:git-workflow',
141
+ 'devflow:github-patterns',
142
+ 'devflow:implement',
143
+ 'devflow:implement:guided',
144
+ 'devflow:implement:orch',
145
+ 'devflow:implement:triage',
146
+ 'devflow:implementation-orchestration',
147
+ 'devflow:implementation-patterns',
148
+ 'devflow:input-validation',
149
+ 'devflow:knowledge-persistence',
150
+ 'devflow:performance-patterns',
151
+ 'devflow:pipeline',
152
+ 'devflow:pipeline-orchestration',
153
+ 'devflow:pipeline:orch',
154
+ 'devflow:plan',
155
+ 'devflow:plan-orchestration',
156
+ 'devflow:plan:guided',
157
+ 'devflow:plan:orch',
158
+ 'devflow:plan:triage',
159
+ 'devflow:regression-patterns',
160
+ 'devflow:release:guided',
161
+ 'devflow:release:orch',
162
+ 'devflow:release:triage',
163
+ 'devflow:research',
164
+ 'devflow:research:guided',
165
+ 'devflow:research:orch',
166
+ 'devflow:research:triage',
167
+ 'devflow:resolve',
168
+ 'devflow:resolve-orchestration',
169
+ 'devflow:resolve:orch',
170
+ 'devflow:review',
171
+ 'devflow:review-orchestration',
172
+ 'devflow:review:guided',
173
+ 'devflow:review:orch',
174
+ 'devflow:review:triage',
175
+ 'devflow:router',
176
+ 'devflow:search-first',
177
+ 'devflow:security-patterns',
178
+ 'devflow:self-review',
179
+ 'devflow:sidecar',
180
+ 'devflow:test-patterns',
181
+ 'docs-framework',
182
+ 'documentation-patterns',
183
+ 'dream-curation',
184
+ 'dream-decisions',
185
+ 'dream-knowledge',
186
+ 'dream-memory',
187
+ 'error-handling',
188
+ 'explore',
189
+ 'explore:guided',
190
+ 'explore:orch',
191
+ 'explore:triage',
192
+ 'feature-kb',
193
+ 'frontend-design',
194
+ 'git-safety',
195
+ 'git-workflow',
196
+ 'github-patterns',
197
+ 'go',
198
+ 'implement',
199
+ 'implement:guided',
200
+ 'implement:orch',
201
+ 'implement:triage',
202
+ 'implementation-orchestration',
203
+ 'implementation-patterns',
204
+ 'input-validation',
205
+ 'java',
206
+ 'knowledge-persistence',
207
+ 'pattern-check',
208
+ 'performance-patterns',
209
+ 'pipeline',
210
+ 'pipeline-orchestration',
211
+ 'pipeline:orch',
212
+ 'plan',
213
+ 'plan-orchestration',
214
+ 'plan:guided',
215
+ 'plan:orch',
216
+ 'plan:triage',
217
+ 'pull-request',
218
+ 'python',
219
+ 'react',
220
+ 'regression-patterns',
221
+ 'release:guided',
222
+ 'release:orch',
223
+ 'release:triage',
224
+ 'research',
225
+ 'research:guided',
226
+ 'research:orch',
227
+ 'research:triage',
228
+ 'resolve',
229
+ 'resolve-orchestration',
230
+ 'resolve:orch',
231
+ 'review',
232
+ 'review-methodology',
233
+ 'review-orchestration',
234
+ 'review:guided',
235
+ 'review:orch',
236
+ 'review:triage',
237
+ 'router',
238
+ 'rust',
239
+ 'search-first',
240
+ 'security-patterns',
241
+ 'self-review',
242
+ 'sidecar',
243
+ 'test-design',
244
+ 'test-driven-development',
245
+ 'test-patterns',
246
+ 'tests-patterns',
247
+ 'typescript',
248
+ 'worktree-support',
249
+ ]),
250
+ skillFiles: Object.freeze({
251
+ 'devflow:agent-references': names([
252
+ 'references/learning/maintain.md',
253
+ ]),
254
+ 'devflow:git': names([
255
+ 'references/pr/ensure-pr-ready.md',
256
+ ]),
257
+ }),
258
+ });
259
+ /**
260
+ * The files of {@link RETIRED_SHIPPED} as paths relative to the Claude dir: the command, agent and
261
+ * rule files, and the listed files of a skill folder. A skill folder of `skills` is not a path
262
+ * here, since the install moves it whole.
263
+ */
264
+ export function retiredPathSet() {
265
+ const paths = [];
266
+ for (const name of RETIRED_SHIPPED.commands)
267
+ paths.push(`commands/devflow/${name}.md`);
268
+ for (const name of RETIRED_SHIPPED.agents)
269
+ paths.push(`agents/devflow/${name}.md`);
270
+ for (const name of RETIRED_SHIPPED.rules)
271
+ paths.push(`rules/devflow/${name}.md`);
272
+ for (const [skill, files] of Object.entries(RETIRED_SHIPPED.skillFiles)) {
273
+ for (const file of files)
274
+ paths.push(`skills/${skill}/${file}`);
275
+ }
276
+ return createPathSet(paths);
277
+ }
278
+ //# sourceMappingURL=retired-shipped-names.js.map
@@ -24,8 +24,9 @@ import * as path from 'path';
24
24
  import { skillsDir, rulesDir } from '../../core/assets.js';
25
25
  import { ALWAYS_PRESENT_REFS, COMPLIANCE_FRAMEWORKS, normalizeFrameworks } from '../../core/compliance.js';
26
26
  import { parseComplianceFragment, composeComplianceSkill, composeComplianceRule } from '../../core/compliance-compose.js';
27
- import { validateSkillShadow, validateRuleShadow } from './installer.js';
28
- import { newLinkReport, refuseUnrealFolder, removeReportingLink, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
27
+ import { swapFolderIn } from './folder-swap.js';
28
+ import { copyDirectory, validateSkillShadow, validateRuleShadow } from './installer.js';
29
+ import { newLinkReport, refuseUnrealFolder, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
29
30
  // ── Path helpers ───────────────────────────────────────────────────────────
30
31
  /** Every registry framework id — the reference set every install carries. */
31
32
  const ALL_FRAMEWORK_IDS = COMPLIANCE_FRAMEWORKS.map(fw => fw.id);
@@ -33,6 +34,14 @@ const ALL_FRAMEWORK_IDS = COMPLIANCE_FRAMEWORKS.map(fw => fw.id);
33
34
  function skillTarget(claudeDir) {
34
35
  return path.join(claudeDir, 'skills', 'devflow:compliance');
35
36
  }
37
+ /** The folder the compliance skill is built in before it is renamed into place: {claudeDir}/skills/devflow:compliance.tmp */
38
+ export function complianceSkillTempPath(claudeDir) {
39
+ return `${skillTarget(claudeDir)}.tmp`;
40
+ }
41
+ /** Where the compliance skill folder waits while the new one is renamed in: {claudeDir}/skills/devflow:compliance.old */
42
+ export function complianceSkillAsidePath(claudeDir) {
43
+ return `${skillTarget(claudeDir)}.old`;
44
+ }
36
45
  /** Installed compliance rule file: {claudeDir}/rules/devflow/compliance.md */
37
46
  function ruleTarget(claudeDir) {
38
47
  return path.join(claudeDir, 'rules', 'devflow', 'compliance.md');
@@ -93,14 +102,22 @@ async function loadComplianceFragments(canonicalSrc, frameworks, warn) {
93
102
  * `fragments` is loaded once by convergeComplianceArtifacts and shared with the rule
94
103
  * installer — the SKILL.md and the rule compose from the same parsed set.
95
104
  *
96
- * Build under a .tmp sibling, remove old target, rename. An old target that is a link is unlinked,
97
- * never followed, so what it points to is left as it was; the link is recorded in `links`.
105
+ * Build under a .tmp sibling, then swap it in: the old target waits in a .old sibling while the new one
106
+ * is renamed in. A file in the old target that devflow did not ship moves to the backup folder first;
107
+ * if one cannot move, nothing is swapped and the shipped files are written into the old target (see
108
+ * {@link swapFolderIn}). An old target that is a link is moved aside as the link itself, never
109
+ * followed, so what it points to is left as it was; the link is recorded in `links`.
98
110
  * Unexpected I/O failures are reported via warn; never thrown.
99
111
  */
100
- async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn, links) {
112
+ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, warn, links, backups) {
101
113
  const canonicalSrc = path.join(skillsDir(), 'compliance');
102
114
  const target = skillTarget(claudeDir);
103
- const tmpTarget = `${target}.tmp`;
115
+ const tmpTarget = complianceSkillTempPath(claudeDir);
116
+ // A skills folder that is a link or not a folder is refused before anything is built: the swap
117
+ // would move and remove below it, in the folder the link points to. The refusal is recorded in
118
+ // `links`, and the converge is not clean.
119
+ if (await refuseUnrealFolder(links, path.dirname(target)))
120
+ return;
104
121
  // Determine SKILL.md source: prefer a valid shadow, else use canonical.
105
122
  const skillShadowDir = path.join(devflowDir, 'skills', 'compliance');
106
123
  const shadowState = await validateSkillShadow(skillShadowDir);
@@ -150,11 +167,18 @@ async function installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments
150
167
  warn(`compliance: reference "${fw}.md" not installed — ${String(err)}`);
151
168
  }
152
169
  }
153
- // Swap: remove old target, rename tmp into place (two calls, not atomic). The link is recorded
170
+ // Swap: the old target waits aside, the tmp folder is renamed into place. The link is recorded
154
171
  // once the real folder stands in its place, so the report matches its wording.
155
- const unlinked = await removeReportingLink(target);
156
- await fs.rename(tmpTarget, target);
157
- links.replacedLinks.push(...unlinked);
172
+ await swapFolderIn({
173
+ backups,
174
+ target,
175
+ staged: tmpTarget,
176
+ aside: complianceSkillAsidePath(claudeDir),
177
+ links,
178
+ mergeInto: () => copyDirectory(tmpTarget, target, undefined, links),
179
+ });
180
+ // The tmp folder is gone after a swap; a merge leaves it behind.
181
+ await fs.rm(tmpTarget, { recursive: true, force: true });
158
182
  }
159
183
  catch (err) {
160
184
  // Clean up tmp on failure (best-effort) so no orphan is left behind.
@@ -219,7 +243,7 @@ async function installRuleFile(claudeDir, devflowDir, frameworks, fragments, war
219
243
  * principle in engineering.md.
220
244
  */
221
245
  export async function convergeComplianceArtifacts(opts) {
222
- const { claudeDir, devflowDir, enabled, frameworks, rulesEnabled, warn } = opts;
246
+ const { claudeDir, devflowDir, enabled, frameworks, rulesEnabled, warn, backups } = opts;
223
247
  // S78: claudeDir precondition — reliability rule: assert invariants in production code.
224
248
  // An empty or relative claudeDir would make skillTarget/ruleTarget resolve to unexpected
225
249
  // locations. Warn and bail rather than running fs.rm with a potentially wrong path.
@@ -251,7 +275,7 @@ export async function convergeComplianceArtifacts(opts) {
251
275
  // installSkillDir is caught internally and reported via trackingWarn, so
252
276
  // execution always continues to the rule step.
253
277
  const links = newLinkReport();
254
- await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn, links);
278
+ await installSkillDir(claudeDir, devflowDir, stampFrameworks, fragments, trackingWarn, links, backups);
255
279
  // The rule folder is proven before the rule is written or removed: a link there sends both into
256
280
  // another place. The skill folder is swapped whole above, and a swap never follows a link.
257
281
  const finish = (removedPreexisting) => {
@@ -298,7 +322,7 @@ export async function convergeComplianceArtifacts(opts) {
298
322
  * than the value stored in the manifest.
299
323
  */
300
324
  export async function convergeFromManifest(opts) {
301
- const { claudeDir, devflowDir, manifest, warn, rulesEnabledOverride } = opts;
325
+ const { claudeDir, devflowDir, manifest, warn, rulesEnabledOverride, backups } = opts;
302
326
  return convergeComplianceArtifacts({
303
327
  claudeDir,
304
328
  devflowDir,
@@ -306,6 +330,7 @@ export async function convergeFromManifest(opts) {
306
330
  frameworks: manifest.features.compliance.frameworks,
307
331
  rulesEnabled: rulesEnabledOverride ?? manifest.features.rules,
308
332
  warn,
333
+ backups,
309
334
  });
310
335
  }
311
336
  //# sourceMappingURL=compliance-install.js.map
@@ -148,9 +148,13 @@ async function sameBytes(a, b) {
148
148
  return false;
149
149
  }
150
150
  }
151
+ /** The temp file beside `target` that {@link copyIntoPlace} writes through: `<target>.tmp.<pid>`. */
152
+ export function copyTempPath(target) {
153
+ return `${target}.tmp.${process.pid}`;
154
+ }
151
155
  /** Copy `source` over `target` through a sibling temp file and a rename, so no reader sees a partial file. */
152
156
  async function copyIntoPlace(source, target) {
153
- const temp = `${target}.tmp.${process.pid}`;
157
+ const temp = copyTempPath(target);
154
158
  try {
155
159
  await fs.unlink(temp).catch((err) => {
156
160
  if (errno(err) !== 'ENOENT')