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
@@ -1,18 +1,24 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { existsSync } from 'fs';
3
3
  import * as path from 'path';
4
- import { SKILL_NAMESPACE, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitCurationGatedSkills } from '../../core/plugins.js';
5
- import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir } from '../../core/assets.js';
4
+ import { SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitCurationGatedSkills, selectedWorkflowFolders, workflowFolderNames } from '../../core/plugins.js';
5
+ import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir, skillRefsSourceDir } from '../../core/assets.js';
6
6
  import { getPackageRoot, isContainedIn } from '../../core/paths.js';
7
+ import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, parseVariantManifest, requiredComplianceVariantFiles, } from '../../core/curation-variants.js';
7
8
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
8
- import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT } from '../../core/mds-variants.js';
9
+ import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
9
10
  import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
11
+ import { complianceOffCopyExclusions, removeComplianceOffFiles } from './compliance-off-files.js';
12
+ import { discardDisplacedFolder } from './folder-swap.js';
13
+ import { clearLinkForWrite, directoryState, refuseUnrealFolder, unlinkLinkAt } from './linked-folder.js';
14
+ import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
10
15
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
11
16
  import { isTrackerStampedAgent } from './tracker-servers.js';
12
17
  import { stampAgentTools } from '../../core/agent-frontmatter.js';
13
18
  import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
14
19
  import { BUILTIN_OVERRIDE_AGENTS, findForeignAgentOwner } from './agent-type-owner.js';
15
20
  import { restampInstalledCommands } from './language-stamp.js';
21
+ import { CHARTER_RELPATH, HANDOFF_SENTINEL_RELPATH, installRenderedCharter, } from './charter-render.js';
16
22
  // ---------------------------------------------------------------------------
17
23
  // Shadow validation helpers (exported — reused by Step 4 list commands)
18
24
  // ---------------------------------------------------------------------------
@@ -85,13 +91,20 @@ export async function validateRuleShadow(shadowFile) {
85
91
  * copy failures (EACCES, ENOSPC, etc.) are still isolated
86
92
  * (one bad copy does not abort the whole batch).
87
93
  * Invalid shadows still warn-and-install-source.
94
+ *
95
+ * A link at the target file is unlinked and replaced by a real file, never written through, and
96
+ * recorded in `links` when given. A link that cannot be removed is recorded as refused and the
97
+ * outcome is `skipped`.
88
98
  */
89
- export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
99
+ export async function installRuleFile(ruleName, devflowDir, rulesTarget, links) {
90
100
  const shadowFile = path.join(devflowDir, 'rules', mdFileName(ruleName));
91
101
  const targetFile = path.join(rulesTarget, mdFileName(ruleName));
92
102
  const ruleSource = path.join(rulesDir(), mdFileName(ruleName));
103
+ // Asked once, so a link that cannot be removed is recorded once.
104
+ let writable;
105
+ const mayWrite = async () => (writable ??= await clearLinkForWrite(targetFile, links));
93
106
  const shadowState = await validateRuleShadow(shadowFile);
94
- if (shadowState === 'valid') {
107
+ if (shadowState === 'valid' && await mayWrite()) {
95
108
  try {
96
109
  await fs.copyFile(shadowFile, targetFile);
97
110
  return 'shadow';
@@ -121,6 +134,8 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
121
134
  }
122
135
  // Copy is isolated: a copy failure degrades to 'skipped' so one
123
136
  // bad rule does not abort the entire installAllRules Promise.all batch.
137
+ if (!(await mayWrite()))
138
+ return 'skipped';
124
139
  try {
125
140
  await fs.copyFile(ruleSource, targetFile);
126
141
  return invalidShadowOutcome ?? 'source';
@@ -138,9 +153,9 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
138
153
  * `rules --enable` (renders per-rule log lines). One place computes;
139
154
  * callers present.
140
155
  */
141
- export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
156
+ export async function installAllRules(rulesMap, devflowDir, rulesTarget, links) {
142
157
  return Promise.all([...rulesMap.keys()].map(async (ruleName) => {
143
- const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget);
158
+ const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget, links);
144
159
  return { ruleName, outcome };
145
160
  }));
146
161
  }
@@ -148,18 +163,32 @@ export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
148
163
  // Directory utilities
149
164
  // ---------------------------------------------------------------------------
150
165
  /**
151
- * Recursively copy a directory tree.
166
+ * Recursively copy a directory tree. `exclude` holds absolute SOURCE paths that the copy
167
+ * skips (a file, or a directory with everything below it); omitted, everything is copied.
168
+ *
169
+ * `links` is given when `dest` is inside a Devflow install folder. The copy then checks, with
170
+ * `lstat`, each folder and file it is about to write, and never writes through a link: the link is
171
+ * unlinked and a real folder or file is written in its place (recorded in `links.replacedLinks`),
172
+ * and a link that cannot be removed is skipped with everything below it (recorded in
173
+ * `links.refusedFolders`). Omitted, the copy is the plain one, for a `dest` that is the user's own
174
+ * content.
152
175
  */
153
- export async function copyDirectory(src, dest) {
176
+ export async function copyDirectory(src, dest, exclude, links) {
177
+ if (links !== undefined && !(await clearLinkForWrite(dest, links)))
178
+ return;
154
179
  await fs.mkdir(dest, { recursive: true });
155
180
  const entries = await fs.readdir(src, { withFileTypes: true });
156
181
  for (const entry of entries) {
157
182
  const srcPath = path.join(src, entry.name);
158
183
  const destPath = path.join(dest, entry.name);
184
+ if (exclude?.has(srcPath))
185
+ continue;
159
186
  if (entry.isDirectory()) {
160
- await copyDirectory(srcPath, destPath);
187
+ await copyDirectory(srcPath, destPath, exclude, links);
161
188
  }
162
189
  else {
190
+ if (links !== undefined && !(await clearLinkForWrite(destPath, links)))
191
+ continue;
163
192
  await fs.copyFile(srcPath, destPath);
164
193
  }
165
194
  }
@@ -190,8 +219,12 @@ export async function copyDirectory(src, dest) {
190
219
  * {@link composeScripts}, swallows it with the rest of its copy-and-chmod step; that tree
191
220
  * is three levels of shipped assets, so a breach there means the package itself grew a
192
221
  * shape no walk in this repo expects, and neither call site aborts an install over it.
222
+ *
223
+ * `skip` names the entries the walk leaves alone: a path it accepts keeps its mode, and a folder
224
+ * it accepts is not entered. The reference overlay passes the backup session's `isKept`, so a file
225
+ * of the user that stays because its move failed keeps the mode the user gave it.
193
226
  */
194
- export async function chmodRecursive(dir, mode, _depth = 0) {
227
+ export async function chmodRecursive(dir, mode, skip, _depth = 0) {
195
228
  if (_depth > MAX_REFERENCE_SWEEP_DEPTH) {
196
229
  throw new Error(`chmodRecursive: descent into ${dir} exceeds the bound of ` +
197
230
  `${MAX_REFERENCE_SWEEP_DEPTH} levels — that subtree keeps the modes it arrived with.`);
@@ -199,8 +232,10 @@ export async function chmodRecursive(dir, mode, _depth = 0) {
199
232
  const entries = await fs.readdir(dir, { withFileTypes: true });
200
233
  for (const entry of entries) {
201
234
  const fullPath = path.join(dir, entry.name);
235
+ if (skip?.(fullPath) === true)
236
+ continue;
202
237
  if (entry.isDirectory()) {
203
- await chmodRecursive(fullPath, mode, _depth + 1);
238
+ await chmodRecursive(fullPath, mode, skip, _depth + 1);
204
239
  }
205
240
  else if (entry.isFile()) {
206
241
  await fs.chmod(fullPath, mode);
@@ -406,7 +441,7 @@ const STAGING_TOKEN = `${process.pid}-${Date.now().toString(36)}`;
406
441
  * arm's basename is `{provider}.{token}.tmp`, which is not dot-prefixed, so a rule keyed
407
442
  * on the name would reach the other arms only.
408
443
  */
409
- function stagingDirFor(referencesTarget, unit) {
444
+ export function stagingDirFor(referencesTarget, unit) {
410
445
  return `${sidecarBaseFor(referencesTarget, unit)}.${STAGING_TOKEN}.tmp`;
411
446
  }
412
447
  /**
@@ -415,7 +450,7 @@ function stagingDirFor(referencesTarget, unit) {
415
450
  * Beside the staging tree, and under `tracker/` for the same reason (property 2 of
416
451
  * {@link stagingDirFor}): a backup a crash strands is removed by a later run's prune.
417
452
  */
418
- function backupDirFor(referencesTarget, unit) {
453
+ export function backupDirFor(referencesTarget, unit) {
419
454
  return `${sidecarBaseFor(referencesTarget, unit)}.old`;
420
455
  }
421
456
  /**
@@ -575,6 +610,10 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
575
610
  * entries the overlay must never replace or delete (D-OVERLAY-FLAT-UNIT), so its
576
611
  * files are exactly the comparison and the neighbours are none of its business.
577
612
  *
613
+ * A directory unit that {@link unitStaysInPlace} is promoted file by file, like a flat set,
614
+ * so a file of the user's that stays in its folder is no sign that the unit is stale: its
615
+ * files are exactly the comparison there too.
616
+ *
578
617
  * What it deliberately does NOT compare is file MODE. D-OVERLAY-MODE-SCOPE normalises the
579
618
  * whole references directory on every run regardless of which units promoted, so a unit
580
619
  * skipped here still has its modes converged — a skipped promotion can hide byte drift
@@ -584,9 +623,9 @@ async function buildUnitStagingTree(unit, sourceRoot, referencesTarget, warn) {
584
623
  * — answers "no". The fallback is always the promotion that was going to happen anyway,
585
624
  * so a failure to compare can only cost a write, never correctness.
586
625
  */
587
- async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir) {
626
+ async function stagedUnitIsAlreadyInstalled(unit, referencesTarget, stagingDir, backups) {
588
627
  const basenameOf = (relPath) => relPath.split('/').slice(-1)[0];
589
- if (unit.kind !== 'cross-cutting') {
628
+ if (unit.kind !== 'cross-cutting' && !unitStaysInPlace(backups, underRoot(referencesTarget, unitSubdir(unit)))) {
590
629
  const owned = new Set(unit.files.map(basenameOf));
591
630
  let installed;
592
631
  try {
@@ -631,6 +670,50 @@ async function restoreDisplacedUnit(backup, target) {
631
670
  return { ok: false, error: String(err) };
632
671
  }
633
672
  }
673
+ /**
674
+ * Does a directory unit's folder hold an entry that must stay where it is?
675
+ *
676
+ * True when the backup session kept an entry at or below the folder (a move that failed, a
677
+ * folder it could not scan), or kept the folder or a folder above it (one over the entry
678
+ * cap, or a root it refused). Such a folder is never swapped out: a swap sets the whole
679
+ * folder aside, and what the session kept in it would go with it. The unit is written into
680
+ * the folder file by file instead, and the prune that follows leaves the kept entries alone.
681
+ */
682
+ function unitStaysInPlace(backups, unitDir) {
683
+ return backups.isKept(unitDir) || backups.keptUnder(unitDir).length > 0;
684
+ }
685
+ /**
686
+ * Rename the documents a staging tree holds into `destDir`, one `rename` each, and drop the
687
+ * staging tree. Shared by the two promotions that write into a folder that stays in place.
688
+ *
689
+ * The directory is created rather than assumed — {@link promoteDirectoryUnit} does the same
690
+ * for its target's parent. The two flat directories the registry emits today exist by the time
691
+ * promotion runs for reasons that have nothing to do with the unit landing in them: the
692
+ * references root is created by {@link overlayGeneratedReferences}, and `tracker/` as a side
693
+ * effect of the flat arm's own staging path. A flat set landing anywhere else takes ENOENT on
694
+ * its first rename — a unit reported as failed for where it was asked to land rather than for
695
+ * anything wrong with the documents it carries.
696
+ *
697
+ * Past the first rename the set is mixed, and there is no directory to swap back
698
+ * (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the rest still
699
+ * carry the previous install's. The state is recorded after each rename so a failure on the
700
+ * next one names both halves instead of claiming the set is untouched.
701
+ *
702
+ * Throws on the first failing rename; the caller reports the state recorded by then.
703
+ */
704
+ async function renameDocumentsInto(files, destDir, stagingDir, record) {
705
+ await fs.mkdir(destDir, { recursive: true });
706
+ for (const [index, relPath] of files.entries()) {
707
+ const basename = relPath.split('/').slice(-1)[0];
708
+ await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
709
+ record({
710
+ kind: 'partially-refreshed',
711
+ refreshed: files.slice(0, index + 1),
712
+ stale: files.slice(index + 1),
713
+ });
714
+ }
715
+ await fs.rm(stagingDir, { recursive: true, force: true });
716
+ }
634
717
  /**
635
718
  * Promote the flat cross-cutting set — one `rename` per document.
636
719
  *
@@ -645,29 +728,7 @@ async function restoreDisplacedUnit(backup, target) {
645
728
  * Throws on the first failing rename; the caller reports the state recorded by then.
646
729
  */
647
730
  async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, record) {
648
- const destDir = underRoot(referencesTarget, unit.dir);
649
- // The directory this set lands in, created rather than assumed — {@link promoteDirectoryUnit}
650
- // does the same for its target's parent. The two flat directories the registry emits today
651
- // exist by the time promotion runs for reasons that have nothing to do with the unit landing
652
- // in them: the references root is created by {@link overlayGeneratedReferences}, and
653
- // `tracker/` as a side effect of this arm's own staging path. A flat set landing anywhere
654
- // else takes ENOENT on its first rename — a unit reported as failed for where it was asked
655
- // to land rather than for anything wrong with the documents it carries.
656
- await fs.mkdir(destDir, { recursive: true });
657
- for (const [index, relPath] of unit.files.entries()) {
658
- const basename = relPath.split('/').slice(-1)[0];
659
- await fs.rename(path.join(stagingDir, basename), path.join(destDir, basename));
660
- // Past the first rename the set is mixed, and there is no directory to swap back
661
- // (D-OVERLAY-FLAT-UNIT). The documents renamed so far carry this run's bytes; the
662
- // rest still carry the previous install's. Recorded after each rename so a failure
663
- // on the next one names both halves instead of claiming the set is untouched.
664
- record({
665
- kind: 'partially-refreshed',
666
- refreshed: unit.files.slice(0, index + 1),
667
- stale: unit.files.slice(index + 1),
668
- });
669
- }
670
- await fs.rm(stagingDir, { recursive: true, force: true });
731
+ await renameDocumentsInto(unit.files, underRoot(referencesTarget, unit.dir), stagingDir, record);
671
732
  }
672
733
  /**
673
734
  * Promote a directory unit (a provider, or `pr/`) — swapped whole, or not at all.
@@ -677,9 +738,17 @@ async function promoteCrossCuttingUnit(unit, referencesTarget, stagingDir, recor
677
738
  * either entirely the previous install or entirely the new one (DR-05, risk P2-g), and a
678
739
  * rename that fails half-way restores the previous one rather than leaving it empty.
679
740
  *
741
+ * A folder that holds an entry which must stay where it is ({@link unitStaysInPlace}), or
742
+ * that cannot be set aside because an earlier `.old` folder still stands beside it, is not
743
+ * swapped: the unit's files are renamed into it one by one, as a flat set is
744
+ * ({@link renameDocumentsInto}), and the prune that follows converges the rest. Nothing here
745
+ * removes a folder with a recursive `rm`: a displaced folder goes through the backup
746
+ * session, which moves what devflow did not ship, removes what it shipped by the list the
747
+ * scan made and ends with a plain `rmdir` ({@link discardDisplacedFolder}).
748
+ *
680
749
  * Throws once the state it left has been recorded; the caller reports it.
681
750
  */
682
- async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record) {
751
+ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups) {
683
752
  const target = underRoot(referencesTarget, unitSubdir(unit));
684
753
  const backup = backupDirFor(referencesTarget, unit);
685
754
  await fs.mkdir(path.dirname(target), { recursive: true });
@@ -696,8 +765,15 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
696
765
  // either is converged away by a later run's tracker-subtree prune (both names end
697
766
  // in neither `/` nor `.md`, so no manifest entry can collide with them) — but the
698
767
  // backup this run is still relying on is exempt from this run's prune, which is
699
- // what `restore-failed` carries the recovery path for.
700
- await fs.rm(backup, { recursive: true, force: true });
768
+ // what `restore-failed` carries the recovery path for. A file of the user's in a
769
+ // stranded `.old` moves to the backup folder first; one that cannot move keeps the
770
+ // folder standing.
771
+ await discardDisplacedFolder(backups, backup, target);
772
+ if ((await directoryState(target)) === 'real'
773
+ && (unitStaysInPlace(backups, target) || (await directoryState(backup)) !== 'absent')) {
774
+ await renameDocumentsInto(unit.files, target, stagingDir, record);
775
+ return;
776
+ }
701
777
  let displaced = false;
702
778
  try {
703
779
  await fs.rename(target, backup);
@@ -722,7 +798,8 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
722
798
  }
723
799
  throw err;
724
800
  }
725
- await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
801
+ if (displaced)
802
+ await discardDisplacedFolder(backups, backup, target).catch(() => undefined);
726
803
  }
727
804
  /**
728
805
  * Promote a fully built staging tree into place, dispatched on what kind of unit it is.
@@ -745,7 +822,7 @@ async function promoteDirectoryUnit(unit, referencesTarget, stagingDir, record)
745
822
  * that failure selects (previous unit restored, not deleted) is exactly the one worth
746
823
  * pinning.
747
824
  */
748
- export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir) {
825
+ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir, backups) {
749
826
  let state = { kind: 'installed-unchanged' };
750
827
  const record = next => { state = next; };
751
828
  try {
@@ -755,7 +832,7 @@ export async function promoteUnitStagingTree(unit, referencesTarget, stagingDir)
755
832
  break;
756
833
  case 'provider':
757
834
  case 'pr-host':
758
- await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record);
835
+ await promoteDirectoryUnit(unit, referencesTarget, stagingDir, record, backups);
759
836
  break;
760
837
  default: {
761
838
  const _exhaustive = unit;
@@ -830,7 +907,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
830
907
  throw new Error(`Generated skill references not found: ${sourceRoot}. ` +
831
908
  `The whole generated tree is absent, so none of the ${manifest.length} references the ` +
832
909
  `devflow:git agent is instructed to load would be installed. ` +
833
- `Run \`npm run build:mds\` to regenerate dist/skills/git/references/ before install ` +
910
+ `Run \`npm run build:mds\` to regenerate ${sourceRoot} before install ` +
834
911
  `(\`npm run build:cli\` alone does not produce it).`);
835
912
  }
836
913
  /**
@@ -872,7 +949,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
872
949
  * coordinate system as the manifest that decided them and as the stranded-skip failure
873
950
  * below, which has always named its subtree from the root.
874
951
  */
875
- async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures) {
952
+ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups) {
876
953
  const subtreeRoot = underRoot(referencesTarget, subtree);
877
954
  const prefix = `${subtree}/`;
878
955
  // Checked here rather than trusted from the unit loop: this is the call that follows
@@ -905,7 +982,7 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
905
982
  }
906
983
  // Keyed by relative path, because `tracker/{provider}/{op}.md` is what distinguishes
907
984
  // two providers' identically named files — the reason mdEntryName cannot serve here.
908
- const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))));
985
+ const swept = await sweepOrphanedReferences(subtreeRoot, new Set(manifest.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))), referenceSweepDelegate(backups));
909
986
  return {
910
987
  scanned: swept.scanned,
911
988
  removed: swept.removed.map(rel => `${prefix}${rel}`),
@@ -925,12 +1002,12 @@ async function prunePreservingRecoveryCopies(referencesTarget, subtree, manifest
925
1002
  * `scanned` sums, which keeps it the non-vacuity counter it is everywhere else: a run
926
1003
  * that scanned nothing scanned nothing in any subtree.
927
1004
  */
928
- async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures) {
1005
+ async function pruneConvergedSubtrees(referencesTarget, manifest, overlayFailures, backups) {
929
1006
  let scanned = 0;
930
1007
  const removed = [];
931
1008
  const failed = [];
932
1009
  for (const subtree of CONVERGED_SUBTREES) {
933
- const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures);
1010
+ const swept = await prunePreservingRecoveryCopies(referencesTarget, subtree, manifest, overlayFailures, backups);
934
1011
  scanned += swept.scanned;
935
1012
  removed.push(...swept.removed);
936
1013
  failed.push(...swept.failed);
@@ -1011,6 +1088,10 @@ export async function overlayGeneratedReferences(opts) {
1011
1088
  if (fault !== null)
1012
1089
  rootFaults.set(subtree, fault);
1013
1090
  }
1091
+ // A unit swap deletes the folder it displaces, and the prune below sweeps what the manifest does
1092
+ // not name. What the user put in a converged subtree therefore moves aside first, before either.
1093
+ // A root that is not a real folder is not scanned; the unit refusal and the prune skip it too.
1094
+ await opts.backups.prepass(CONVERGED_SUBTREES.filter(subtree => !rootFaults.has(subtree)).map(subtree => underRoot(opts.referencesTarget, subtree)));
1014
1095
  for (const unit of planOverlayUnits(manifest)) {
1015
1096
  const fault = subtreesTouchedBy(opts.referencesTarget, unit)
1016
1097
  .map(subtree => rootFaults.get(subtree))
@@ -1036,19 +1117,19 @@ export async function overlayGeneratedReferences(opts) {
1036
1117
  // installed, so `overlaidRefs` names what this run WROTE (AC-23). The discard is the
1037
1118
  // same one both promotion halves perform on their way out; skipping it would leave
1038
1119
  // the `.tmp` residue every other path is asserted not to leave.
1039
- if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir)) {
1120
+ if (await stagedUnitIsAlreadyInstalled(unit, opts.referencesTarget, built.stagingDir, opts.backups)) {
1040
1121
  await fs.rm(built.stagingDir, { recursive: true, force: true }).catch(() => undefined);
1041
1122
  unchangedRefs.push(...unit.files);
1042
1123
  continue;
1043
1124
  }
1044
- const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir);
1125
+ const promoted = await promoteUnitStagingTree(unit, opts.referencesTarget, built.stagingDir, opts.backups);
1045
1126
  if (!promoted.ok) {
1046
1127
  overlayFailures.push({ unit: unitRef(unit), state: promoted.state, error: promoted.error });
1047
1128
  continue;
1048
1129
  }
1049
1130
  overlaidRefs.push(...unit.files);
1050
1131
  }
1051
- const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures);
1132
+ const pruned = await pruneConvergedSubtrees(opts.referencesTarget, manifest, overlayFailures, opts.backups);
1052
1133
  // D-OVERLAY-MODE-SCOPE: normalise the WHOLE references directory, not only the files
1053
1134
  // this run installed. copyDirectory preserves source modes, so a hand-authored
1054
1135
  // reference checked in with an odd mode installs with it; a reference is read-only
@@ -1059,12 +1140,13 @@ export async function overlayGeneratedReferences(opts) {
1059
1140
  // boundary is stated as "never replace or delete" rather than "never touch": the MODE of
1060
1141
  // a hand-authored reference — and of whatever a shadowed skill supplied outside
1061
1142
  // `tracker/` — is normalised here. The prove-you-wrote-it rule permits exactly that: the
1062
- // ownership guard protects deletion, not overwrite.
1143
+ // ownership guard protects deletion, not overwrite. A file of the user that the backup session
1144
+ // kept in place (its move failed) is the exception: its mode stays as the user set it.
1063
1145
  //
1064
1146
  // It is also the one walk that can breach chmodRecursive's descent bound. The catch is
1065
1147
  // that breach's reporting channel, not just an I/O guard (see {@link chmodRecursive}).
1066
1148
  try {
1067
- await chmodRecursive(opts.referencesTarget, 0o644);
1149
+ await chmodRecursive(opts.referencesTarget, 0o644, entry => opts.backups.isKept(entry));
1068
1150
  }
1069
1151
  catch (err) {
1070
1152
  warn(`reference overlay: could not normalise reference file modes — ${String(err)}`);
@@ -1073,6 +1155,46 @@ export async function overlayGeneratedReferences(opts) {
1073
1155
  }
1074
1156
  /** The directory inside an installed skill that the reference overlay converges. */
1075
1157
  const SKILL_REFERENCES_DIRNAME = 'references';
1158
+ /**
1159
+ * Prove, with `lstat`, that the overlay can run below `gitSkillDir`.
1160
+ *
1161
+ * The overlay stages, prunes and sets modes below the `references` entry, and the skill copy
1162
+ * before it writes there too. `mkdir` and `readdir` follow a link, so a link at either level
1163
+ * would send those writes into another directory. An absent entry is fine: the install creates it.
1164
+ * Fail closed: an entry that cannot be inspected is refused, and a link is never repaired,
1165
+ * because the installer did not create it.
1166
+ *
1167
+ * One answer for every caller that has no pre-clean to replace such a link: the converge, a
1168
+ * partial install, and a full install whose pre-clean could not remove one. `lstat` runs on
1169
+ * `references` only below a real skill directory, since through a linked one it would follow the
1170
+ * link. Never throws.
1171
+ */
1172
+ export async function referencesHostState(gitSkillDir) {
1173
+ const skill = await directoryState(gitSkillDir);
1174
+ if (skill === 'absent')
1175
+ return { kind: 'absent' };
1176
+ if (skill !== 'real')
1177
+ return { kind: 'refused', path: gitSkillDir };
1178
+ const referencesDir = path.join(gitSkillDir, SKILL_REFERENCES_DIRNAME);
1179
+ const references = await directoryState(referencesDir);
1180
+ return references === 'absent' || references === 'real'
1181
+ ? { kind: 'ready' }
1182
+ : { kind: 'refused', path: referencesDir };
1183
+ }
1184
+ /**
1185
+ * The report of an overlay refused as a whole: one failure per unit, as a refused converged
1186
+ * root reports (D-CONVERGED-ROOT-REAL), each carrying the state the unit was left in.
1187
+ * The classification reads; nothing is written.
1188
+ */
1189
+ async function refusedOverlayFailures(refusedPath, referencesTarget, manifest) {
1190
+ const error = `${refusedPath} is not a real directory — nothing is copied, staged, pruned or set through it. ` +
1191
+ `Replace it with a real directory and re-run.`;
1192
+ const failures = [];
1193
+ for (const unit of planOverlayUnits(manifest)) {
1194
+ failures.push({ unit: unitRef(unit), state: await classifyUntouchedUnit(unit, referencesTarget), error });
1195
+ }
1196
+ return failures;
1197
+ }
1076
1198
  /**
1077
1199
  * What inside an installed skill directory the reference overlay owns, and the
1078
1200
  * pre-clean must therefore leave standing (D-OVERLAY-OWNERSHIP).
@@ -1119,54 +1241,17 @@ export function overlayOwnedSkillPaths(manifest) {
1119
1241
  return owned;
1120
1242
  }
1121
1243
  /**
1122
- * Empty a directory of everything but the paths another converger owns.
1123
- *
1124
- * `fs.rm(dir)` with a hole in it. `keep` holds directory-relative paths, each preserved
1125
- * whole — a file as itself, a directory with its entire subtree. Everything else is
1126
- * removed exactly as the unconditional pre-clean would have removed it.
1127
- *
1128
- * A kept path is preserved only as a real file or directory. A symlink planted at one is
1129
- * removed like anything else (the link, never its target): the converger that owns the
1130
- * path writes and prunes through it, so a link left standing here would hand that
1131
- * converger someone else's directory (D-CONVERGED-ROOT-REAL). `Dirent` types are
1132
- * lstat-based, which is what makes the check see the link rather than its target.
1133
- *
1134
- * Descent is bounded, and the bound is the `keep` set's own deepest path rather than a
1135
- * constant: the walk only ever descends INTO a directory that still has a kept
1136
- * descendant below it, so there is nothing to look for past that depth. A `keep` set of
1137
- * depth 2 — which is what {@link overlayOwnedSkillPaths} produces for every manifest the
1138
- * build emits — walks two levels and `fs.rm`s the rest recursively in one call.
1139
- *
1140
- * An unreadable directory is left alone rather than reported: the caller already
1141
- * swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
1142
- * read its target has nothing to remove from it.
1244
+ * Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
1245
+ * path. A path that is still absent, or a link again, is not a replacement and is not reported: the
1246
+ * step that should have written there failed or was refused, and it reports that failure itself.
1247
+ * Bounded by `removed`, which holds at most the kept paths of one pre-clean.
1143
1248
  */
1144
- async function emptyDirectoryExcept(dir, keep) {
1145
- const kept = [...keep];
1146
- const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
1147
- const walk = async (current, rel, depth) => {
1148
- let entries;
1149
- try {
1150
- entries = await fs.readdir(current, { withFileTypes: true });
1151
- }
1152
- catch {
1153
- return;
1154
- }
1155
- for (const entry of entries) {
1156
- const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
1157
- if (keep.has(entryRel) && !entry.isSymbolicLink())
1158
- continue;
1159
- const holdsSomethingKept = entry.isDirectory()
1160
- && depth < maxDepth
1161
- && kept.some(relPath => relPath.startsWith(`${entryRel}/`));
1162
- if (holdsSomethingKept) {
1163
- await walk(path.join(current, entry.name), entryRel, depth + 1);
1164
- continue;
1165
- }
1166
- await fs.rm(path.join(current, entry.name), { recursive: true, force: true });
1167
- }
1168
- };
1169
- await walk(dir, '', 1);
1249
+ async function reportLinksOnceWritten(report, removed) {
1250
+ for (const linkPath of removed) {
1251
+ const stat = await fs.lstat(linkPath).catch(() => undefined);
1252
+ if (stat !== undefined && !stat.isSymbolicLink())
1253
+ report.replacedLinks.push(linkPath);
1254
+ }
1170
1255
  }
1171
1256
  // ---------------------------------------------------------------------------
1172
1257
  // Script composer
@@ -1190,6 +1275,9 @@ function collectRelativeImports(source) {
1190
1275
  * under ~/.devflow/scripts/ at the same relative-to-dist/ paths
1191
1276
  * (c) ~/.devflow/scripts/package.json → {"type":"module"}
1192
1277
  *
1278
+ * The copy leaves out the orchestrator charter and the implement sentinel. Install the
1279
+ * directory with {@link installScripts}, which writes both after the copy.
1280
+ *
1193
1281
  * Frozen externally-referenced paths:
1194
1282
  * ~/.devflow/scripts/hooks/run-hook (hook bootstrap entry)
1195
1283
  * ~/.devflow/scripts/hud.sh (HUD entry script)
@@ -1204,9 +1292,19 @@ export async function composeScripts(scriptsTarget) {
1204
1292
  // mirrored dist/hud/ closure), so a re-run gave package.json an exec bit the first
1205
1293
  // run never did and re-init stopped being a no-op on disk (#388 AC-3). Scoping the
1206
1294
  // chmod to the copied entries makes the first and every later run agree.
1295
+ //
1296
+ // D-CHARTER-INSTALL-RENDER: the orchestrator charter and the implement sentinel are
1297
+ // NOT part of this verbatim copy. The charter is installed as a render of the packaged
1298
+ // source for the installed plugin set, and the sentinel exists only while implement is
1299
+ // installed; `installRenderedCharter` is the only writer of both, so no copy here can put
1300
+ // the full source charter or a stale sentinel back over a render.
1207
1301
  const srcScripts = scriptsDir();
1302
+ const rendered = new Set([
1303
+ path.join(srcScripts, CHARTER_RELPATH),
1304
+ path.join(srcScripts, HANDOFF_SENTINEL_RELPATH),
1305
+ ]);
1208
1306
  try {
1209
- await copyDirectory(srcScripts, scriptsTarget);
1307
+ await copyDirectory(srcScripts, scriptsTarget, rendered);
1210
1308
  if (process.platform !== 'win32') {
1211
1309
  for (const entry of await fs.readdir(srcScripts, { withFileTypes: true })) {
1212
1310
  const copied = path.join(scriptsTarget, entry.name);
@@ -1268,6 +1366,23 @@ export async function composeScripts(scriptsTarget) {
1268
1366
  }
1269
1367
  catch { /* already exists from prior install — leave as-is */ }
1270
1368
  }
1369
+ /**
1370
+ * Install the scripts directory whole: the verbatim copy of {@link composeScripts}, then the orchestrator
1371
+ * charter and the implement sentinel it skips.
1372
+ *
1373
+ * D-CHARTER-INSTALL-RENDER: a tree from `composeScripts` alone has no charter, so every caller outside this
1374
+ * module goes through here and none pairs the two steps by hand. The render is its own step, outside the
1375
+ * copy's catch-all, so its failure handling is the writer's own: a warning, never an abort.
1376
+ */
1377
+ export async function installScripts(opts) {
1378
+ await composeScripts(opts.scriptsTarget);
1379
+ await installRenderedCharter({
1380
+ scriptsDir: opts.scriptsTarget,
1381
+ plugins: opts.plugins,
1382
+ warn: opts.warn,
1383
+ mode: 'install',
1384
+ });
1385
+ }
1271
1386
  /**
1272
1387
  * Install one agent that carries the tracker-server stamp: the shipped source with
1273
1388
  * `servers` appended to its `tools:` line, written in one step so no copy of it ever sits on
@@ -1344,7 +1459,7 @@ async function firstExisting(candidates) {
1344
1459
  * installed in its place).
1345
1460
  *
1346
1461
  * The ONE spelling of that decision: the install loop and
1347
- * convergeCurationVariants both call it, so a skill the curation toggle installs
1462
+ * convergeVariantAxes both call it, so a skill the curation toggle installs
1348
1463
  * later is shadowed exactly as one the install installed.
1349
1464
  *
1350
1465
  * The shipped source is stat-checked FIRST, even under a valid shadow, so an
@@ -1383,8 +1498,8 @@ export async function resolveSkillSource(skillName, devflowDir, packageRoot) {
1383
1498
  * validateSkillShadow at install time. This only answers "did the user write
1384
1499
  * one?", which is what dormancy reporting turns on.
1385
1500
  */
1386
- async function listShadowedSkills(devflowDir) {
1387
- const registry = new Set(getAllSkillNames());
1501
+ async function listShadowedSkills(devflowDir, folderWorkflows) {
1502
+ const registry = new Set(getAllSkillNames(folderWorkflows));
1388
1503
  let entries;
1389
1504
  try {
1390
1505
  entries = await fs.readdir(path.join(devflowDir, 'skills'));
@@ -1408,29 +1523,274 @@ function recordSweep(report, kind, sweep) {
1408
1523
  * report which of them were actually there to remove.
1409
1524
  *
1410
1525
  * `names` is registry arithmetic (the skills no selected plugin owns), so most of
1411
- * it is routinely absent from disk. Only the deleter can say what it deleted:
1412
- * `fs.rm` is called WITHOUT `force`, so an absent directory rejects with ENOENT —
1413
- * "nothing to remove", neither a removal nor a failure. Reporting the plan instead
1414
- * would tell the user about deletions that never happened.
1415
- *
1416
- * Per-item failure isolation, never throws: any other rejection is recorded in
1417
- * `failed` and the remaining names are still attempted.
1526
+ * it is routinely absent from disk. Only the deleter can say what it deleted: an
1527
+ * absent folder is "nothing to remove", neither a removal nor a failure. Reporting
1528
+ * the plan instead would tell the user about deletions that never happened.
1529
+ *
1530
+ * A folder goes by the entries the backup session listed, then a plain `rmdir`. The
1531
+ * pre-pass has moved every file devflow did not ship, so a folder that still holds
1532
+ * an entry (a move that failed) stays whole and is not reported as removed. A link
1533
+ * at the folder is unlinked and its target is left as it is; a path that is not a
1534
+ * folder is a failure and stays.
1535
+ *
1536
+ * Per-item failure isolation, never throws: a failure is recorded in `failed`
1537
+ * and the remaining names are still attempted.
1418
1538
  */
1419
- async function removeDeselectedSkills(skillsDir, names) {
1539
+ async function removeDeselectedSkills(skillsDir, names, backups) {
1420
1540
  const removed = [];
1421
1541
  const failed = [];
1422
1542
  for (const name of names) {
1543
+ const folder = path.join(skillsDir, prefixSkillName(name));
1544
+ const state = await directoryState(folder);
1545
+ switch (state) {
1546
+ case 'absent':
1547
+ break;
1548
+ case 'link':
1549
+ if ((await unlinkLinkAt(folder)) === 'unlinked')
1550
+ removed.push(name);
1551
+ else
1552
+ failed.push({ name, error: new Error('a link that could not be removed') });
1553
+ break;
1554
+ case 'unusable':
1555
+ failed.push({ name, error: new Error('not a folder, so nothing was removed') });
1556
+ break;
1557
+ case 'real': {
1558
+ const result = await backups.removeListed(folder);
1559
+ if (result.folderRemoved)
1560
+ removed.push(name);
1561
+ break;
1562
+ }
1563
+ default: {
1564
+ const unreachable = state;
1565
+ void unreachable;
1566
+ }
1567
+ }
1568
+ }
1569
+ return { removed, failed };
1570
+ }
1571
+ /** The kind of the entry at `entryPath`, read with `lstat` so a link is a link; null when it cannot be read. */
1572
+ async function entryKindAt(entryPath) {
1573
+ try {
1574
+ const stat = await fs.lstat(entryPath);
1575
+ if (stat.isSymbolicLink())
1576
+ return 'link';
1577
+ if (stat.isDirectory())
1578
+ return 'directory';
1579
+ return stat.isFile() ? 'file' : 'special';
1580
+ }
1581
+ catch {
1582
+ return null;
1583
+ }
1584
+ }
1585
+ /**
1586
+ * The `mayRemove` check of a flat registry-diff sweep. It admits a file or link that the backup
1587
+ * session classifies as devflow's own (a retired or superseded file, a leftover of an interrupted
1588
+ * write) and nothing else. An unknown entry was moved by the pre-pass or stays where it is, and a
1589
+ * folder is never admitted: the sweep removes what this check admits with `unlink`, so an entry
1590
+ * that turns into a folder after the check is not removed. An entry the session kept, or one in a
1591
+ * folder it kept (over the entry cap, or one it could not scan), is not admitted either: the run
1592
+ * said it would leave that folder whole.
1593
+ */
1594
+ function removesOnlyDevflowFiles(backups) {
1595
+ return async (entryPath) => {
1596
+ if (backups.isKept(entryPath))
1597
+ return false;
1598
+ const kind = await entryKindAt(entryPath);
1599
+ if (kind === null || kind === 'directory')
1600
+ return false;
1601
+ return backups.classifyAt(entryPath, kind) !== 'unknown';
1602
+ };
1603
+ }
1604
+ /**
1605
+ * The delegate the reference prune acts through: it decides, per entry the manifest does not name,
1606
+ * between keeping a file of the user's and removing one of devflow's own.
1607
+ *
1608
+ * An unknown entry moves whole to the backup folder, with one rename. An entry the session
1609
+ * classifies as devflow's own (a retired or superseded reference, a leftover of an interrupted
1610
+ * write) is removed: a file or link by `unlink`, a folder by the files the session lists and a plain
1611
+ * `rmdir`, after its unknown entries moved. A folder is never removed recursively. A failed move is
1612
+ * in the session's record and the entry stays; a failed unlink reaches the sweep as a failure.
1613
+ *
1614
+ * An entry the session kept stays as it is, with no second attempt: a move that failed earlier in
1615
+ * the run, or a folder over the entry cap or one the scan could not read, and everything in it.
1616
+ */
1617
+ export function referenceSweepDelegate(backups) {
1618
+ return {
1619
+ async dispose(entry, isDirectory) {
1620
+ if (backups.isKept(entry))
1621
+ return { outcome: 'kept' };
1622
+ const kind = isDirectory ? 'directory' : (await entryKindAt(entry)) ?? 'file';
1623
+ if (backups.classifyAt(entry, kind) === 'unknown') {
1624
+ const moved = await backups.moveWhole(entry);
1625
+ return { outcome: moved.status === 'moved' ? 'moved' : 'kept' };
1626
+ }
1627
+ if (kind === 'directory') {
1628
+ await backups.prepass([entry]);
1629
+ const removal = await backups.removeListed(entry);
1630
+ return { outcome: removal.folderRemoved ? 'removed' : 'kept' };
1631
+ }
1632
+ try {
1633
+ await fs.unlink(entry);
1634
+ return { outcome: 'removed' };
1635
+ }
1636
+ catch (err) {
1637
+ if (err.code === 'ENOENT')
1638
+ return { outcome: 'kept' };
1639
+ return { outcome: 'failed', error: err };
1640
+ }
1641
+ },
1642
+ };
1643
+ }
1644
+ /**
1645
+ * Whether the run may move and remove below `root`: it is a real folder. An absent root has nothing
1646
+ * to scan. A link at a root the pre-clean replaces (`replaceLink`) is unlinked and reported, as the
1647
+ * pre-clean always did, and its target is not walked. A link that stays, or a path that is not a
1648
+ * folder, is a refused root: no move and no removal below it, and `refuseUnrealFolder` names it
1649
+ * later in the run.
1650
+ */
1651
+ async function mayChangeBelow(root, report, replaceLink) {
1652
+ const state = await directoryState(root);
1653
+ if (state === 'real')
1654
+ return true;
1655
+ if (state === 'link' && replaceLink && (await unlinkLinkAt(root)) === 'unlinked')
1656
+ report.replacedLinks.push(root);
1657
+ return false;
1658
+ }
1659
+ /** Whether `relative` (a path below a skill folder) is an overlay-owned path or lies below one. */
1660
+ function isUnderOverlayOwnedPath(relative, owned) {
1661
+ for (const ownedPath of owned) {
1662
+ if (relative === ownedPath || relative.startsWith(`${ownedPath}/`))
1663
+ return true;
1664
+ }
1665
+ return false;
1666
+ }
1667
+ /**
1668
+ * Whether the top-level entry that `relative` starts with is a `devflow:*` skill folder outside the
1669
+ * registry: the folders the skill registry-diff sweep would remove.
1670
+ */
1671
+ function isUnregisteredSkillFolder(relative, registry) {
1672
+ const top = relative.split('/', 1)[0];
1673
+ return top.startsWith(SKILL_NAMESPACE) && !registry.has(unprefixSkillName(top));
1674
+ }
1675
+ /**
1676
+ * Unlink a link that stands at an overlay-owned path or above one, inside the references skill
1677
+ * `skillDir`. The overlay writes and prunes through those paths, so a link left there would hand it
1678
+ * another folder. Each unlinked link is returned for {@link reportLinksOnceWritten}, since it is
1679
+ * replaced only once the overlay has written a real entry. Bounded by the owned set.
1680
+ */
1681
+ async function unlinkOverlayOwnedLinks(skillDir, owned) {
1682
+ const paths = new Set();
1683
+ for (const rel of owned) {
1684
+ const segments = rel.split('/');
1685
+ for (let depth = 1; depth <= segments.length; depth++)
1686
+ paths.add(segments.slice(0, depth).join('/'));
1687
+ }
1688
+ const unlinked = [];
1689
+ for (const rel of [...paths].sort((a, b) => a.split('/').length - b.split('/').length || (a < b ? -1 : 1))) {
1690
+ const entry = path.join(skillDir, ...rel.split('/'));
1691
+ if ((await unlinkLinkAt(entry)) === 'unlinked')
1692
+ unlinked.push(entry);
1693
+ }
1694
+ return unlinked;
1695
+ }
1696
+ /** The most variant files a refusal names; the count says how many more there are. */
1697
+ const MAX_REPORTED_MISSING_VARIANTS = 5;
1698
+ /** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
1699
+ function namedVariantFiles(items) {
1700
+ const named = items.slice(0, MAX_REPORTED_MISSING_VARIANTS).join(', ');
1701
+ const more = items.length > MAX_REPORTED_MISSING_VARIANTS ? ` and ${items.length - MAX_REPORTED_MISSING_VARIANTS} more` : '';
1702
+ return `${named}${more}`;
1703
+ }
1704
+ /**
1705
+ * The source lists the copy reads, most-preferred first. The check in
1706
+ * {@link requireComplianceVariantFiles} and the copy in {@link installCommandsAndAgents}
1707
+ * both take their lists from here, so the check looks at the files the copy takes.
1708
+ * An injected agent list wins as given.
1709
+ */
1710
+ function copySourceDirs(opts) {
1711
+ const { curation, compliance, packageRoot } = opts;
1712
+ return {
1713
+ commands: commandSourceDirs({ curation, compliance }, packageRoot),
1714
+ agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, { curation, compliance }),
1715
+ };
1716
+ }
1717
+ /**
1718
+ * Refuse a compliance-off install that cannot be trusted to carry the right text, before
1719
+ * any file is written. Compliance on reads nothing and returns.
1720
+ *
1721
+ * With compliance off, the installer must know which compiled prompts carry a compliance
1722
+ * arm: the build records that in dist/variant-axes.json, and the installer reads it here
1723
+ * rather than guess from the files it finds. A prompt with a compliance arm has no safe
1724
+ * fall-through, because its normal file, and on a both-off machine its curation-off file,
1725
+ * carry the compliance text the machine has switched off. So the install is refused when
1726
+ * - the manifest is absent, unreadable or not valid (the build has not run, or only
1727
+ * `build:cli` did); or
1728
+ * - a compliance-armed prompt has no file for the machine's EXACT switches
1729
+ * (requiredComplianceVariantFiles names the file: a both-off machine needs the file
1730
+ * built for both axes, never the curation-off one); or
1731
+ * - the copy would take another file for that prompt: the first existing file of its
1732
+ * kind and name over the copy's own source list (copySourceDirs) is not the required
1733
+ * file. A prompt whose list holds no file at all is left to the copy's own refusal.
1734
+ * A prompt with no compliance arm is not checked: it falls through to its normal file.
1735
+ *
1736
+ * The same refusal shape as {@link requireGeneratedTree}: one throw, before the unit loop,
1737
+ * naming the build step. Only the file reads, so it leaves no partial install behind.
1738
+ */
1739
+ async function requireComplianceVariantFiles(opts) {
1740
+ const { curation, compliance, packageRoot } = opts;
1741
+ if (compliance)
1742
+ return;
1743
+ const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
1744
+ const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
1745
+ `(\`npm run build:cli\` alone does not produce them).`;
1746
+ let text;
1747
+ try {
1748
+ text = await fs.readFile(manifestFile, 'utf-8');
1749
+ }
1750
+ catch (err) {
1751
+ const reason = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
1752
+ throw new Error(`Variant manifest ${reason}: ${manifestFile}. ` +
1753
+ `With compliance off the installer needs it to find the prompts that carry a compliance arm, so no file was written. ${remedy}`);
1754
+ }
1755
+ const parsed = parseVariantManifest(text);
1756
+ if (!parsed.ok) {
1757
+ throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
1758
+ `No file was written. ${remedy}`);
1759
+ }
1760
+ const sourceDirs = copySourceDirs({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1761
+ const machine = `curation ${curation ? 'on' : 'off'} and compliance off`;
1762
+ const missing = [];
1763
+ const shadowed = [];
1764
+ for (const host of parsed.value) {
1765
+ const [rel] = requiredComplianceVariantFiles({ curation, compliance }, [host]);
1766
+ if (rel === undefined)
1767
+ continue;
1768
+ const required = path.join(packageRoot, ...rel.split('/'));
1769
+ let present = false;
1423
1770
  try {
1424
- await fs.rm(path.join(skillsDir, prefixSkillName(name)), { recursive: true });
1425
- removed.push(name);
1771
+ present = (await fs.stat(required)).isFile();
1426
1772
  }
1427
- catch (err) {
1428
- if (err.code === 'ENOENT')
1429
- continue;
1430
- failed.push({ name, error: err });
1773
+ catch { /* absent — reported below */ }
1774
+ if (!present) {
1775
+ missing.push(rel);
1776
+ continue;
1777
+ }
1778
+ const dirs = host.kind === 'commands' ? sourceDirs.commands : sourceDirs.agents;
1779
+ const taken = await firstExisting(dirs.map(dir => path.join(dir, mdFileName(host.name))));
1780
+ if (taken !== undefined && path.resolve(taken) !== path.resolve(required)) {
1781
+ shadowed.push(`${rel} (the copy would take ${path.relative(packageRoot, taken).split(path.sep).join('/')})`);
1431
1782
  }
1432
1783
  }
1433
- return { removed, failed };
1784
+ if (missing.length > 0) {
1785
+ throw new Error(`Variant file not found for ${missing.length} prompt(s) that carry a compliance arm, on a machine with ` +
1786
+ `${machine}: ${namedVariantFiles(missing)}. ` +
1787
+ `Another variant of the same prompt would carry the wrong text, so no file was written. ${remedy}`);
1788
+ }
1789
+ if (shadowed.length > 0) {
1790
+ throw new Error(`Another file comes first for ${shadowed.length} prompt(s) that carry a compliance arm, on a machine with ` +
1791
+ `${machine}: ${namedVariantFiles(shadowed)}. ` +
1792
+ `The install would carry the wrong text, so no file was written. ${remedy}`);
1793
+ }
1434
1794
  }
1435
1795
  /**
1436
1796
  * Write the commands and the agents of `plugins` into `claudeDir`, and nothing else:
@@ -1438,10 +1798,13 @@ async function removeDeselectedSkills(skillsDir, names) {
1438
1798
  * the selective uninstall, which rewrites the files of the plugins that stay installed
1439
1799
  * after it removed a retired agent.
1440
1800
  *
1441
- * Commands come from dist/commands/{name}.md, or — with curation off — the curation-off
1442
- * variant dist/curation-off/commands/{name}.md when the host has one
1801
+ * Commands come from dist/commands/{name}.md, or — with an axis off — the off variant of
1802
+ * the host when it has one, such as dist/curation-off/commands/{name}.md
1443
1803
  * (D-LEARNING-VARIANT-INSTALL; commandSourceDirs owns the order). A declared command
1444
- * with no compiled source file is a hard error, not a skip.
1804
+ * with no compiled source file is a hard error, not a skip. With compliance off the copy
1805
+ * first checks that every compliance-armed prompt has its file for the machine's exact
1806
+ * switches and that the copy takes that file, and refuses before it writes anything
1807
+ * otherwise ({@link requireComplianceVariantFiles}).
1445
1808
  *
1446
1809
  * Agents resolve dist-first with a src fallback: dist/agents/{name}.md (compiled from an
1447
1810
  * .mds generator host) wins over src/assets/agents/{name}.md. A declared agent absent from
@@ -1458,32 +1821,45 @@ async function removeDeselectedSkills(skillsDir, names) {
1458
1821
  * known and leaves it alone.
1459
1822
  */
1460
1823
  export async function installCommandsAndAgents(opts) {
1461
- const { plugins, agentsMap, claudeDir, curation, trackerServers, warn } = opts;
1824
+ const { plugins, agentsMap, claudeDir, curation, compliance, trackerServers, warn } = opts;
1825
+ const skip = opts.skip ?? {};
1826
+ const packageRoot = opts.packageRoot ?? getPackageRoot();
1827
+ // Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
1828
+ // this call covers the caller that reaches the copy alone (the selective uninstall).
1829
+ await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1830
+ const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
1831
+ curation,
1832
+ compliance,
1833
+ packageRoot,
1834
+ agentSourceDirs: opts.agentSourceDirs,
1835
+ });
1462
1836
  const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1463
- const commandDirs = commandSourceDirs(curation);
1837
+ const folderWorkflows = opts.folderWorkflows ?? WORKFLOW_FOLDER_NAMES;
1464
1838
  const commandsSourceNames = new Set();
1465
1839
  for (const plugin of plugins) {
1466
1840
  for (const cmd of plugin.commands) {
1467
1841
  const name = cmd.startsWith('/') ? cmd.slice(1) : cmd;
1468
- commandsSourceNames.add(name);
1842
+ // D-WORKFLOW-FOLDER-INSTALL: a workflow that ships as a skill folder has no command file.
1843
+ if (!folderWorkflows.has(name))
1844
+ commandsSourceNames.add(name);
1469
1845
  }
1470
1846
  }
1471
- if (commandsSourceNames.size > 0) {
1847
+ if (commandsSourceNames.size > 0 && skip.commands !== true) {
1472
1848
  await fs.mkdir(commandsTarget, { recursive: true });
1473
1849
  for (const name of commandsSourceNames) {
1474
1850
  const srcFile = await firstExisting(commandDirs.map(dir => path.join(dir, mdFileName(name))));
1475
1851
  if (srcFile === undefined) {
1476
1852
  // The error names the curation-ON path: that file exists for every declared
1477
1853
  // command, while a curation-off file exists only for a host with an arm.
1478
- throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(true)[0], mdFileName(name))}. ` +
1854
+ throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(ALL_SWITCHES_ON, packageRoot)[0], mdFileName(name))}. ` +
1479
1855
  `Ensure build:mds ran successfully before install.`);
1480
1856
  }
1481
- await fs.copyFile(srcFile, path.join(commandsTarget, mdFileName(name)));
1857
+ const commandTarget = path.join(commandsTarget, mdFileName(name));
1858
+ if (await clearLinkForWrite(commandTarget, opts.links))
1859
+ await fs.copyFile(srcFile, commandTarget);
1482
1860
  }
1483
1861
  }
1484
1862
  const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1485
- // Curation-aware (D-LEARNING-VARIANT-INSTALL). An injected list wins as given.
1486
- const agentDirs = opts.agentSourceDirs ?? agentSourceDirs(undefined, curation);
1487
1863
  const allAgentNames = new Set();
1488
1864
  for (const plugin of plugins) {
1489
1865
  for (const agent of plugin.agents) {
@@ -1494,7 +1870,7 @@ export async function installCommandsAndAgents(opts) {
1494
1870
  }
1495
1871
  }
1496
1872
  }
1497
- if (allAgentNames.size > 0) {
1873
+ if (allAgentNames.size > 0 && skip.agents !== true) {
1498
1874
  await fs.mkdir(agentsTarget, { recursive: true });
1499
1875
  for (const agentName of allAgentNames) {
1500
1876
  if (await skipsForUserOwnedType(agentName, claudeDir, agentsTarget, warn))
@@ -1502,11 +1878,13 @@ export async function installCommandsAndAgents(opts) {
1502
1878
  const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
1503
1879
  const srcFile = await firstExisting(candidates);
1504
1880
  if (srcFile === undefined) {
1505
- throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs()[0], mdFileName(agentName))}. ` +
1881
+ throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs(packageRoot)[0], mdFileName(agentName))}. ` +
1506
1882
  `Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
1507
1883
  `ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
1508
1884
  }
1509
1885
  const agentTarget = path.join(agentsTarget, mdFileName(agentName));
1886
+ if (!(await clearLinkForWrite(agentTarget, opts.links)))
1887
+ continue;
1510
1888
  if (isTrackerStampedAgent(agentName)) {
1511
1889
  await writeStampedAgent(srcFile, agentTarget, trackerServers, warn);
1512
1890
  }
@@ -1524,7 +1902,48 @@ export async function installCommandsAndAgents(opts) {
1524
1902
  * Returns an InstallReport describing which shadows were applied and which were skipped.
1525
1903
  */
1526
1904
  export async function installViaFileCopy(options) {
1527
- const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, } = options;
1905
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
1906
+ const packageRoot = options.packageRoot ?? getPackageRoot();
1907
+ // D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
1908
+ // empty, and with it every folder step below is a no-op over an empty set.
1909
+ const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
1910
+ const folderWorkflows = workflowFolderNames(folderRegistry);
1911
+ const folderSwitches = { curation, compliance };
1912
+ const folderFiles = new Map();
1913
+ /** The files of one registered folder, read once. A failed read is a reason and never a throw. */
1914
+ const readFolder = async (folder) => {
1915
+ const cached = folderFiles.get(folder.name);
1916
+ if (cached !== undefined)
1917
+ return { ok: true, files: cached };
1918
+ const read = await readWorkflowFolder(folder, folderSwitches, options.workflowFoldersRoot ?? packageRoot);
1919
+ if (read.ok)
1920
+ folderFiles.set(folder.name, read.files);
1921
+ return read;
1922
+ };
1923
+ const readFolders = async (names) => {
1924
+ for (const folder of folderRegistry) {
1925
+ if (!names.has(folder.name))
1926
+ continue;
1927
+ const read = await readFolder(folder);
1928
+ if (!read.ok)
1929
+ throw new Error(read.reason);
1930
+ }
1931
+ };
1932
+ // The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
1933
+ // must leave the installed tree as it found it.
1934
+ await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: options.agentSourceDirs });
1935
+ // The folders this run installs are read from the exact-key tree before the pre-clean for the
1936
+ // same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
1937
+ await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
1938
+ // The references the machine's compliance switch installs: the manifest, and the ONE source
1939
+ // root they are overlaid from. With compliance off the root is the compliance-off tree, and its
1940
+ // absence refuses the install here, before the pre-clean, so a refused install leaves the
1941
+ // installed tree as it found it and never falls through to the compliance-on references (AC-50).
1942
+ const refsManifest = installedReferenceManifest({ compliance });
1943
+ const refsSourceRoot = skillRefsSourceDir(compliance, packageRoot);
1944
+ if (omitCurationGatedSkills(skillsMap, curation).has(SKILL_REFS_SKILL_NAME)) {
1945
+ await requireGeneratedTree(refsSourceRoot, refsManifest);
1946
+ }
1528
1947
  const report = {
1529
1948
  shadowedSkills: [],
1530
1949
  shadowedRules: [],
@@ -1534,8 +1953,11 @@ export async function installViaFileCopy(options) {
1534
1953
  overlaidRefs: [],
1535
1954
  unchangedRefs: [],
1536
1955
  overlayFailures: [],
1956
+ replacedLinks: [],
1957
+ refusedFolders: [],
1537
1958
  removedSkills: [],
1538
1959
  dormantShadows: [],
1960
+ backups: backups.snapshot(),
1539
1961
  };
1540
1962
  // The skill decision, made in full before anything is touched — which skills
1541
1963
  // this selection installs, which it removes, and which shadows it leaves inert.
@@ -1551,39 +1973,122 @@ export async function installViaFileCopy(options) {
1551
1973
  const skillPlan = resolveSkillInstallPlan({
1552
1974
  effectivePlugins: options.effectivePlugins ?? plugins,
1553
1975
  isPartialInstall,
1554
- shadowedSkills: await listShadowedSkills(devflowDir),
1976
+ shadowedSkills: await listShadowedSkills(devflowDir, folderWorkflows),
1977
+ folderWorkflows,
1555
1978
  });
1556
1979
  report.dormantShadows = [...skillPlan.dormantShadows];
1557
- // Clean old Devflow files before installing
1558
- spinner.message('Cleaning old files...');
1980
+ // D-WORKFLOW-SHADOW-REFUSAL, install half: a folder installs from the build and has no source to
1981
+ // overlay, so a shadow of its name is ignored and kept. The warning says so; it is not dormant.
1982
+ for (const name of skillPlan.ignoredFolderShadows) {
1983
+ warn(`The shadow of skill "${name}" is ignored: it ships as a skill folder from the build, which a shadow cannot replace. The shadow was kept.`);
1984
+ }
1985
+ // Links the pre-clean removed at a path that a later step of this install writes. Each is reported
1986
+ // as replaced only once a real file or folder stands there (reportLinksOnceWritten).
1987
+ const awaitingWrite = [];
1988
+ const skillsRoot = path.join(claudeDir, 'skills');
1989
+ const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1990
+ const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1991
+ const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1992
+ const skillFolder = (name) => path.join(skillsRoot, prefixSkillName(name));
1993
+ const registrySkills = new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]);
1994
+ const overlayOwned = overlayOwnedSkillPaths(refsManifest);
1995
+ const trackerAgentFile = mdFileName(TRACKER_AGENT_NAME);
1996
+ // Every root this run would change is checked first, with the same link checks the install uses
1997
+ // for its writes, and before the first move or removal. A refused root (a link that stays, or a
1998
+ // path that is not a folder) and everything below it get no move and no removal; the other roots
1999
+ // go on. A full install replaces a link at a root, as its pre-clean always did, and does not walk
2000
+ // the folder the link points to.
2001
+ const wipedFolders = [];
2002
+ const prepassTargets = [];
2003
+ const skillsRootState = await directoryState(skillsRoot);
2004
+ const mayChangeSkills = skillsRootState === 'real';
2005
+ // A skills root that is a link or not a folder is refused once, for every step below it: the
2006
+ // pre-pass, the sweeps, the skill copies and the overlay, the exact-name removals and the
2007
+ // replacement of a link at a workflow folder. Each of them would move, remove or write inside the
2008
+ // folder the link points to. The refusal is named in `refusedFolders`, and the install summary says
2009
+ // that nothing was written there. An absent root is fine: the skill copies create it.
2010
+ const skillsRefused = skillsRootState === 'link' || skillsRootState === 'unusable';
2011
+ if (skillsRefused)
2012
+ report.refusedFolders.push(skillsRoot);
1559
2013
  if (!isPartialInstall) {
1560
2014
  // Commands and agents are plugin-scoped — only wipe on full install.
1561
2015
  // On partial installs the registry-diff sweeps below handle stale entries
1562
2016
  // without discarding assets from plugins not included in this run.
1563
- const oldDirs = [
1564
- path.join(claudeDir, 'commands', 'devflow'),
1565
- path.join(claudeDir, 'rules', 'devflow'),
1566
- ];
1567
- for (const dir of oldDirs) {
1568
- try {
1569
- await fs.rm(dir, { recursive: true, force: true });
1570
- }
1571
- catch { /* ignore */ }
2017
+ for (const dir of [commandsTarget, rulesTarget]) {
2018
+ if (!(await mayChangeBelow(dir, report, true)))
2019
+ continue;
2020
+ prepassTargets.push({ folder: dir });
2021
+ wipedFolders.push(dir);
1572
2022
  }
1573
2023
  // D-TRACKER-AGENT-OWNER, pre-clean half — the same split the reference tree
1574
2024
  // needed (D-OVERLAY-OWNERSHIP), for the same reason. The agent directory is
1575
2025
  // emptied AROUND the one file `convergeTrackerArtifacts` owns: taking it
1576
2026
  // would leave converge with nothing to byte-compare against, so a
1577
2027
  // steady-state re-init would re-copy the agent and announce
1578
- // `tracker agent installed` on every run. Everything else is removed
1579
- // exactly as the unconditional wipe removed it, and the file is still
1580
- // converged on this run — drift in it is restored, so preserving it strands
1581
- // nothing.
1582
- try {
1583
- await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]));
2028
+ // `tracker agent installed` on every run. The scan leaves that file out, and
2029
+ // the file is still converged on this run — drift in it is restored, so
2030
+ // preserving it strands nothing.
2031
+ //
2032
+ // A link at that file is left standing: the converge replaces it with a real file and reports it
2033
+ // once it has written, so the install does not claim a replacement the converge may not make.
2034
+ if (await mayChangeBelow(agentsTarget, report, true)) {
2035
+ prepassTargets.push({ folder: agentsTarget, include: relative => relative !== trackerAgentFile });
2036
+ wipedFolders.push(agentsTarget);
2037
+ }
2038
+ // The skill folders this run reinstalls, and the ones it removes. The references skill is
2039
+ // scanned around the paths the overlay owns (D-OVERLAY-OWNERSHIP): the overlay converges them
2040
+ // and moves what the user put in them, so the scan leaves them out.
2041
+ if (mayChangeSkills) {
2042
+ for (const skill of skillsMap.keys()) {
2043
+ const target = skillFolder(skill);
2044
+ if (!(await mayChangeBelow(target, report, true)))
2045
+ continue;
2046
+ if (skill === SKILL_REFS_SKILL_NAME) {
2047
+ awaitingWrite.push(...await unlinkOverlayOwnedLinks(target, overlayOwned));
2048
+ prepassTargets.push({ folder: target, include: relative => !isUnderOverlayOwnedPath(relative, overlayOwned) });
2049
+ }
2050
+ else {
2051
+ prepassTargets.push({ folder: target });
2052
+ }
2053
+ wipedFolders.push(target);
2054
+ }
2055
+ for (const name of [...skillPlan.remove, ...skillPlan.removeFolders]) {
2056
+ const target = skillFolder(name);
2057
+ if ((await directoryState(target)) === 'real')
2058
+ prepassTargets.push({ folder: target });
2059
+ }
2060
+ }
2061
+ }
2062
+ else {
2063
+ // Only what the registry-diff sweeps below would remove: the unknown .md files of the commands
2064
+ // and agents folders. Files of other kinds stay, as they always did on a partial install.
2065
+ for (const dir of [commandsTarget, agentsTarget]) {
2066
+ if (await mayChangeBelow(dir, report, false)) {
2067
+ prepassTargets.push({ folder: dir, include: relative => mdEntryName(relative) !== null });
2068
+ }
1584
2069
  }
1585
- catch { /* ignore */ }
1586
2070
  }
2071
+ // The devflow:* folders outside the registry, on every shape: a retired folder goes by its listed
2072
+ // files and anything else moves whole.
2073
+ if (mayChangeSkills) {
2074
+ prepassTargets.push({ folder: skillsRoot, include: relative => isUnregisteredSkillFolder(relative, registrySkills) });
2075
+ }
2076
+ const prepass = await backups.prepass(prepassTargets);
2077
+ const movedFromAgents = prepass.moved.some(unit => isContainedIn(agentsTarget, unit.original));
2078
+ // Clean old Devflow files before installing: each folder loses the entries the pre-pass listed as
2079
+ // devflow's own, then a plain rmdir. A folder that still holds a file the pre-pass could not move stays.
2080
+ spinner.message('Cleaning old files...');
2081
+ for (const folder of wipedFolders)
2082
+ await backups.removeListed(folder);
2083
+ // The commands, agents and rules folders, each proved a real folder (or absent) before any write
2084
+ // below. A full install's pre-clean replaced a link at each of them. A partial install has no
2085
+ // pre-clean, and a link the pre-clean could not remove is still standing, so every write below
2086
+ // would go through it into another directory: the sweeps, the copies and the restamp. A refused
2087
+ // folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
2088
+ // only when the selection has a rule, so only then is it proved.
2089
+ const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
2090
+ const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
2091
+ const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
1587
2092
  // Sweep stale devflow:* skill dirs — ungated: runs on every install shape,
1588
2093
  // including partial installs, so renamed/deleted skills are pruned promptly.
1589
2094
  // knownNames spans ALL plugins (getAllSkillNames) so skills from uninstalled
@@ -1596,12 +2101,20 @@ export async function installViaFileCopy(options) {
1596
2101
  // are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
1597
2102
  // runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
1598
2103
  // then re-materialized by converge, creating a false-orphan report on every install.
1599
- recordSweep(report, 'skill', await sweepOrphanedAssets(path.join(claudeDir, 'skills'), new Set([...getAllSkillNames(), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null));
1600
- // Pre-clean the prefixed install targets before re-copying so stale content
1601
- // never bleeds into a fresh install. Bare pre-namespace dirs at
1602
- // ~/.claude/skills/{name} are owned solely by the frozen LEGACY_SKILL_NAMES
1603
- // pass in init.ts (runs immediately after this call). A bare dir whose name
1604
- // matches a current registry skill is by construction foreign to Devflow and
2104
+ //
2105
+ // The pre-pass has already moved every devflow:* folder outside the registry that holds a file of
2106
+ // the user's, so this sweep removes nothing itself: a retired folder goes by the files the session
2107
+ // listed, and a folder the pre-pass could not move stays where it is.
2108
+ if (mayChangeSkills) {
2109
+ recordSweep(report, 'skill', await sweepOrphanedAssets(skillsRoot, registrySkills, (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null, async (entryPath) => {
2110
+ await backups.removeListed(entryPath);
2111
+ return false;
2112
+ }));
2113
+ }
2114
+ // The prefixed install targets were pre-cleaned above, before re-copying, so stale content never
2115
+ // bleeds into a fresh install. Bare pre-namespace dirs at ~/.claude/skills/{name} are owned
2116
+ // solely by the frozen LEGACY_SKILL_NAMES pass in init.ts (runs immediately after this call). A
2117
+ // bare dir whose name matches a current registry skill is by construction foreign to Devflow and
1605
2118
  // must not be touched here.
1606
2119
  //
1607
2120
  // The pre-clean is SCOPED to what this run reinstalls and the orphan sweep
@@ -1632,21 +2145,17 @@ export async function installViaFileCopy(options) {
1632
2145
  // Everything else in the directory is still emptied, so a stale hand-authored skill
1633
2146
  // file — including a reference at the references ROOT, which the overlay may replace
1634
2147
  // but never delete (D-OVERLAY-FLAT-UNIT) — does not survive a full install.
1635
- if (!isPartialInstall) {
1636
- const overlayOwned = overlayOwnedSkillPaths(installedReferenceManifest());
1637
- for (const skill of skillsMap.keys()) {
1638
- // Empty the prefixed directory (its contents are re-created during the install
1639
- // phase), minus whatever another converger owns inside it.
1640
- const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
1641
- try {
1642
- if (skill === SKILL_REFS_SKILL_NAME) {
1643
- await emptyDirectoryExcept(target, overlayOwned);
1644
- }
1645
- else {
1646
- await fs.rm(target, { recursive: true, force: true });
1647
- }
1648
- }
1649
- catch { /* ignore */ }
2148
+ //
2149
+ // A target that is a link is unlinked and reported in `replacedLinks`, never emptied
2150
+ // through: the folder it points to is not Devflow's, and the install below writes a real
2151
+ // folder in its place.
2152
+ if (!isPartialInstall && !skillsRefused) {
2153
+ // A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
2154
+ // for a skill. A link it cannot remove is refused by the install below.
2155
+ for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
2156
+ const target = skillFolder(name);
2157
+ if ((await unlinkLinkAt(target)) === 'unlinked')
2158
+ report.replacedLinks.push(target);
1650
2159
  }
1651
2160
  }
1652
2161
  // Remove the skills no selected plugin owns or requires — the deselection half
@@ -1655,10 +2164,12 @@ export async function installViaFileCopy(options) {
1655
2164
  // (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
1656
2165
  // was on disk and is now gone: the plan is registry arithmetic and mostly names
1657
2166
  // skills that were never installed.
1658
- const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), skillPlan.remove);
1659
- report.removedSkills.push(...deselected.removed);
1660
- for (const failure of deselected.failed) {
1661
- warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
2167
+ if (mayChangeSkills) {
2168
+ const deselected = await removeDeselectedSkills(skillsRoot, [...skillPlan.remove, ...skillPlan.removeFolders], backups);
2169
+ report.removedSkills.push(...deselected.removed);
2170
+ for (const failure of deselected.failed) {
2171
+ warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
2172
+ }
1662
2173
  }
1663
2174
  // Retired agents. The sweep runs BEFORE the copy so the copy can see what it found:
1664
2175
  // an agent file whose name left the registry means a previous release is installed
@@ -1668,16 +2179,21 @@ export async function installViaFileCopy(options) {
1668
2179
  // Ungated: runs on every install shape. knownNames spans ALL plugins
1669
2180
  // (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
1670
2181
  // names absent from the full registry are removed.
1671
- const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1672
- const agentSweep = await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName);
2182
+ //
2183
+ // The sweep removes only what the backup session classifies as devflow's own (a retired name, a
2184
+ // leftover of an interrupted write): an unknown file was moved by the pre-pass or stays.
2185
+ const agentSweep = agentsRefused
2186
+ ? { scanned: 0, removed: [], failed: [] }
2187
+ : await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink');
1673
2188
  recordSweep(report, 'agent', agentSweep);
1674
2189
  // A partial install writes the selected plugins' commands and agents and leaves every
1675
2190
  // other plugin's files alone, which is only safe while those files still name agents
1676
2191
  // that exist. After a retired agent was removed they do not, so a partial install
1677
2192
  // writes the commands and agents of every plugin the manifest records instead. The
1678
2193
  // widening is conditional because an ordinary add-one run has nothing to repair and
1679
- // must not rewrite files the user did not ask about.
1680
- const widenToInstalled = isPartialInstall && agentSweep.removed.length > 0;
2194
+ // must not rewrite files the user did not ask about. An agent file the pre-pass moved out of the
2195
+ // folder is the same sign: it came from an earlier release, so the commands may still name it.
2196
+ const widenToInstalled = isPartialInstall && (agentSweep.removed.length > 0 || movedFromAgents);
1681
2197
  const commandAgentPlugins = widenToInstalled ? (options.effectivePlugins ?? plugins) : plugins;
1682
2198
  const commandAgentMap = widenToInstalled ? buildAssetMaps(commandAgentPlugins).agentsMap : agentsMap;
1683
2199
  // Install commands and agents (D-TRACKER-AGENT-OWNER and the source-resolution rules
@@ -1688,26 +2204,37 @@ export async function installViaFileCopy(options) {
1688
2204
  agentsMap: commandAgentMap,
1689
2205
  claudeDir,
1690
2206
  curation,
2207
+ compliance,
2208
+ packageRoot,
1691
2209
  trackerServers,
1692
2210
  agentSourceDirs: options.agentSourceDirs,
2211
+ skip: { commands: commandsRefused, agents: agentsRefused },
2212
+ links: report,
2213
+ folderWorkflows,
1693
2214
  warn,
1694
2215
  });
1695
2216
  // Sweep stale command files — ungated: runs on every install shape.
1696
2217
  // knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
1697
2218
  // plugins survive a partial run. Only names absent from the full registry are removed.
1698
- recordSweep(report, 'command', await sweepOrphanedAssets(path.join(claudeDir, 'commands', 'devflow'), new Set(getAllCommandNames()), mdEntryName));
2219
+ // A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
2220
+ if (!commandsRefused) {
2221
+ recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName, removesOnlyDevflowFiles(backups), 'unlink'));
2222
+ }
1699
2223
  // D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
1700
2224
  // installed /code-review, on every install shape. This is the language gate the command reads
1701
2225
  // (no run-time probe of Claude Code's directory), so it runs after the copy and the sweep and
1702
2226
  // whether or not code-review's own plugin is in this run: `--plugin=devflow-typescript` copies no
1703
2227
  // command, yet must update a code-review an earlier run installed. The copy above wrote the shipped
1704
2228
  // `(none)` line; the rewrite compares against what is on disk now, so neither that copy nor the
1705
- // pre-clean can defeat it. A failure is a warning, never an abort.
1706
- await restampInstalledCommands({
1707
- claudeDir,
1708
- effectivePlugins: options.effectivePlugins ?? plugins,
1709
- warn,
1710
- });
2229
+ // pre-clean can defeat it. A failure is a warning, never an abort. A refused commands folder is not
2230
+ // restamped: the rewrite would go through the link.
2231
+ if (!commandsRefused) {
2232
+ await restampInstalledCommands({
2233
+ claudeDir,
2234
+ effectivePlugins: options.effectivePlugins ?? plugins,
2235
+ warn,
2236
+ });
2237
+ }
1711
2238
  // Install skills from ALL plugins (skillsMap covers all plugins, not just selected).
1712
2239
  // Resolved from flat src/assets/skills/{name}/ (no per-plugin subdirectory).
1713
2240
  // A declared skill whose source directory is absent is a build/packaging failure
@@ -1716,12 +2243,36 @@ export async function installViaFileCopy(options) {
1716
2243
  // D-LEARNING-VARIANT-INSTALL: a curation-off machine skips the curation-gated
1717
2244
  // skills here. The pre-clean above walked the UNFILTERED map, so on a full
1718
2245
  // install a leftover copy is already gone; a partial install leaves removal to
1719
- // convergeCurationVariants, which init runs straight after this call.
2246
+ // convergeVariantAxes, which init runs straight after this call.
2247
+ //
2248
+ // A refused skills root (a link or not a folder) gets no skill: every copy, and the overlay
2249
+ // below it, would write into the folder the link points to.
1720
2250
  spinner.message('Installing skills...');
1721
- for (const [skillName] of omitCurationGatedSkills(skillsMap, curation)) {
1722
- const resolved = await resolveSkillSource(skillName, devflowDir);
2251
+ const skillsToInstall = skillsRefused ? [] : [...omitCurationGatedSkills(skillsMap, curation).keys()];
2252
+ for (const skillName of skillsToInstall) {
1723
2253
  const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
1724
- await copyDirectory(resolved.dir, skillTarget);
2254
+ // A full install's pre-clean replaced a link at the skill or its references entry. A partial
2255
+ // install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
2256
+ // copy and the overlay below would write through such a link into another directory: the whole
2257
+ // skill is refused instead and listed in `refusedFolders`. The references skill is also reported
2258
+ // like a refused overlay, one failure for each unit.
2259
+ if (skillName === SKILL_REFS_SKILL_NAME) {
2260
+ const host = await referencesHostState(skillTarget);
2261
+ if (host.kind === 'refused') {
2262
+ report.refusedFolders.push(host.path);
2263
+ report.overlayFailures.push(...await refusedOverlayFailures(host.path, path.join(skillTarget, SKILL_REFERENCES_DIRNAME), refsManifest));
2264
+ continue;
2265
+ }
2266
+ }
2267
+ else if (await refuseUnrealFolder(report, skillTarget)) {
2268
+ continue;
2269
+ }
2270
+ // With compliance off the copy leaves out the posting files of COMPLIANCE_OFF_REMOVED_SKILL_FILES.
2271
+ // They are named below the source the copy reads, shadow or shipped, so the posting text is never
2272
+ // on the machine, even when the install stops or a removal is refused later. With compliance on
2273
+ // nothing is excluded.
2274
+ const resolved = await resolveSkillSource(skillName, devflowDir);
2275
+ await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
1725
2276
  if (resolved.shadow === 'valid') {
1726
2277
  report.shadowedSkills.push(skillName);
1727
2278
  }
@@ -1737,8 +2288,11 @@ export async function installViaFileCopy(options) {
1737
2288
  referencesTarget: path.join(skillTarget, 'references'),
1738
2289
  // Every provider's mechanics (D-INSTALL-ALL-PROVIDERS). The overlay
1739
2290
  // converges rather than merges, so a retired generated document is pruned.
1740
- manifest: installedReferenceManifest(),
2291
+ // The manifest and the source root follow the machine compliance switch together.
2292
+ sourceRoot: refsSourceRoot,
2293
+ manifest: refsManifest,
1741
2294
  warn,
2295
+ backups,
1742
2296
  });
1743
2297
  report.overlaidRefs.push(...overlay.overlaidRefs);
1744
2298
  report.unchangedRefs.push(...overlay.unchangedRefs);
@@ -1746,13 +2300,48 @@ export async function installViaFileCopy(options) {
1746
2300
  recordSweep(report, 'reference', overlay.pruned);
1747
2301
  }
1748
2302
  }
2303
+ // The skill copy and the overlay have written. A link the pre-clean removed at a path they write is
2304
+ // reported now if a real file or folder stands there, and not at all if a unit failed or was refused.
2305
+ await reportLinksOnceWritten(report, awaitingWrite);
2306
+ // With compliance off, the flat skill files that only the posting steps load are removed by
2307
+ // exact name (COMPLIANCE_OFF_REMOVED_SKILL_FILES), on every install shape. The copy above wrote
2308
+ // none of them; this step removes the ones an earlier compliance-on install left, which a partial
2309
+ // install copies over without clearing. A removal that was refused is a reported failure and
2310
+ // never an abort; the compliance converge that init runs next fails closed on the same files.
2311
+ // A refused skills root is not entered: the removal would reach files in the folder a link points to.
2312
+ if (!compliance && !skillsRefused) {
2313
+ const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
2314
+ for (const refusal of removal.refused) {
2315
+ report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
2316
+ }
2317
+ }
2318
+ // D-WORKFLOW-FOLDER-INSTALL: the workflow skill folders of the plugins whose commands this run
2319
+ // writes, each from the exact-key tree read above. Before the scripts step, so the hand-off
2320
+ // sentinel that step installs never stands without the folder it names. A widened run may add a
2321
+ // folder the early read did not cover. Each folder is read, installed and reported on its own, and
2322
+ // a read failure is a warning, since the pre-clean has run: one broken folder never hides the next.
2323
+ const foldersToInstall = selectedWorkflowFolders(commandAgentPlugins, folderWorkflows);
2324
+ if (foldersToInstall.size > 0)
2325
+ spinner.message('Installing workflow skills...');
2326
+ for (const folder of folderRegistry) {
2327
+ if (!foldersToInstall.has(folder.name))
2328
+ continue;
2329
+ const read = await readFolder(folder);
2330
+ if (!read.ok) {
2331
+ warn(`workflow skill folder: ${read.reason}`);
2332
+ continue;
2333
+ }
2334
+ const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report, backups });
2335
+ if (installed.outcome === 'failed') {
2336
+ warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
2337
+ }
2338
+ }
1749
2339
  // Install rules from selected plugins (rulesMap covers selected plugins only).
1750
2340
  // Rules are flat .md files resolved from src/assets/rules/{name}.md (no per-plugin subdir).
1751
2341
  spinner.message('Installing rules...');
1752
- const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1753
- if (rulesMap.size > 0) {
2342
+ if (rulesMap.size > 0 && !rulesRefused) {
1754
2343
  await fs.mkdir(rulesTarget, { recursive: true });
1755
- const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget);
2344
+ const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget, report);
1756
2345
  for (const { ruleName, outcome } of outcomes) {
1757
2346
  if (outcome === 'shadow') {
1758
2347
  report.shadowedRules.push(ruleName);
@@ -1769,8 +2358,15 @@ export async function installViaFileCopy(options) {
1769
2358
  // (no dev tooling, no raw TypeScript).
1770
2359
  spinner.message('Installing scripts...');
1771
2360
  const scriptsTarget = path.join(devflowDir, 'scripts');
1772
- await composeScripts(scriptsTarget);
2361
+ // The charter and the sentinel render from the EFFECTIVE selection (a partial install
2362
+ // keeps what earlier runs installed).
2363
+ await installScripts({
2364
+ scriptsTarget,
2365
+ plugins: options.effectivePlugins ?? plugins,
2366
+ warn,
2367
+ });
1773
2368
  spinner.stop('Components installed via file copy');
2369
+ report.backups = backups.snapshot();
1774
2370
  return report;
1775
2371
  }
1776
2372
  //# sourceMappingURL=installer.js.map