devflow-kit 3.9.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.
Files changed (168) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/backup-session.js +131 -0
  9. package/dist/cli/commands/compliance-prompts.js +9 -4
  10. package/dist/cli/commands/compliance.js +184 -24
  11. package/dist/cli/commands/curation.js +23 -10
  12. package/dist/cli/commands/gate-level-prompts.js +39 -25
  13. package/dist/cli/commands/gates.js +5 -7
  14. package/dist/cli/commands/init.js +169 -184
  15. package/dist/cli/commands/install-report.js +129 -0
  16. package/dist/cli/commands/legacy-sweep.js +159 -0
  17. package/dist/cli/commands/proxy.js +6 -1
  18. package/dist/cli/commands/rules.js +75 -21
  19. package/dist/cli/commands/skills.js +42 -9
  20. package/dist/cli/commands/tracker.js +9 -3
  21. package/dist/cli/commands/uninstall.js +339 -75
  22. package/dist/commands/bug-analysis.md +69 -120
  23. package/dist/commands/dynamic-build.md +23 -23
  24. package/dist/commands/implement.md +46 -32
  25. package/dist/commands/release.md +1 -1
  26. package/dist/commands/resolve.md +35 -31
  27. package/dist/commands/self-review.md +8 -7
  28. package/dist/compliance-off/agents/git.md +120 -0
  29. package/dist/compliance-off/agents/triage.md +180 -0
  30. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  31. package/dist/compliance-off/commands/code-review.md +520 -0
  32. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  33. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  34. package/dist/compliance-off/commands/implement.md +736 -0
  35. package/dist/compliance-off/commands/plan.md +671 -0
  36. package/dist/compliance-off/commands/release.md +309 -0
  37. package/dist/compliance-off/commands/resolve.md +742 -0
  38. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  39. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  41. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  42. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  43. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  45. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  52. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  59. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  66. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  67. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  68. package/dist/core/agent-models.js +19 -4
  69. package/dist/core/assets.js +126 -17
  70. package/dist/core/compliance-off-files.js +27 -0
  71. package/dist/core/compliance.js +14 -0
  72. package/dist/core/curation-variants.js +460 -112
  73. package/dist/core/flags.js +2 -2
  74. package/dist/core/fs-atomic.js +5 -1
  75. package/dist/core/installed-variants.js +63 -0
  76. package/dist/core/manifest.js +48 -5
  77. package/dist/core/mds-variants.js +700 -3
  78. package/dist/core/orphan-sweep.js +29 -2
  79. package/dist/core/path-set.js +15 -0
  80. package/dist/core/plugins.js +68 -10
  81. package/dist/core/reference-sweep.js +42 -25
  82. package/dist/core/retired-shipped-names.js +278 -0
  83. package/dist/curation-compliance-off/agents/triage.md +176 -0
  84. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  85. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  86. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  87. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  88. package/dist/curation-compliance-off/commands/implement.md +689 -0
  89. package/dist/curation-compliance-off/commands/plan.md +631 -0
  90. package/dist/curation-compliance-off/commands/release.md +279 -0
  91. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  92. package/dist/curation-off/agents/code.md +6 -6
  93. package/dist/curation-off/agents/fix.md +6 -6
  94. package/dist/curation-off/agents/harden.md +7 -6
  95. package/dist/curation-off/agents/refactor.md +6 -6
  96. package/dist/curation-off/commands/bug-analysis.md +69 -119
  97. package/dist/curation-off/commands/dynamic-build.md +23 -23
  98. package/dist/curation-off/commands/implement.md +46 -32
  99. package/dist/curation-off/commands/release.md +1 -1
  100. package/dist/curation-off/commands/resolve.md +35 -31
  101. package/dist/curation-off/commands/self-review.md +8 -7
  102. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  103. package/dist/targets/claude-code/behavior-install.js +23 -9
  104. package/dist/targets/claude-code/charter-render.js +374 -0
  105. package/dist/targets/claude-code/compliance-install.js +62 -17
  106. package/dist/targets/claude-code/compliance-off-files.js +249 -0
  107. package/dist/targets/claude-code/curation-install.js +632 -117
  108. package/dist/targets/claude-code/folder-swap.js +142 -0
  109. package/dist/targets/claude-code/install-backup.js +780 -0
  110. package/dist/targets/claude-code/installer.js +795 -199
  111. package/dist/targets/claude-code/language-stamp.js +6 -0
  112. package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
  113. package/dist/targets/claude-code/linked-folder.js +176 -0
  114. package/dist/targets/claude-code/reference-converge.js +131 -0
  115. package/dist/targets/claude-code/rules-folder.js +34 -0
  116. package/dist/targets/claude-code/shipped-set.js +160 -0
  117. package/dist/targets/claude-code/tracker-install.js +21 -8
  118. package/dist/targets/claude-code/tracker-servers.js +20 -6
  119. package/dist/targets/claude-code/workflow-folder-install.js +251 -0
  120. package/dist/variant-axes.json +109 -0
  121. package/package.json +1 -1
  122. package/src/assets/agents/_partials/_code.mds +6 -6
  123. package/src/assets/agents/evaluate.md +1 -1
  124. package/src/assets/agents/git.mds +32 -0
  125. package/src/assets/agents/harden.mds +7 -6
  126. package/src/assets/agents/test.md +21 -12
  127. package/src/assets/agents/triage.mds +2 -0
  128. package/src/assets/agents/validate.md +10 -10
  129. package/src/assets/commands/_partials/_compliance.mds +5 -3
  130. package/src/assets/commands/_partials/_engine.mds +9 -8
  131. package/src/assets/commands/_partials/_publication.mds +10 -1
  132. package/src/assets/commands/bug-analysis.mds +80 -126
  133. package/src/assets/commands/code-review.mds +43 -0
  134. package/src/assets/commands/dynamic-build.mds +109 -15
  135. package/src/assets/commands/dynamic-tickets.mds +13 -0
  136. package/src/assets/commands/implement.mds +145 -33
  137. package/src/assets/commands/plan.mds +19 -0
  138. package/src/assets/commands/release.mds +50 -1
  139. package/src/assets/commands/resolve.mds +217 -32
  140. package/src/assets/commands/self-review.mds +8 -7
  141. package/src/assets/mds/git/_ops.mds +23 -0
  142. package/src/assets/mds/git/_ops_steps.mds +17 -0
  143. package/src/assets/mds/git/_pr.mds +1 -1
  144. package/src/assets/mds/git/_references.mds +6 -0
  145. package/src/assets/mds/tracker/_common.mds +4 -0
  146. package/src/assets/mds/tracker/_github.mds +42 -0
  147. package/src/assets/mds/tracker/_jira.mds +43 -0
  148. package/src/assets/mds/tracker/_linear.mds +43 -0
  149. package/src/assets/rules/quality.md +2 -0
  150. package/src/assets/scripts/ci-wait.cjs +204 -126
  151. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  152. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  153. package/src/assets/scripts/hooks/preamble +18 -8
  154. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  155. package/src/assets/scripts/pr-evidence.cjs +41 -2
  156. package/src/assets/scripts/verify-evidence.cjs +408 -56
  157. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  158. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  159. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  160. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  162. package/src/assets/skills/git/SKILL.md +3 -3
  163. package/src/assets/skills/git/references/github-api.md +2 -139
  164. package/src/assets/skills/git/references/github-posting.md +158 -0
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  167. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  168. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import color from 'picocolors';
11
11
  import { overlayUnitLabel } from '../../targets/claude-code/installer.js';
12
+ import { escapeName, formatCappedList } from '../../targets/claude-code/install-backup.js';
13
+ import { refusedFolderMessage, replacedLinkMessage } from '../../targets/claude-code/linked-folder.js';
12
14
  import { SKILL_REFS_SKILL_NAME } from '../../core/mds-variants.js';
13
15
  import { prefixSkillName } from '../../core/plugins.js';
14
16
  /**
@@ -49,6 +51,39 @@ export function formatOverlaySummary(report, skillName = SKILL_REFS_SKILL_NAME)
49
51
  }
50
52
  return lines;
51
53
  }
54
+ /**
55
+ * Turn the replaced-link half of an InstallReport into summary lines, one per link.
56
+ *
57
+ * An install removes a link it finds where it keeps one of its own files or folders and writes a
58
+ * real file or folder there. What the link pointed to is not touched, which looks the same from the
59
+ * filesystem as an install that never met the link, so the line says both.
60
+ *
61
+ * Pure function — returns lines, logs nothing.
62
+ */
63
+ export function formatReplacedLinksSummary(report) {
64
+ return report.replacedLinks.map(linkPath => ({ level: 'warn', message: replacedLinkMessage(linkPath) }));
65
+ }
66
+ /**
67
+ * Turn the refused-folder half of an InstallReport into summary lines, one per folder.
68
+ *
69
+ * An install writes nothing at a path that is a link it could not remove, or not a folder where it
70
+ * needs one, and leaves the entry as it was. That looks the same on disk as an install that refreshed
71
+ * the path, so the line says what was skipped and what to do about it.
72
+ *
73
+ * Pure function — returns lines, logs nothing.
74
+ */
75
+ export function formatRefusedFoldersSummary(report) {
76
+ return report.refusedFolders.map(folder => ({ level: 'warn', message: refusedFolderMessage(folder) }));
77
+ }
78
+ /**
79
+ * Both halves in one list, the replaced links first: for a command that prints the links one
80
+ * converge met and has no other summary of its own.
81
+ *
82
+ * Pure function — returns lines, logs nothing.
83
+ */
84
+ export function formatLinksSummary(report) {
85
+ return [...formatReplacedLinksSummary(report), ...formatRefusedFoldersSummary(report)];
86
+ }
52
87
  /**
53
88
  * The half of an overlay warning that describes what is actually on disk.
54
89
  *
@@ -197,4 +232,98 @@ export function formatSkillScopeSummary(report, pluginListUnchanged) {
197
232
  }
198
233
  return lines;
199
234
  }
235
+ const BACKUP_SHOWN_UNITS = 5;
236
+ /** One moved unit as `original -> backup`; a folder carries a trailing slash. */
237
+ function movedUnitLabel(unit) {
238
+ const slash = unit.kind === 'directory' ? '/' : '';
239
+ return `${unit.original}${slash} -> ${unit.backup}${slash}`;
240
+ }
241
+ function failureLine(failure) {
242
+ const where = escapeName(failure.path);
243
+ const reason = escapeName(failure.reason);
244
+ switch (failure.action) {
245
+ case 'move':
246
+ return { level: 'warn', message: `Could not move ${where} to the backup folder (${reason}) — it stays where it is` };
247
+ case 'scan':
248
+ return { level: 'warn', message: `Could not scan ${where} (${reason}) — nothing in it was moved or removed` };
249
+ case 'remove':
250
+ return { level: 'warn', message: `Could not remove ${where} (${reason}) — it stays where it is` };
251
+ default: {
252
+ const _exhaustive = failure.action;
253
+ void _exhaustive;
254
+ return { level: 'warn', message: `Could not finish a step on ${where} (${reason})` };
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * The lines for the bare legacy skill folders that init kept because it could not tell if devflow
260
+ * made them (`sweepLegacySkillFolders`): one line per folder for the first five, then one line that
261
+ * counts the rest. A run that kept none gives no lines. Every printed path goes through `escapeName`.
262
+ */
263
+ export function formatKeptLegacySkills(kept) {
264
+ const lines = kept.slice(0, BACKUP_SHOWN_UNITS).map((folder) => ({
265
+ level: 'info',
266
+ message: `Kept ${escapeName(folder)}: devflow could not tell if it made this folder`,
267
+ }));
268
+ if (kept.length > BACKUP_SHOWN_UNITS) {
269
+ lines.push({ level: 'info', message: `Kept ${kept.length - BACKUP_SHOWN_UNITS} more folder(s) for the same reason` });
270
+ }
271
+ return lines;
272
+ }
273
+ /**
274
+ * Turn the backup session's record into summary lines: what moved aside, what stayed because it
275
+ * could not move, and which retired files were removed. An empty snapshot gives no lines, so a
276
+ * clean run prints nothing.
277
+ *
278
+ * A move is not a removal. The moved line has its own wording and counts only moved units (a moved
279
+ * folder is one), and it never feeds the "Removed N orphaned asset(s)" line of `formatSweepSummary`.
280
+ * One removed name prints on one line only, by the kind of removal:
281
+ *
282
+ * - the retired line below lists the retired files the session removed itself, with
283
+ * `BackupSession.removeListed` (the earlier releases' commands, agents, rules and skill files)
284
+ * or `BackupSession.removeExact` (the legacy sweep's command and rule names, and the legacy
285
+ * hook scripts);
286
+ * - `formatSweepSummary` lists the names the registry-diff sweeps removed, which the install
287
+ * report records in `sweptOrphans`. A sweep removes a name only after the session has not,
288
+ * and the session removes no name that a sweep records.
289
+ *
290
+ * Every printed name goes through `escapeName`. Pure function — returns lines, logs nothing.
291
+ */
292
+ export function formatBackupSummary(snapshot) {
293
+ const lines = [];
294
+ if (snapshot.moved.length > 0) {
295
+ lines.push({
296
+ level: 'info',
297
+ message: `Moved ${snapshot.moved.length} item(s) that devflow does not ship out of its folders: ` +
298
+ formatCappedList(snapshot.moved.map(movedUnitLabel), BACKUP_SHOWN_UNITS),
299
+ });
300
+ if (snapshot.runFolder !== null) {
301
+ lines.push({ level: 'info', message: `Backup folder: ${escapeName(snapshot.runFolder)}` });
302
+ }
303
+ }
304
+ if (snapshot.retiredRemoved.length > 0) {
305
+ lines.push({
306
+ level: 'info',
307
+ message: `Removed ${snapshot.retiredRemoved.length} retired devflow file(s): ` +
308
+ formatCappedList(snapshot.retiredRemoved, BACKUP_SHOWN_UNITS),
309
+ });
310
+ }
311
+ for (const failure of snapshot.failures)
312
+ lines.push(failureLine(failure));
313
+ for (const hit of snapshot.capHits) {
314
+ lines.push({
315
+ level: 'warn',
316
+ message: `${escapeName(hit.folder)} holds ${hit.entries} entries, more than the limit of ${hit.cap} — ` +
317
+ 'devflow left it whole and moved nothing out of it',
318
+ });
319
+ }
320
+ if (snapshot.rootRefusal !== null) {
321
+ lines.push({
322
+ level: 'warn',
323
+ message: `Could not use the backup folder ${escapeName(snapshot.rootRefusal.path)} ` +
324
+ `(${escapeName(snapshot.rootRefusal.reason)}) — nothing was moved aside in this run`,
325
+ });
326
+ }
327
+ return lines;
328
+ }
200
329
  //# sourceMappingURL=install-report.js.map
@@ -0,0 +1,159 @@
1
+ import { createHash } from 'crypto';
2
+ import { promises as fs } from 'fs';
3
+ import * as path from 'path';
4
+ import { LEGACY_COMMAND_NAMES, LEGACY_RULE_NAMES } from '../../core/plugins.js';
5
+ import { RETIRED_SHIPPED } from '../../core/retired-shipped-names.js';
6
+ import { LEGACY_SKILL_NAMES } from '../../targets/claude-code/legacy.js';
7
+ import { BARE_LEGACY_SKILL_HASHES } from '../../targets/claude-code/legacy-skill-hashes.js';
8
+ import { directoryState, isUnrealFolder } from '../../targets/claude-code/linked-folder.js';
9
+ /** The prefix of a skill folder in devflow's own namespace. A folder without it is bare. */
10
+ const NAMESPACE_PREFIX = 'devflow:';
11
+ /**
12
+ * Whether the sweep may remove files from `dir`: the install did not refuse it, nothing in it stays
13
+ * because a move failed or the folder was too large to scan, and `lstat` shows a real folder or
14
+ * nothing. `readdir` and `rm` of a path below a link act on the folder it points to.
15
+ */
16
+ async function mayRemoveFrom(report, backups, dir) {
17
+ return !report.refusedFolders.includes(dir) && !backups.isKept(dir) && !(await isUnrealFolder(dir));
18
+ }
19
+ /** Each name once, in the order given. */
20
+ const uniqueNames = (...lists) => [...new Set(lists.flat())];
21
+ /**
22
+ * Remove `<folder>/<name>.md` for each name, by exact name. A name that is shipped today is never
23
+ * removed, and a file that stays is left for the session's record.
24
+ */
25
+ async function removeRetiredMarkdown(folder, names, backups) {
26
+ const removed = [];
27
+ for (const name of names) {
28
+ const file = path.join(folder, `${name}.md`);
29
+ if (backups.classifyAt(file, 'file') === 'shipped')
30
+ continue;
31
+ if ((await backups.removeExact(file)).status === 'removed')
32
+ removed.push(file);
33
+ }
34
+ return removed;
35
+ }
36
+ /**
37
+ * Remove the files that earlier releases installed into `commands/devflow` and `rules/devflow` and
38
+ * that no release installs now: the retired command and rule names and the legacy lists, each by its
39
+ * exact name. Runs on every install shape, since the full install's pre-clean covers only a full
40
+ * install. A file whose name only looks like a retired one (a `*-teams.md` of the user's) is not
41
+ * matched here: the install's pre-pass moves it aside and names it.
42
+ *
43
+ * A folder the install refused (`report.refusedFolders`), that the session could not scan, or that
44
+ * is a link now is skipped whole: the sweep reads and removes nothing there, and the folder a link
45
+ * points to is left as it is. Every removal goes through `backups`, so each removed file is named
46
+ * once, on the retired line of its record. Never throws.
47
+ *
48
+ * @param opts.legacyCommands - Legacy command names; added to the retired commands.
49
+ * @param opts.legacyRules - Legacy rule names; added to the retired rules.
50
+ */
51
+ export async function sweepLegacyDevflowFiles(opts) {
52
+ const { claudeDir, report, backups } = opts;
53
+ const commands = uniqueNames(RETIRED_SHIPPED.commands, opts.legacyCommands ?? LEGACY_COMMAND_NAMES);
54
+ const rules = uniqueNames(RETIRED_SHIPPED.rules, opts.legacyRules ?? LEGACY_RULE_NAMES);
55
+ const removed = [];
56
+ const commandsDir = path.join(claudeDir, 'commands', 'devflow');
57
+ if (await mayRemoveFrom(report, backups, commandsDir)) {
58
+ removed.push(...await removeRetiredMarkdown(commandsDir, commands, backups));
59
+ }
60
+ const rulesDir = path.join(claudeDir, 'rules', 'devflow');
61
+ if (await mayRemoveFrom(report, backups, rulesDir)) {
62
+ removed.push(...await removeRetiredMarkdown(rulesDir, rules, backups));
63
+ }
64
+ return { removed };
65
+ }
66
+ /** The most bytes of a `SKILL.md` the check reads. Every released copy is far smaller, so a larger file is not one. */
67
+ const MAX_SKILL_FILE_BYTES = 1024 * 1024;
68
+ /**
69
+ * Who made a bare legacy skill folder, judged from its `SKILL.md` alone:
70
+ * - `devflow`: the file is a regular file whose SHA-256 is one of `released`;
71
+ * - `other`: the file reads and is not one of them, or is larger than any release copy;
72
+ * - `unknown`: the check cannot decide. There is no `SKILL.md`, it is not a regular file, the
73
+ * folder or the file is a link, or a read fails.
74
+ * An `absent` folder needs no answer. Reads one file at most. Never throws.
75
+ */
76
+ async function judgeBareSkillFolder(folder, released) {
77
+ const state = await directoryState(folder);
78
+ if (state === 'absent')
79
+ return 'absent';
80
+ if (state !== 'real')
81
+ return 'unknown';
82
+ try {
83
+ const skillFile = path.join(folder, 'SKILL.md');
84
+ const stat = await fs.lstat(skillFile);
85
+ if (!stat.isFile())
86
+ return 'unknown';
87
+ if (stat.size > MAX_SKILL_FILE_BYTES)
88
+ return 'other';
89
+ const digest = createHash('sha256').update(await fs.readFile(skillFile)).digest('hex');
90
+ return released.includes(digest) ? 'devflow' : 'other';
91
+ }
92
+ catch {
93
+ return 'unknown';
94
+ }
95
+ }
96
+ /**
97
+ * {@link judgeBareSkillFolder} for the bare legacy skill `name`, against the released copies of that
98
+ * name. A name with no entry in `released` was never installed bare by a release, so a folder of
99
+ * that name is `other`. The one check that `devflow init` and `devflow uninstall` share.
100
+ *
101
+ * @param folder - The folder of the skill, `<skills folder>/<name>`.
102
+ * @param released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
103
+ */
104
+ export function judgeBareLegacySkill(folder, name, released = BARE_LEGACY_SKILL_HASHES) {
105
+ return judgeBareSkillFolder(folder, Object.hasOwn(released, name) ? released[name] : []);
106
+ }
107
+ /**
108
+ * Deal with the skill folders that earlier installs left under the legacy names: every name of the
109
+ * legacy skill list, bare and `devflow:`-prefixed.
110
+ *
111
+ * A `devflow:`-prefixed folder that no release listed moves whole, with one rename and no walk, and
112
+ * is named; it is never deleted. A bare folder lives in the skills folder that Claude Code and other
113
+ * tools share, so its name proves nothing (`python`, `plan` and `review` are names of a user's own
114
+ * skills too). It moves only when its `SKILL.md` is a copy that a release of devflow installed
115
+ * (`released`). A bare folder whose `SKILL.md` is any other file stays where it is, and nothing is
116
+ * said. A bare folder that the check cannot judge stays, is never deleted, and is returned in
117
+ * `kept`, so the caller names it. A name with a listing in `retiredFiles` (the files a release
118
+ * shipped in that folder) goes by the listing instead: the listed files are removed, the rest of
119
+ * the folder moves, and a plain `rmdir` removes the folder only when it is empty. A move that fails
120
+ * leaves the folder and its files where they are, and the session records it. A name that is shipped
121
+ * today, or that an earlier step could not move, is skipped, and so is every name when the skills
122
+ * folder is a link or not a folder: nothing is moved through a link. Nothing here removes a folder
123
+ * with recursion. Never throws.
124
+ *
125
+ * @param opts.legacySkills - Folder names to deal with; defaults to the legacy skill list.
126
+ * @param opts.retiredFiles - Released file listings by folder name; defaults to the retired registry.
127
+ * @param opts.released - SHA-256 hashes of the released `SKILL.md` copies by bare name; defaults to the frozen table.
128
+ */
129
+ export async function sweepLegacySkillFolders(opts) {
130
+ const { backups } = opts;
131
+ const retiredFiles = opts.retiredFiles ?? RETIRED_SHIPPED.skillFiles;
132
+ const released = opts.released ?? BARE_LEGACY_SKILL_HASHES;
133
+ const kept = [];
134
+ const skillsDir = path.join(opts.claudeDir, 'skills');
135
+ if ((await directoryState(skillsDir)) !== 'real')
136
+ return { kept };
137
+ for (const name of opts.legacySkills ?? LEGACY_SKILL_NAMES) {
138
+ const folder = path.join(skillsDir, name);
139
+ if (backups.isKept(folder) || backups.classifyAt(folder, 'directory') === 'shipped')
140
+ continue;
141
+ const listed = Object.hasOwn(retiredFiles, name) && retiredFiles[name].length > 0;
142
+ if (listed && (await directoryState(folder)) === 'real') {
143
+ await backups.prepass([folder]);
144
+ await backups.removeListed(folder);
145
+ }
146
+ else if (name.startsWith(NAMESPACE_PREFIX)) {
147
+ await backups.moveWhole(folder);
148
+ }
149
+ else {
150
+ const verdict = await judgeBareLegacySkill(folder, name, released);
151
+ if (verdict === 'devflow')
152
+ await backups.moveWhole(folder);
153
+ else if (verdict === 'unknown')
154
+ kept.push(folder);
155
+ }
156
+ }
157
+ return { kept };
158
+ }
159
+ //# sourceMappingURL=legacy-sweep.js.map
@@ -31,6 +31,7 @@ import { stripAnsi } from '../../hud/colors.js';
31
31
  import { reapplyAgentMapping, revertExternalAgents, countExternalMappedAgents, readAgentMapping, } from '../../core/agent-models.js';
32
32
  import { getExternalModelsCached, discoverExternalModels, } from '../../core/model-discovery.js';
33
33
  import { modelCacheDir } from '../../core/cache.js';
34
+ import { formatLinksSummary } from './install-report.js';
34
35
  import { getClaudeDirectory, getDevFlowDirectory, getHomeDirectory, } from '../../targets/claude-code/claude-paths.js';
35
36
  import { devflowHookOwner, ensureHook, hasHook, removeHooks, runHookCommand, } from '../../targets/claude-code/hooks.js';
36
37
  function Ok(value) {
@@ -1389,6 +1390,8 @@ async function runEnable(portOption) {
1389
1390
  s.message('Warming model cache...');
1390
1391
  await discoverExternalModels(cacheDir, logPath).catch(() => { });
1391
1392
  s.stop(color.green('External model routing enabled'));
1393
+ for (const { message } of formatLinksSummary(reapplyResult))
1394
+ p.log.warn(message);
1392
1395
  // D-P4-1: applies-on-restart — env var takes effect only for new sessions
1393
1396
  p.log.info(color.dim('Context-window enforcement disabled for relay-routed models — applies to new Claude Code sessions'));
1394
1397
  if (adopted) {
@@ -1472,7 +1475,9 @@ async function runDisable() {
1472
1475
  // Step 3: Sync manifest
1473
1476
  await syncManifestFeature(devflowDir, 'proxy', false);
1474
1477
  // Step 4: Revert external agents to shipped defaults
1475
- await revertExternalAgents({ installDir, devflowDir });
1478
+ const revertResult = await revertExternalAgents({ installDir, devflowDir });
1479
+ for (const { message } of formatLinksSummary(revertResult))
1480
+ p.log.warn(message);
1476
1481
  p.log.success('External model routing disabled');
1477
1482
  // Step 5: Terminate the relay process.
1478
1483
  // Identity is confirmed via TCP + health before signalling — a recycled PID
@@ -10,6 +10,10 @@ import { rulesDir } from '../../core/assets.js';
10
10
  import { installAllRules, validateRuleShadow } from '../../targets/claude-code/installer.js';
11
11
  import { convergeFromManifest } from '../../targets/claude-code/compliance-install.js';
12
12
  import { convergeBehaviorFromManifest } from '../../targets/claude-code/behavior-install.js';
13
+ import { newLinkReport, refuseUnrealFolder, refusedFolderMessage } from '../../targets/claude-code/linked-folder.js';
14
+ import { removeRulesFolder, rulesFolderRefusedMessage } from '../../targets/claude-code/rules-folder.js';
15
+ import { openBackupSessionOrFail, runWithBackupReport } from './backup-session.js';
16
+ import { formatLinksSummary } from './install-report.js';
13
17
  /**
14
18
  * Check whether a rule has a user shadow file at ~/.devflow/rules/{name}.md.
15
19
  */
@@ -203,27 +207,43 @@ async function handleRuleUnshadow(name, allRules, devflowDir) {
203
207
  *
204
208
  * Warn-not-throw on per-item compliance convergence failure, so a convergence
205
209
  * failure never undoes the rules install.
210
+ *
211
+ * The record of `backups` prints once, as the enable returns or throws.
206
212
  */
207
- export async function runRulesEnable(claudeDir, devflowDir) {
213
+ export function runRulesEnable(claudeDir, devflowDir, backups) {
214
+ return runWithBackupReport(backups, printBackups => enableRules(claudeDir, devflowDir, backups, printBackups));
215
+ }
216
+ async function enableRules(claudeDir, devflowDir, backups, printBackups) {
208
217
  const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
209
218
  const manifest = await readManifest(devflowDir);
210
219
  if (!manifest) {
211
220
  p.log.error('No manifest found. Run devflow init first.');
221
+ printBackups();
212
222
  process.exit(1);
213
223
  }
214
224
  const installedPlugins = DEVFLOW_PLUGINS.filter(pl => manifest.plugins.includes(pl.name));
215
225
  const rulesMap = buildRulesMap(installedPlugins);
216
- // Wipe stale rules from previously uninstalled plugins before re-installing.
217
- await fs.rm(rulesTarget, { recursive: true, force: true });
218
- await fs.mkdir(rulesTarget, { recursive: true });
226
+ // Clear stale rules from previously uninstalled plugins before re-installing: the files devflow did
227
+ // not ship move to the backup folder, and the rest of the folder is removed by the session's list.
228
+ // A rules folder that is a link is unlinked and replaced by a real folder, never emptied through,
229
+ // and the link is reported. Something that is not a folder stays and is refused below.
230
+ const links = newLinkReport();
231
+ if ((await removeRulesFolder(rulesTarget, backups)) === 'unlinked')
232
+ links.replacedLinks.push(rulesTarget);
233
+ // A link that could not be removed is still there, and `mkdir` and every rule write would follow it.
234
+ // Prove the folder first; a refused folder gets no rule, no manifest write and one refusal line.
235
+ const rulesFolderRefused = await refuseUnrealFolder(links, rulesTarget);
219
236
  let outcomes = [];
220
- let installFailed = false;
221
- try {
222
- outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget);
223
- }
224
- catch (err) {
225
- p.log.error(`Rules installation failed — rules directory has been cleared: ${err instanceof Error ? err.message : String(err)}`);
226
- installFailed = true;
237
+ let installFailed = rulesFolderRefused;
238
+ if (!rulesFolderRefused) {
239
+ await fs.mkdir(rulesTarget, { recursive: true });
240
+ try {
241
+ outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget, links);
242
+ }
243
+ catch (err) {
244
+ p.log.error(`Rules installation failed — rules directory has been cleared: ${err instanceof Error ? err.message : String(err)}`);
245
+ installFailed = true;
246
+ }
227
247
  }
228
248
  if (!installFailed) {
229
249
  const shadowedCount = outcomes.filter(o => o.outcome === 'shadow').length;
@@ -239,6 +259,14 @@ export async function runRulesEnable(claudeDir, devflowDir) {
239
259
  await writeManifest(devflowDir, manifest);
240
260
  p.log.success(`Installed ${rulesMap.size} rule(s) to ${color.dim(rulesTarget)}${shadowSuffix}`);
241
261
  }
262
+ for (const { message } of formatLinksSummary(links))
263
+ p.log.warn(message);
264
+ // Each converge below proves the rules folder again and names a refused one itself; the line above
265
+ // has already named it, so the repeat is dropped.
266
+ const warnConverge = (msg) => {
267
+ if (!(rulesFolderRefused && msg === refusedFolderMessage(rulesTarget)))
268
+ p.log.warn(msg);
269
+ };
242
270
  // Converge compliance artifacts unconditionally — convergeFromManifest handles the
243
271
  // disabled case itself (removes stale artifacts). Running this regardless of installFailed
244
272
  // prevents a rules-install failure from leaving the compliance rule in a stale state.
@@ -249,8 +277,9 @@ export async function runRulesEnable(claudeDir, devflowDir) {
249
277
  claudeDir,
250
278
  devflowDir,
251
279
  manifest,
252
- warn: (msg) => p.log.warn(msg),
280
+ warn: warnConverge,
253
281
  rulesEnabledOverride: !installFailed,
282
+ backups,
254
283
  });
255
284
  }
256
285
  catch (err) {
@@ -265,7 +294,7 @@ export async function runRulesEnable(claudeDir, devflowDir) {
265
294
  claudeDir,
266
295
  devflowDir,
267
296
  manifest,
268
- warn: (msg) => p.log.warn(msg),
297
+ warn: warnConverge,
269
298
  rulesEnabledOverride: !installFailed,
270
299
  });
271
300
  }
@@ -308,17 +337,42 @@ export const rulesCommand = new Command('rules')
308
337
  }
309
338
  // No positional action — fall through to flag-based handling
310
339
  if (options.enable) {
311
- await runRulesEnable(claudeDir, devflowDir);
340
+ const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
341
+ if (backups === null)
342
+ return;
343
+ await runRulesEnable(claudeDir, devflowDir, backups);
312
344
  }
313
345
  else if (options.disable) {
314
- await fs.rm(rulesTarget, { recursive: true, force: true });
315
- const manifest = await readManifest(devflowDir);
316
- if (manifest) {
317
- manifest.features.rules = false;
318
- manifest.updatedAt = new Date().toISOString();
319
- await writeManifest(devflowDir, manifest);
346
+ const backups = await openBackupSessionOrFail({ claudeDir, devflowDir });
347
+ if (backups === null)
348
+ return;
349
+ const removal = await runWithBackupReport(backups, async () => {
350
+ const outcome = await removeRulesFolder(rulesTarget, backups);
351
+ const manifest = await readManifest(devflowDir);
352
+ if (manifest) {
353
+ manifest.features.rules = false;
354
+ manifest.updatedAt = new Date().toISOString();
355
+ await writeManifest(devflowDir, manifest);
356
+ }
357
+ return outcome;
358
+ });
359
+ switch (removal) {
360
+ case 'kept':
361
+ p.log.warn(`Rules disabled — ${rulesTarget} stays, because it still holds files that devflow did not remove`);
362
+ break;
363
+ case 'refused':
364
+ p.log.warn(`Rules disabled. ${rulesFolderRefusedMessage(rulesTarget)}`);
365
+ break;
366
+ case 'absent':
367
+ case 'removed':
368
+ case 'unlinked':
369
+ p.log.success('Rules disabled — removed rules directory');
370
+ break;
371
+ default: {
372
+ const unreachable = removal;
373
+ void unreachable;
374
+ }
320
375
  }
321
- p.log.success('Rules disabled — removed rules directory');
322
376
  }
323
377
  else if (options.status) {
324
378
  let installedFiles = [];
@@ -4,7 +4,7 @@ import * as path from 'path';
4
4
  import * as p from '@clack/prompts';
5
5
  import color from 'picocolors';
6
6
  import { getClaudeDirectory, getDevFlowDirectory } from '../../targets/claude-code/claude-paths.js';
7
- import { getAllSkillNames, prefixSkillName, unprefixSkillName, skillOwners, FEATURE_OWNED_SKILLS } from '../../core/plugins.js';
7
+ import { getAllSkillNames, prefixSkillName, unprefixSkillName, skillOwners, FEATURE_OWNED_SKILLS, WORKFLOW_FOLDER_NAMES } from '../../core/plugins.js';
8
8
  import { skillsDir } from '../../core/assets.js';
9
9
  import { copyDirectory, validateSkillShadow } from '../../targets/claude-code/installer.js';
10
10
  /**
@@ -42,6 +42,22 @@ export function shadowSeedDir(bareName, installedDir) {
42
42
  return sourceDir;
43
43
  return installedDir ?? sourceDir;
44
44
  }
45
+ /**
46
+ * D-WORKFLOW-SHADOW-REFUSAL: why `devflow skills shadow <name>` refuses a workflow that ships as a
47
+ * skill folder, or null for any other name.
48
+ *
49
+ * A folder installs from the build tree of the machine's exact variant key, with no source
50
+ * directory to copy and no shadow to overlay, so a shadow of its name could never apply. Refusing
51
+ * at the command says so before the user edits a copy that does nothing; the install ignores a
52
+ * shadow that is already there, with a warning, and keeps it.
53
+ *
54
+ * @param folderWorkflows - The target-layer set; the default is the real set.
55
+ */
56
+ export function workflowFolderShadowRefusal(bareName, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
57
+ if (!folderWorkflows.has(bareName))
58
+ return null;
59
+ return `${bareName} ships as a skill folder built for the machine's variant, so a shadow cannot replace it. Nothing was shadowed.`;
60
+ }
45
61
  /**
46
62
  * Check if a skill has a shadow (personal override).
47
63
  */
@@ -85,16 +101,19 @@ function buildSkillShadowTag(shadowState) {
85
101
  }
86
102
  }
87
103
  }
88
- export const skillsCommand = new Command('skills')
89
- .description('Manage skill overrides (shadow/unshadow/list)')
90
- .argument('<action>', 'Action: shadow, unshadow, or list')
91
- .argument('[name]', 'Skill name (required for shadow/unshadow)')
92
- .action(async (action, name) => {
104
+ /**
105
+ * Run one `devflow skills` action: shadow, unshadow or list.
106
+ *
107
+ * @param folderWorkflows - The workflows that ship as skill folders; the default is the real
108
+ * set. They count as known skills, `shadow` refuses them, and `list` tags a leftover shadow
109
+ * of one as ignored. Tests pass a fixture set, because the real set is empty.
110
+ */
111
+ export async function runSkillsAction(action, name, folderWorkflows = WORKFLOW_FOLDER_NAMES) {
93
112
  const devflowDir = getDevFlowDirectory();
94
113
  const claudeDir = getClaudeDirectory();
95
114
  // Union FEATURE_OWNED_SKILLS (compliance) so shadow/unshadow/list cover feature-managed
96
115
  // skills that left the plugin registry but still live in src/assets/skills/ (step 1.5).
97
- const allSkills = [...getAllSkillNames(), ...FEATURE_OWNED_SKILLS];
116
+ const allSkills = [...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS];
98
117
  if (action === 'shadow') {
99
118
  if (!name) {
100
119
  p.log.error('Skill name required. Usage: devflow skills shadow <name>');
@@ -103,6 +122,11 @@ export const skillsCommand = new Command('skills')
103
122
  }
104
123
  // Accept both bare and prefixed input
105
124
  const bareName = unprefixSkillName(name);
125
+ const refusal = workflowFolderShadowRefusal(bareName, folderWorkflows);
126
+ if (refusal !== null) {
127
+ p.log.error(refusal);
128
+ process.exit(1);
129
+ }
106
130
  if (!allSkills.includes(bareName)) {
107
131
  p.log.error(`Unknown skill: ${bareName}`);
108
132
  p.log.info(`Available skills: ${allSkills.join(', ')}`);
@@ -183,7 +207,11 @@ export const skillsCommand = new Command('skills')
183
207
  const provenance = installed
184
208
  ? color.dim(`installed because: ${describeOwners(skill, ownersBySkill.get(skill))}`)
185
209
  : color.dim(`not installed — provided by: ${describeOwners(skill, ownersBySkill.get(skill))}`);
186
- return ` ${color.cyan(skill.padEnd(28))} ${buildSkillShadowTag(shadowState).padEnd(20)} ${provenance}`;
210
+ // A shadow of a skill folder is kept but never applied (D-WORKFLOW-SHADOW-REFUSAL).
211
+ const tag = workflowFolderShadowRefusal(skill, folderWorkflows) !== null && shadowState !== 'none'
212
+ ? color.yellow('shadow ignored — skill folder')
213
+ : buildSkillShadowTag(shadowState);
214
+ return ` ${color.cyan(skill.padEnd(28))} ${tag.padEnd(20)} ${provenance}`;
187
215
  });
188
216
  // Orphan shadows: in ~/.devflow/skills/ but not a known skill
189
217
  for (const dirName of shadowDirNames) {
@@ -200,5 +228,10 @@ export const skillsCommand = new Command('skills')
200
228
  p.log.info('Usage: devflow skills <shadow|unshadow|list> [name]');
201
229
  process.exit(1);
202
230
  }
203
- });
231
+ }
232
+ export const skillsCommand = new Command('skills')
233
+ .description('Manage skill overrides (shadow/unshadow/list)')
234
+ .argument('<action>', 'Action: shadow, unshadow, or list')
235
+ .argument('[name]', 'Skill name (required for shadow/unshadow)')
236
+ .action((action, name) => runSkillsAction(action, name));
204
237
  //# sourceMappingURL=skills.js.map
@@ -56,12 +56,18 @@ export async function readTrackerProvenance(devflowDir, provider) {
56
56
  * every install carries them all (D-INSTALL-ALL-PROVIDERS) — rather than listing
57
57
  * the directory: the question a user asks `--status` is "can the agent load what
58
58
  * it is told to load?", and a stray file in the tree is not an answer to it.
59
+ *
60
+ * `compliance` is the machine's compliance switch, read from the install manifest by the
61
+ * caller and never from a repository file: a compliance-off machine installs the compliance-off
62
+ * manifest, which holds fewer references, so counting against the compliance-on one would read
63
+ * every such machine as damaged.
64
+ *
59
65
  * Never throws.
60
66
  */
61
- export async function readTrackerMechanics(claudeDir) {
67
+ export async function readTrackerMechanics(claudeDir, compliance) {
62
68
  const root = path.join(claudeDir, 'skills', prefixSkillName(SKILL_REFS_SKILL_NAME), 'references');
63
69
  let present = 0;
64
- for (const rel of installedReferenceManifest()) {
70
+ for (const rel of installedReferenceManifest({ compliance })) {
65
71
  try {
66
72
  await fs.access(path.join(root, ...rel.split('/')));
67
73
  present++;
@@ -292,7 +298,7 @@ export const trackerCommand = new Command('tracker')
292
298
  const selection = repoTrackerSelection(settingsModule, { dir: process.cwd() });
293
299
  const effective = selection?.provider ?? current.provider;
294
300
  const conventions = await readTrackerStatusConventions(devflowDir, effective);
295
- const mechanics = await readTrackerMechanics(getClaudeDirectory());
301
+ const mechanics = await readTrackerMechanics(getClaudeDirectory(), manifest.features.compliance.enabled);
296
302
  // [D-F] Inspecting the status re-arms the attempt counters. --status is
297
303
  // the command a capped user reaches for to find out why nothing is being
298
304
  // learned, so it is the command that has to hand back another five