devflow-kit 3.8.0 → 3.10.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 (211) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +6 -6
  3. package/dist/agents/code.md +10 -10
  4. package/dist/agents/fix.md +7 -7
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/harden.md +7 -6
  7. package/dist/agents/refactor.md +6 -6
  8. package/dist/agents/skim.md +1 -1
  9. package/dist/agents/triage.md +7 -0
  10. package/dist/cli/commands/ambient.js +10 -2
  11. package/dist/cli/commands/compliance-prompts.js +9 -4
  12. package/dist/cli/commands/compliance.js +171 -24
  13. package/dist/cli/commands/curation.js +79 -23
  14. package/dist/cli/commands/gate-level-prompts.js +39 -23
  15. package/dist/cli/commands/gates.js +6 -7
  16. package/dist/cli/commands/init.js +150 -145
  17. package/dist/cli/commands/install-report.js +34 -0
  18. package/dist/cli/commands/legacy-sweep.js +64 -0
  19. package/dist/cli/commands/memory.js +59 -12
  20. package/dist/cli/commands/proxy.js +6 -1
  21. package/dist/cli/commands/rules.js +29 -12
  22. package/dist/cli/commands/skills.js +42 -9
  23. package/dist/cli/commands/tracker.js +9 -3
  24. package/dist/cli/commands/uninstall.js +354 -60
  25. package/dist/commands/bug-analysis.md +71 -122
  26. package/dist/commands/code-review.md +11 -11
  27. package/dist/commands/debug.md +2 -2
  28. package/dist/commands/dynamic-build.md +25 -25
  29. package/dist/commands/dynamic-plan.md +2 -2
  30. package/dist/commands/dynamic-profile.md +2 -2
  31. package/dist/commands/dynamic-tickets.md +2 -2
  32. package/dist/commands/explore.md +2 -2
  33. package/dist/commands/implement.md +48 -34
  34. package/dist/commands/plan.md +2 -2
  35. package/dist/commands/release.md +3 -3
  36. package/dist/commands/research.md +2 -2
  37. package/dist/commands/resolve.md +50 -46
  38. package/dist/commands/self-review.md +10 -9
  39. package/dist/compliance-off/agents/git.md +120 -0
  40. package/dist/compliance-off/agents/triage.md +180 -0
  41. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  42. package/dist/compliance-off/commands/code-review.md +520 -0
  43. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  44. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  45. package/dist/compliance-off/commands/implement.md +736 -0
  46. package/dist/compliance-off/commands/plan.md +671 -0
  47. package/dist/compliance-off/commands/release.md +309 -0
  48. package/dist/compliance-off/commands/resolve.md +742 -0
  49. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  50. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  51. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  52. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  53. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  54. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  55. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  56. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  57. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  58. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  59. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  60. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  61. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  62. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  63. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  64. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  65. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  66. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  67. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  68. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  69. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  70. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  71. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  72. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  73. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  74. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  75. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  76. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  77. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  78. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  79. package/dist/core/agent-models.js +19 -4
  80. package/dist/core/area-pause-notice.js +44 -0
  81. package/dist/core/assets.js +126 -17
  82. package/dist/core/compliance-off-files.js +27 -0
  83. package/dist/core/curation-move-skip.js +4 -5
  84. package/dist/core/curation-queue-cleanup.js +6 -5
  85. package/dist/core/curation-store.js +1 -0
  86. package/dist/core/curation-variants.js +460 -112
  87. package/dist/core/evidence-policy.js +10 -8
  88. package/dist/core/flags.js +2 -2
  89. package/dist/core/installed-variants.js +63 -0
  90. package/dist/core/manifest.js +48 -5
  91. package/dist/core/mds-variants.js +700 -3
  92. package/dist/core/orphan-sweep.js +11 -2
  93. package/dist/core/plugins.js +69 -12
  94. package/dist/core/tracked-areas.js +138 -14
  95. package/dist/curation-compliance-off/agents/triage.md +176 -0
  96. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  97. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  98. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  99. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  100. package/dist/curation-compliance-off/commands/implement.md +689 -0
  101. package/dist/curation-compliance-off/commands/plan.md +631 -0
  102. package/dist/curation-compliance-off/commands/release.md +279 -0
  103. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  104. package/dist/curation-off/agents/code.md +10 -10
  105. package/dist/curation-off/agents/fix.md +7 -7
  106. package/dist/curation-off/agents/harden.md +7 -6
  107. package/dist/curation-off/agents/refactor.md +6 -6
  108. package/dist/curation-off/agents/triage.md +7 -0
  109. package/dist/curation-off/commands/bug-analysis.md +69 -119
  110. package/dist/curation-off/commands/code-review.md +11 -11
  111. package/dist/curation-off/commands/debug.md +2 -2
  112. package/dist/curation-off/commands/dynamic-build.md +25 -25
  113. package/dist/curation-off/commands/explore.md +2 -2
  114. package/dist/curation-off/commands/implement.md +48 -34
  115. package/dist/curation-off/commands/plan.md +2 -2
  116. package/dist/curation-off/commands/release.md +1 -1
  117. package/dist/curation-off/commands/resolve.md +50 -46
  118. package/dist/curation-off/commands/self-review.md +10 -9
  119. package/dist/hud/components/curation-counts.js +27 -0
  120. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  121. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  122. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  123. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  124. package/dist/skills/git/references/publication-gate.md +6 -7
  125. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  126. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  127. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  128. package/dist/targets/claude-code/behavior-install.js +23 -9
  129. package/dist/targets/claude-code/charter-render.js +374 -0
  130. package/dist/targets/claude-code/compliance-install.js +32 -12
  131. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  132. package/dist/targets/claude-code/curation-install.js +570 -100
  133. package/dist/targets/claude-code/installer.js +467 -62
  134. package/dist/targets/claude-code/language-stamp.js +6 -0
  135. package/dist/targets/claude-code/linked-folder.js +182 -0
  136. package/dist/targets/claude-code/reference-converge.js +118 -0
  137. package/dist/targets/claude-code/tracker-install.js +21 -8
  138. package/dist/targets/claude-code/tracker-servers.js +20 -6
  139. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  140. package/dist/variant-axes.json +109 -0
  141. package/package.json +1 -1
  142. package/src/assets/agents/_partials/_code.mds +6 -6
  143. package/src/assets/agents/code.mds +4 -4
  144. package/src/assets/agents/evaluate.md +7 -13
  145. package/src/assets/agents/fix.mds +1 -1
  146. package/src/assets/agents/git.mds +34 -2
  147. package/src/assets/agents/harden.mds +7 -6
  148. package/src/assets/agents/skim.mds +1 -1
  149. package/src/assets/agents/test.md +21 -12
  150. package/src/assets/agents/triage.mds +9 -0
  151. package/src/assets/agents/validate.md +27 -34
  152. package/src/assets/commands/_partials/_compliance.mds +5 -3
  153. package/src/assets/commands/_partials/_decisions.mds +10 -8
  154. package/src/assets/commands/_partials/_engine.mds +9 -8
  155. package/src/assets/commands/_partials/_publication.mds +10 -1
  156. package/src/assets/commands/_partials/_settings.mds +2 -2
  157. package/src/assets/commands/bug-analysis.mds +80 -126
  158. package/src/assets/commands/code-review.mds +52 -9
  159. package/src/assets/commands/dynamic-build.mds +109 -15
  160. package/src/assets/commands/dynamic-tickets.mds +13 -0
  161. package/src/assets/commands/implement.mds +145 -33
  162. package/src/assets/commands/plan.mds +19 -0
  163. package/src/assets/commands/release.mds +50 -1
  164. package/src/assets/commands/resolve.mds +229 -44
  165. package/src/assets/commands/self-review.mds +8 -7
  166. package/src/assets/mds/git/_ops.mds +29 -1
  167. package/src/assets/mds/git/_ops_pr.mds +20 -10
  168. package/src/assets/mds/git/_ops_steps.mds +27 -0
  169. package/src/assets/mds/git/_pr.mds +15 -15
  170. package/src/assets/mds/git/_references.mds +12 -7
  171. package/src/assets/mds/tracker/_common.mds +4 -0
  172. package/src/assets/mds/tracker/_github.mds +43 -4
  173. package/src/assets/mds/tracker/_jira.mds +44 -4
  174. package/src/assets/mds/tracker/_linear.mds +44 -4
  175. package/src/assets/rules/quality.md +2 -0
  176. package/src/assets/scripts/ci-wait.cjs +493 -118
  177. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  178. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  179. package/src/assets/scripts/hooks/background-memory-update +79 -1
  180. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  181. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  182. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  183. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  184. package/src/assets/scripts/hooks/memory-permission +10 -2
  185. package/src/assets/scripts/hooks/memory-worker +11 -0
  186. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  187. package/src/assets/scripts/hooks/preamble +18 -8
  188. package/src/assets/scripts/hooks/queue-append +218 -1
  189. package/src/assets/scripts/hooks/session-start-context +89 -37
  190. package/src/assets/scripts/hooks/session-start-memory +39 -0
  191. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  192. package/src/assets/scripts/pr-evidence.cjs +46 -4
  193. package/src/assets/scripts/resolve-settings.cjs +244 -30
  194. package/src/assets/scripts/verify-evidence.cjs +708 -124
  195. package/src/assets/skills/agent-references/SKILL.md +7 -1
  196. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  197. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  198. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  199. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  200. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  201. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  202. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  203. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  204. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  205. package/src/assets/skills/git/SKILL.md +3 -3
  206. package/src/assets/skills/git/references/github-api.md +2 -139
  207. package/src/assets/skills/git/references/github-posting.md +158 -0
  208. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  209. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  210. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  211. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -1,18 +1,23 @@
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 { clearLinkForWrite, directoryState, refuseUnrealFolder, removeReportingLink, unlinkLinkAt } from './linked-folder.js';
13
+ import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
10
14
  import { TRACKER_AGENT_NAME } from './tracker-install.js';
11
15
  import { isTrackerStampedAgent } from './tracker-servers.js';
12
16
  import { stampAgentTools } from '../../core/agent-frontmatter.js';
13
17
  import { TRACKER_MCP_SERVERS_DEFAULT, trackerServerTools } from '../../core/tracker.js';
14
18
  import { BUILTIN_OVERRIDE_AGENTS, findForeignAgentOwner } from './agent-type-owner.js';
15
19
  import { restampInstalledCommands } from './language-stamp.js';
20
+ import { CHARTER_RELPATH, HANDOFF_SENTINEL_RELPATH, installRenderedCharter, } from './charter-render.js';
16
21
  // ---------------------------------------------------------------------------
17
22
  // Shadow validation helpers (exported — reused by Step 4 list commands)
18
23
  // ---------------------------------------------------------------------------
@@ -85,13 +90,20 @@ export async function validateRuleShadow(shadowFile) {
85
90
  * copy failures (EACCES, ENOSPC, etc.) are still isolated
86
91
  * (one bad copy does not abort the whole batch).
87
92
  * Invalid shadows still warn-and-install-source.
93
+ *
94
+ * A link at the target file is unlinked and replaced by a real file, never written through, and
95
+ * recorded in `links` when given. A link that cannot be removed is recorded as refused and the
96
+ * outcome is `skipped`.
88
97
  */
89
- export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
98
+ export async function installRuleFile(ruleName, devflowDir, rulesTarget, links) {
90
99
  const shadowFile = path.join(devflowDir, 'rules', mdFileName(ruleName));
91
100
  const targetFile = path.join(rulesTarget, mdFileName(ruleName));
92
101
  const ruleSource = path.join(rulesDir(), mdFileName(ruleName));
102
+ // Asked once, so a link that cannot be removed is recorded once.
103
+ let writable;
104
+ const mayWrite = async () => (writable ??= await clearLinkForWrite(targetFile, links));
93
105
  const shadowState = await validateRuleShadow(shadowFile);
94
- if (shadowState === 'valid') {
106
+ if (shadowState === 'valid' && await mayWrite()) {
95
107
  try {
96
108
  await fs.copyFile(shadowFile, targetFile);
97
109
  return 'shadow';
@@ -121,6 +133,8 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
121
133
  }
122
134
  // Copy is isolated: a copy failure degrades to 'skipped' so one
123
135
  // bad rule does not abort the entire installAllRules Promise.all batch.
136
+ if (!(await mayWrite()))
137
+ return 'skipped';
124
138
  try {
125
139
  await fs.copyFile(ruleSource, targetFile);
126
140
  return invalidShadowOutcome ?? 'source';
@@ -138,9 +152,9 @@ export async function installRuleFile(ruleName, devflowDir, rulesTarget) {
138
152
  * `rules --enable` (renders per-rule log lines). One place computes;
139
153
  * callers present.
140
154
  */
141
- export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
155
+ export async function installAllRules(rulesMap, devflowDir, rulesTarget, links) {
142
156
  return Promise.all([...rulesMap.keys()].map(async (ruleName) => {
143
- const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget);
157
+ const outcome = await installRuleFile(ruleName, devflowDir, rulesTarget, links);
144
158
  return { ruleName, outcome };
145
159
  }));
146
160
  }
@@ -148,18 +162,32 @@ export async function installAllRules(rulesMap, devflowDir, rulesTarget) {
148
162
  // Directory utilities
149
163
  // ---------------------------------------------------------------------------
150
164
  /**
151
- * Recursively copy a directory tree.
165
+ * Recursively copy a directory tree. `exclude` holds absolute SOURCE paths that the copy
166
+ * skips (a file, or a directory with everything below it); omitted, everything is copied.
167
+ *
168
+ * `links` is given when `dest` is inside a Devflow install folder. The copy then checks, with
169
+ * `lstat`, each folder and file it is about to write, and never writes through a link: the link is
170
+ * unlinked and a real folder or file is written in its place (recorded in `links.replacedLinks`),
171
+ * and a link that cannot be removed is skipped with everything below it (recorded in
172
+ * `links.refusedFolders`). Omitted, the copy is the plain one, for a `dest` that is the user's own
173
+ * content.
152
174
  */
153
- export async function copyDirectory(src, dest) {
175
+ export async function copyDirectory(src, dest, exclude, links) {
176
+ if (links !== undefined && !(await clearLinkForWrite(dest, links)))
177
+ return;
154
178
  await fs.mkdir(dest, { recursive: true });
155
179
  const entries = await fs.readdir(src, { withFileTypes: true });
156
180
  for (const entry of entries) {
157
181
  const srcPath = path.join(src, entry.name);
158
182
  const destPath = path.join(dest, entry.name);
183
+ if (exclude?.has(srcPath))
184
+ continue;
159
185
  if (entry.isDirectory()) {
160
- await copyDirectory(srcPath, destPath);
186
+ await copyDirectory(srcPath, destPath, exclude, links);
161
187
  }
162
188
  else {
189
+ if (links !== undefined && !(await clearLinkForWrite(destPath, links)))
190
+ continue;
163
191
  await fs.copyFile(srcPath, destPath);
164
192
  }
165
193
  }
@@ -830,7 +858,7 @@ async function requireGeneratedTree(sourceRoot, manifest) {
830
858
  throw new Error(`Generated skill references not found: ${sourceRoot}. ` +
831
859
  `The whole generated tree is absent, so none of the ${manifest.length} references the ` +
832
860
  `devflow:git agent is instructed to load would be installed. ` +
833
- `Run \`npm run build:mds\` to regenerate dist/skills/git/references/ before install ` +
861
+ `Run \`npm run build:mds\` to regenerate ${sourceRoot} before install ` +
834
862
  `(\`npm run build:cli\` alone does not produce it).`);
835
863
  }
836
864
  /**
@@ -1073,6 +1101,46 @@ export async function overlayGeneratedReferences(opts) {
1073
1101
  }
1074
1102
  /** The directory inside an installed skill that the reference overlay converges. */
1075
1103
  const SKILL_REFERENCES_DIRNAME = 'references';
1104
+ /**
1105
+ * Prove, with `lstat`, that the overlay can run below `gitSkillDir`.
1106
+ *
1107
+ * The overlay stages, prunes and sets modes below the `references` entry, and the skill copy
1108
+ * before it writes there too. `mkdir` and `readdir` follow a link, so a link at either level
1109
+ * would send those writes into another directory. An absent entry is fine: the install creates it.
1110
+ * Fail closed: an entry that cannot be inspected is refused, and a link is never repaired,
1111
+ * because the installer did not create it.
1112
+ *
1113
+ * One answer for every caller that has no pre-clean to replace such a link: the converge, a
1114
+ * partial install, and a full install whose pre-clean could not remove one. `lstat` runs on
1115
+ * `references` only below a real skill directory, since through a linked one it would follow the
1116
+ * link. Never throws.
1117
+ */
1118
+ export async function referencesHostState(gitSkillDir) {
1119
+ const skill = await directoryState(gitSkillDir);
1120
+ if (skill === 'absent')
1121
+ return { kind: 'absent' };
1122
+ if (skill !== 'real')
1123
+ return { kind: 'refused', path: gitSkillDir };
1124
+ const referencesDir = path.join(gitSkillDir, SKILL_REFERENCES_DIRNAME);
1125
+ const references = await directoryState(referencesDir);
1126
+ return references === 'absent' || references === 'real'
1127
+ ? { kind: 'ready' }
1128
+ : { kind: 'refused', path: referencesDir };
1129
+ }
1130
+ /**
1131
+ * The report of an overlay refused as a whole: one failure per unit, as a refused converged
1132
+ * root reports (D-CONVERGED-ROOT-REAL), each carrying the state the unit was left in.
1133
+ * The classification reads; nothing is written.
1134
+ */
1135
+ async function refusedOverlayFailures(refusedPath, referencesTarget, manifest) {
1136
+ const error = `${refusedPath} is not a real directory — nothing is copied, staged, pruned or set through it. ` +
1137
+ `Replace it with a real directory and re-run.`;
1138
+ const failures = [];
1139
+ for (const unit of planOverlayUnits(manifest)) {
1140
+ failures.push({ unit: unitRef(unit), state: await classifyUntouchedUnit(unit, referencesTarget), error });
1141
+ }
1142
+ return failures;
1143
+ }
1076
1144
  /**
1077
1145
  * What inside an installed skill directory the reference overlay owns, and the
1078
1146
  * pre-clean must therefore leave standing (D-OVERLAY-OWNERSHIP).
@@ -1140,10 +1208,29 @@ export function overlayOwnedSkillPaths(manifest) {
1140
1208
  * An unreadable directory is left alone rather than reported: the caller already
1141
1209
  * swallows the errors of the `fs.rm` this stands in for, and a pre-clean that cannot
1142
1210
  * read its target has nothing to remove from it.
1211
+ *
1212
+ * The walk never goes through a link. `readdir` follows one, so a link at `dir` itself is
1213
+ * unlinked and nothing is read from it, and below `dir` only a `Dirent` that is a real
1214
+ * directory is entered. The folder a link points to is left as it is. A link that stands where
1215
+ * the pre-clean expects its own folder or file (`dir`, or a path in `keep` or above one) is
1216
+ * returned, so the caller reports it; any other link is removed like the file it stands for.
1217
+ * A link at `dir` that cannot be removed is left in place and nothing is emptied.
1218
+ *
1219
+ * A link at a kept path or above one is returned as `awaitingWrite`, not as replaced: the
1220
+ * converger that owns the path has not written there yet, and a summary that says a real file or
1221
+ * folder stands where the link stood is true only after it has ({@link reportLinksOnceWritten}).
1222
+ * `leaveKeptLinks` is for a kept path whose converger replaces a link itself and reports it, never
1223
+ * writing through it (the tracker agent file): the link then stays for that converger.
1224
+ *
1225
+ * @returns The links at `dir` that this replaced, and the links it removed that a later write replaces.
1143
1226
  */
1144
- async function emptyDirectoryExcept(dir, keep) {
1227
+ async function emptyDirectoryExcept(dir, keep, options = {}) {
1228
+ const rootLink = await unlinkLinkAt(dir);
1229
+ if (rootLink !== 'no-link')
1230
+ return { replacedLinks: rootLink === 'unlinked' ? [dir] : [], awaitingWrite: [] };
1145
1231
  const kept = [...keep];
1146
1232
  const maxDepth = Math.max(0, ...kept.map(relPath => relPath.split('/').length));
1233
+ const awaitingWrite = [];
1147
1234
  const walk = async (current, rel, depth) => {
1148
1235
  let entries;
1149
1236
  try {
@@ -1154,19 +1241,34 @@ async function emptyDirectoryExcept(dir, keep) {
1154
1241
  }
1155
1242
  for (const entry of entries) {
1156
1243
  const entryRel = rel === '' ? entry.name : `${rel}/${entry.name}`;
1157
- if (keep.has(entryRel) && !entry.isSymbolicLink())
1244
+ if (keep.has(entryRel) && (!entry.isSymbolicLink() || options.leaveKeptLinks === true))
1158
1245
  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);
1246
+ const entryPath = path.join(current, entry.name);
1247
+ const holdsSomethingKept = kept.some(relPath => relPath.startsWith(`${entryRel}/`));
1248
+ if (entry.isDirectory() && depth < maxDepth && holdsSomethingKept) {
1249
+ await walk(entryPath, entryRel, depth + 1);
1164
1250
  continue;
1165
1251
  }
1166
- await fs.rm(path.join(current, entry.name), { recursive: true, force: true });
1252
+ await fs.rm(entryPath, { recursive: true, force: true });
1253
+ if (entry.isSymbolicLink() && (holdsSomethingKept || keep.has(entryRel)))
1254
+ awaitingWrite.push(entryPath);
1167
1255
  }
1168
1256
  };
1169
1257
  await walk(dir, '', 1);
1258
+ return { replacedLinks: [], awaitingWrite };
1259
+ }
1260
+ /**
1261
+ * Add each link in `removed` to `report.replacedLinks` once a real file or folder stands at its
1262
+ * path. A path that is still absent, or a link again, is not a replacement and is not reported: the
1263
+ * step that should have written there failed or was refused, and it reports that failure itself.
1264
+ * Bounded by `removed`, which holds at most the kept paths of one pre-clean.
1265
+ */
1266
+ async function reportLinksOnceWritten(report, removed) {
1267
+ for (const linkPath of removed) {
1268
+ const stat = await fs.lstat(linkPath).catch(() => undefined);
1269
+ if (stat !== undefined && !stat.isSymbolicLink())
1270
+ report.replacedLinks.push(linkPath);
1271
+ }
1170
1272
  }
1171
1273
  // ---------------------------------------------------------------------------
1172
1274
  // Script composer
@@ -1190,6 +1292,9 @@ function collectRelativeImports(source) {
1190
1292
  * under ~/.devflow/scripts/ at the same relative-to-dist/ paths
1191
1293
  * (c) ~/.devflow/scripts/package.json → {"type":"module"}
1192
1294
  *
1295
+ * The copy leaves out the orchestrator charter and the implement sentinel. Install the
1296
+ * directory with {@link installScripts}, which writes both after the copy.
1297
+ *
1193
1298
  * Frozen externally-referenced paths:
1194
1299
  * ~/.devflow/scripts/hooks/run-hook (hook bootstrap entry)
1195
1300
  * ~/.devflow/scripts/hud.sh (HUD entry script)
@@ -1204,9 +1309,19 @@ export async function composeScripts(scriptsTarget) {
1204
1309
  // mirrored dist/hud/ closure), so a re-run gave package.json an exec bit the first
1205
1310
  // run never did and re-init stopped being a no-op on disk (#388 AC-3). Scoping the
1206
1311
  // chmod to the copied entries makes the first and every later run agree.
1312
+ //
1313
+ // D-CHARTER-INSTALL-RENDER: the orchestrator charter and the implement sentinel are
1314
+ // NOT part of this verbatim copy. The charter is installed as a render of the packaged
1315
+ // source for the installed plugin set, and the sentinel exists only while implement is
1316
+ // installed; `installRenderedCharter` is the only writer of both, so no copy here can put
1317
+ // the full source charter or a stale sentinel back over a render.
1207
1318
  const srcScripts = scriptsDir();
1319
+ const rendered = new Set([
1320
+ path.join(srcScripts, CHARTER_RELPATH),
1321
+ path.join(srcScripts, HANDOFF_SENTINEL_RELPATH),
1322
+ ]);
1208
1323
  try {
1209
- await copyDirectory(srcScripts, scriptsTarget);
1324
+ await copyDirectory(srcScripts, scriptsTarget, rendered);
1210
1325
  if (process.platform !== 'win32') {
1211
1326
  for (const entry of await fs.readdir(srcScripts, { withFileTypes: true })) {
1212
1327
  const copied = path.join(scriptsTarget, entry.name);
@@ -1268,6 +1383,23 @@ export async function composeScripts(scriptsTarget) {
1268
1383
  }
1269
1384
  catch { /* already exists from prior install — leave as-is */ }
1270
1385
  }
1386
+ /**
1387
+ * Install the scripts directory whole: the verbatim copy of {@link composeScripts}, then the orchestrator
1388
+ * charter and the implement sentinel it skips.
1389
+ *
1390
+ * D-CHARTER-INSTALL-RENDER: a tree from `composeScripts` alone has no charter, so every caller outside this
1391
+ * module goes through here and none pairs the two steps by hand. The render is its own step, outside the
1392
+ * copy's catch-all, so its failure handling is the writer's own: a warning, never an abort.
1393
+ */
1394
+ export async function installScripts(opts) {
1395
+ await composeScripts(opts.scriptsTarget);
1396
+ await installRenderedCharter({
1397
+ scriptsDir: opts.scriptsTarget,
1398
+ plugins: opts.plugins,
1399
+ warn: opts.warn,
1400
+ mode: 'install',
1401
+ });
1402
+ }
1271
1403
  /**
1272
1404
  * Install one agent that carries the tracker-server stamp: the shipped source with
1273
1405
  * `servers` appended to its `tools:` line, written in one step so no copy of it ever sits on
@@ -1344,7 +1476,7 @@ async function firstExisting(candidates) {
1344
1476
  * installed in its place).
1345
1477
  *
1346
1478
  * The ONE spelling of that decision: the install loop and
1347
- * convergeCurationVariants both call it, so a skill the curation toggle installs
1479
+ * convergeVariantAxes both call it, so a skill the curation toggle installs
1348
1480
  * later is shadowed exactly as one the install installed.
1349
1481
  *
1350
1482
  * The shipped source is stat-checked FIRST, even under a valid shadow, so an
@@ -1383,8 +1515,8 @@ export async function resolveSkillSource(skillName, devflowDir, packageRoot) {
1383
1515
  * validateSkillShadow at install time. This only answers "did the user write
1384
1516
  * one?", which is what dormancy reporting turns on.
1385
1517
  */
1386
- async function listShadowedSkills(devflowDir) {
1387
- const registry = new Set(getAllSkillNames());
1518
+ async function listShadowedSkills(devflowDir, folderWorkflows) {
1519
+ const registry = new Set(getAllSkillNames(folderWorkflows));
1388
1520
  let entries;
1389
1521
  try {
1390
1522
  entries = await fs.readdir(path.join(devflowDir, 'skills'));
@@ -1432,16 +1564,118 @@ async function removeDeselectedSkills(skillsDir, names) {
1432
1564
  }
1433
1565
  return { removed, failed };
1434
1566
  }
1567
+ /** The most variant files a refusal names; the count says how many more there are. */
1568
+ const MAX_REPORTED_MISSING_VARIANTS = 5;
1569
+ /** The names a refusal lists: at most {@link MAX_REPORTED_MISSING_VARIANTS}, then a count of the rest. */
1570
+ function namedVariantFiles(items) {
1571
+ const named = items.slice(0, MAX_REPORTED_MISSING_VARIANTS).join(', ');
1572
+ const more = items.length > MAX_REPORTED_MISSING_VARIANTS ? ` and ${items.length - MAX_REPORTED_MISSING_VARIANTS} more` : '';
1573
+ return `${named}${more}`;
1574
+ }
1575
+ /**
1576
+ * The source lists the copy reads, most-preferred first. The check in
1577
+ * {@link requireComplianceVariantFiles} and the copy in {@link installCommandsAndAgents}
1578
+ * both take their lists from here, so the check looks at the files the copy takes.
1579
+ * An injected agent list wins as given.
1580
+ */
1581
+ function copySourceDirs(opts) {
1582
+ const { curation, compliance, packageRoot } = opts;
1583
+ return {
1584
+ commands: commandSourceDirs({ curation, compliance }, packageRoot),
1585
+ agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, { curation, compliance }),
1586
+ };
1587
+ }
1588
+ /**
1589
+ * Refuse a compliance-off install that cannot be trusted to carry the right text, before
1590
+ * any file is written. Compliance on reads nothing and returns.
1591
+ *
1592
+ * With compliance off, the installer must know which compiled prompts carry a compliance
1593
+ * arm: the build records that in dist/variant-axes.json, and the installer reads it here
1594
+ * rather than guess from the files it finds. A prompt with a compliance arm has no safe
1595
+ * fall-through, because its normal file, and on a both-off machine its curation-off file,
1596
+ * carry the compliance text the machine has switched off. So the install is refused when
1597
+ * - the manifest is absent, unreadable or not valid (the build has not run, or only
1598
+ * `build:cli` did); or
1599
+ * - a compliance-armed prompt has no file for the machine's EXACT switches
1600
+ * (requiredComplianceVariantFiles names the file: a both-off machine needs the file
1601
+ * built for both axes, never the curation-off one); or
1602
+ * - the copy would take another file for that prompt: the first existing file of its
1603
+ * kind and name over the copy's own source list (copySourceDirs) is not the required
1604
+ * file. A prompt whose list holds no file at all is left to the copy's own refusal.
1605
+ * A prompt with no compliance arm is not checked: it falls through to its normal file.
1606
+ *
1607
+ * The same refusal shape as {@link requireGeneratedTree}: one throw, before the unit loop,
1608
+ * naming the build step. Only the file reads, so it leaves no partial install behind.
1609
+ */
1610
+ async function requireComplianceVariantFiles(opts) {
1611
+ const { curation, compliance, packageRoot } = opts;
1612
+ if (compliance)
1613
+ return;
1614
+ const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
1615
+ const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
1616
+ `(\`npm run build:cli\` alone does not produce them).`;
1617
+ let text;
1618
+ try {
1619
+ text = await fs.readFile(manifestFile, 'utf-8');
1620
+ }
1621
+ catch (err) {
1622
+ const reason = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
1623
+ throw new Error(`Variant manifest ${reason}: ${manifestFile}. ` +
1624
+ `With compliance off the installer needs it to find the prompts that carry a compliance arm, so no file was written. ${remedy}`);
1625
+ }
1626
+ const parsed = parseVariantManifest(text);
1627
+ if (!parsed.ok) {
1628
+ throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
1629
+ `No file was written. ${remedy}`);
1630
+ }
1631
+ const sourceDirs = copySourceDirs({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1632
+ const machine = `curation ${curation ? 'on' : 'off'} and compliance off`;
1633
+ const missing = [];
1634
+ const shadowed = [];
1635
+ for (const host of parsed.value) {
1636
+ const [rel] = requiredComplianceVariantFiles({ curation, compliance }, [host]);
1637
+ if (rel === undefined)
1638
+ continue;
1639
+ const required = path.join(packageRoot, ...rel.split('/'));
1640
+ let present = false;
1641
+ try {
1642
+ present = (await fs.stat(required)).isFile();
1643
+ }
1644
+ catch { /* absent — reported below */ }
1645
+ if (!present) {
1646
+ missing.push(rel);
1647
+ continue;
1648
+ }
1649
+ const dirs = host.kind === 'commands' ? sourceDirs.commands : sourceDirs.agents;
1650
+ const taken = await firstExisting(dirs.map(dir => path.join(dir, mdFileName(host.name))));
1651
+ if (taken !== undefined && path.resolve(taken) !== path.resolve(required)) {
1652
+ shadowed.push(`${rel} (the copy would take ${path.relative(packageRoot, taken).split(path.sep).join('/')})`);
1653
+ }
1654
+ }
1655
+ if (missing.length > 0) {
1656
+ throw new Error(`Variant file not found for ${missing.length} prompt(s) that carry a compliance arm, on a machine with ` +
1657
+ `${machine}: ${namedVariantFiles(missing)}. ` +
1658
+ `Another variant of the same prompt would carry the wrong text, so no file was written. ${remedy}`);
1659
+ }
1660
+ if (shadowed.length > 0) {
1661
+ throw new Error(`Another file comes first for ${shadowed.length} prompt(s) that carry a compliance arm, on a machine with ` +
1662
+ `${machine}: ${namedVariantFiles(shadowed)}. ` +
1663
+ `The install would carry the wrong text, so no file was written. ${remedy}`);
1664
+ }
1665
+ }
1435
1666
  /**
1436
1667
  * Write the commands and the agents of `plugins` into `claudeDir`, and nothing else:
1437
1668
  * no sweep, no stamp, no skills. The one copy path shared by `installViaFileCopy` and
1438
1669
  * the selective uninstall, which rewrites the files of the plugins that stay installed
1439
1670
  * after it removed a retired agent.
1440
1671
  *
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
1672
+ * Commands come from dist/commands/{name}.md, or — with an axis off — the off variant of
1673
+ * the host when it has one, such as dist/curation-off/commands/{name}.md
1443
1674
  * (D-LEARNING-VARIANT-INSTALL; commandSourceDirs owns the order). A declared command
1444
- * with no compiled source file is a hard error, not a skip.
1675
+ * with no compiled source file is a hard error, not a skip. With compliance off the copy
1676
+ * first checks that every compliance-armed prompt has its file for the machine's exact
1677
+ * switches and that the copy takes that file, and refuses before it writes anything
1678
+ * otherwise ({@link requireComplianceVariantFiles}).
1445
1679
  *
1446
1680
  * Agents resolve dist-first with a src fallback: dist/agents/{name}.md (compiled from an
1447
1681
  * .mds generator host) wins over src/assets/agents/{name}.md. A declared agent absent from
@@ -1458,32 +1692,45 @@ async function removeDeselectedSkills(skillsDir, names) {
1458
1692
  * known and leaves it alone.
1459
1693
  */
1460
1694
  export async function installCommandsAndAgents(opts) {
1461
- const { plugins, agentsMap, claudeDir, curation, trackerServers, warn } = opts;
1695
+ const { plugins, agentsMap, claudeDir, curation, compliance, trackerServers, warn } = opts;
1696
+ const skip = opts.skip ?? {};
1697
+ const packageRoot = opts.packageRoot ?? getPackageRoot();
1698
+ // Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
1699
+ // this call covers the caller that reaches the copy alone (the selective uninstall).
1700
+ await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1701
+ const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
1702
+ curation,
1703
+ compliance,
1704
+ packageRoot,
1705
+ agentSourceDirs: opts.agentSourceDirs,
1706
+ });
1462
1707
  const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1463
- const commandDirs = commandSourceDirs(curation);
1708
+ const folderWorkflows = opts.folderWorkflows ?? WORKFLOW_FOLDER_NAMES;
1464
1709
  const commandsSourceNames = new Set();
1465
1710
  for (const plugin of plugins) {
1466
1711
  for (const cmd of plugin.commands) {
1467
1712
  const name = cmd.startsWith('/') ? cmd.slice(1) : cmd;
1468
- commandsSourceNames.add(name);
1713
+ // D-WORKFLOW-FOLDER-INSTALL: a workflow that ships as a skill folder has no command file.
1714
+ if (!folderWorkflows.has(name))
1715
+ commandsSourceNames.add(name);
1469
1716
  }
1470
1717
  }
1471
- if (commandsSourceNames.size > 0) {
1718
+ if (commandsSourceNames.size > 0 && skip.commands !== true) {
1472
1719
  await fs.mkdir(commandsTarget, { recursive: true });
1473
1720
  for (const name of commandsSourceNames) {
1474
1721
  const srcFile = await firstExisting(commandDirs.map(dir => path.join(dir, mdFileName(name))));
1475
1722
  if (srcFile === undefined) {
1476
1723
  // The error names the curation-ON path: that file exists for every declared
1477
1724
  // 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))}. ` +
1725
+ throw new Error(`Command source not found for declared command "${name}": ${path.join(commandSourceDirs(ALL_SWITCHES_ON, packageRoot)[0], mdFileName(name))}. ` +
1479
1726
  `Ensure build:mds ran successfully before install.`);
1480
1727
  }
1481
- await fs.copyFile(srcFile, path.join(commandsTarget, mdFileName(name)));
1728
+ const commandTarget = path.join(commandsTarget, mdFileName(name));
1729
+ if (await clearLinkForWrite(commandTarget, opts.links))
1730
+ await fs.copyFile(srcFile, commandTarget);
1482
1731
  }
1483
1732
  }
1484
1733
  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
1734
  const allAgentNames = new Set();
1488
1735
  for (const plugin of plugins) {
1489
1736
  for (const agent of plugin.agents) {
@@ -1494,7 +1741,7 @@ export async function installCommandsAndAgents(opts) {
1494
1741
  }
1495
1742
  }
1496
1743
  }
1497
- if (allAgentNames.size > 0) {
1744
+ if (allAgentNames.size > 0 && skip.agents !== true) {
1498
1745
  await fs.mkdir(agentsTarget, { recursive: true });
1499
1746
  for (const agentName of allAgentNames) {
1500
1747
  if (await skipsForUserOwnedType(agentName, claudeDir, agentsTarget, warn))
@@ -1502,11 +1749,13 @@ export async function installCommandsAndAgents(opts) {
1502
1749
  const candidates = agentDirs.map(dir => path.join(dir, mdFileName(agentName)));
1503
1750
  const srcFile = await firstExisting(candidates);
1504
1751
  if (srcFile === undefined) {
1505
- throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs()[0], mdFileName(agentName))}. ` +
1752
+ throw new Error(`Agent source not found for declared agent "${agentName}": ${path.join(opts.agentSourceDirs?.[0] ?? agentSourceDirs(packageRoot)[0], mdFileName(agentName))}. ` +
1506
1753
  `Run \`npm run build:mds\` if it is compiled from an .mds generator host, otherwise ` +
1507
1754
  `ensure the agent file exists in src/assets/agents/ (searched: ${candidates.join(', ')}).`);
1508
1755
  }
1509
1756
  const agentTarget = path.join(agentsTarget, mdFileName(agentName));
1757
+ if (!(await clearLinkForWrite(agentTarget, opts.links)))
1758
+ continue;
1510
1759
  if (isTrackerStampedAgent(agentName)) {
1511
1760
  await writeStampedAgent(srcFile, agentTarget, trackerServers, warn);
1512
1761
  }
@@ -1524,7 +1773,48 @@ export async function installCommandsAndAgents(opts) {
1524
1773
  * Returns an InstallReport describing which shadows were applied and which were skipped.
1525
1774
  */
1526
1775
  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;
1776
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, } = options;
1777
+ const packageRoot = options.packageRoot ?? getPackageRoot();
1778
+ // D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
1779
+ // empty, and with it every folder step below is a no-op over an empty set.
1780
+ const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
1781
+ const folderWorkflows = workflowFolderNames(folderRegistry);
1782
+ const folderSwitches = { curation, compliance };
1783
+ const folderFiles = new Map();
1784
+ /** The files of one registered folder, read once. A failed read is a reason and never a throw. */
1785
+ const readFolder = async (folder) => {
1786
+ const cached = folderFiles.get(folder.name);
1787
+ if (cached !== undefined)
1788
+ return { ok: true, files: cached };
1789
+ const read = await readWorkflowFolder(folder, folderSwitches, options.workflowFoldersRoot ?? packageRoot);
1790
+ if (read.ok)
1791
+ folderFiles.set(folder.name, read.files);
1792
+ return read;
1793
+ };
1794
+ const readFolders = async (names) => {
1795
+ for (const folder of folderRegistry) {
1796
+ if (!names.has(folder.name))
1797
+ continue;
1798
+ const read = await readFolder(folder);
1799
+ if (!read.ok)
1800
+ throw new Error(read.reason);
1801
+ }
1802
+ };
1803
+ // The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
1804
+ // must leave the installed tree as it found it.
1805
+ await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: options.agentSourceDirs });
1806
+ // The folders this run installs are read from the exact-key tree before the pre-clean for the
1807
+ // same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
1808
+ await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
1809
+ // The references the machine's compliance switch installs: the manifest, and the ONE source
1810
+ // root they are overlaid from. With compliance off the root is the compliance-off tree, and its
1811
+ // absence refuses the install here, before the pre-clean, so a refused install leaves the
1812
+ // installed tree as it found it and never falls through to the compliance-on references (AC-50).
1813
+ const refsManifest = installedReferenceManifest({ compliance });
1814
+ const refsSourceRoot = skillRefsSourceDir(compliance, packageRoot);
1815
+ if (omitCurationGatedSkills(skillsMap, curation).has(SKILL_REFS_SKILL_NAME)) {
1816
+ await requireGeneratedTree(refsSourceRoot, refsManifest);
1817
+ }
1528
1818
  const report = {
1529
1819
  shadowedSkills: [],
1530
1820
  shadowedRules: [],
@@ -1534,6 +1824,8 @@ export async function installViaFileCopy(options) {
1534
1824
  overlaidRefs: [],
1535
1825
  unchangedRefs: [],
1536
1826
  overlayFailures: [],
1827
+ replacedLinks: [],
1828
+ refusedFolders: [],
1537
1829
  removedSkills: [],
1538
1830
  dormantShadows: [],
1539
1831
  };
@@ -1551,9 +1843,18 @@ export async function installViaFileCopy(options) {
1551
1843
  const skillPlan = resolveSkillInstallPlan({
1552
1844
  effectivePlugins: options.effectivePlugins ?? plugins,
1553
1845
  isPartialInstall,
1554
- shadowedSkills: await listShadowedSkills(devflowDir),
1846
+ shadowedSkills: await listShadowedSkills(devflowDir, folderWorkflows),
1847
+ folderWorkflows,
1555
1848
  });
1556
1849
  report.dormantShadows = [...skillPlan.dormantShadows];
1850
+ // D-WORKFLOW-SHADOW-REFUSAL, install half: a folder installs from the build and has no source to
1851
+ // overlay, so a shadow of its name is ignored and kept. The warning says so; it is not dormant.
1852
+ for (const name of skillPlan.ignoredFolderShadows) {
1853
+ 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.`);
1854
+ }
1855
+ // Links the pre-clean removed at a path that a later step of this install writes. Each is reported
1856
+ // as replaced only once a real file or folder stands there (reportLinksOnceWritten).
1857
+ const awaitingWrite = [];
1557
1858
  // Clean old Devflow files before installing
1558
1859
  spinner.message('Cleaning old files...');
1559
1860
  if (!isPartialInstall) {
@@ -1566,7 +1867,7 @@ export async function installViaFileCopy(options) {
1566
1867
  ];
1567
1868
  for (const dir of oldDirs) {
1568
1869
  try {
1569
- await fs.rm(dir, { recursive: true, force: true });
1870
+ report.replacedLinks.push(...await removeReportingLink(dir));
1570
1871
  }
1571
1872
  catch { /* ignore */ }
1572
1873
  }
@@ -1579,11 +1880,28 @@ export async function installViaFileCopy(options) {
1579
1880
  // exactly as the unconditional wipe removed it, and the file is still
1580
1881
  // converged on this run — drift in it is restored, so preserving it strands
1581
1882
  // nothing.
1883
+ //
1884
+ // A link at that file is left standing: the converge replaces it with a real file and reports it
1885
+ // once it has written, so the install does not claim a replacement the converge may not make.
1582
1886
  try {
1583
- await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]));
1887
+ const emptied = await emptyDirectoryExcept(path.join(claudeDir, 'agents', 'devflow'), new Set([mdFileName(TRACKER_AGENT_NAME)]), { leaveKeptLinks: true });
1888
+ report.replacedLinks.push(...emptied.replacedLinks);
1889
+ awaitingWrite.push(...emptied.awaitingWrite);
1584
1890
  }
1585
1891
  catch { /* ignore */ }
1586
1892
  }
1893
+ // The commands, agents and rules folders, each proved a real folder (or absent) before any write
1894
+ // below. A full install's pre-clean replaced a link at each of them. A partial install has no
1895
+ // pre-clean, and a link the pre-clean could not remove is still standing, so every write below
1896
+ // would go through it into another directory: the sweeps, the copies and the restamp. A refused
1897
+ // folder is skipped by all of them and reported in `refusedFolders`. The rules folder is written
1898
+ // only when the selection has a rule, so only then is it proved.
1899
+ const commandsTarget = path.join(claudeDir, 'commands', 'devflow');
1900
+ const agentsTarget = path.join(claudeDir, 'agents', 'devflow');
1901
+ const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1902
+ const commandsRefused = await refuseUnrealFolder(report, commandsTarget);
1903
+ const agentsRefused = await refuseUnrealFolder(report, agentsTarget);
1904
+ const rulesRefused = rulesMap.size > 0 && await refuseUnrealFolder(report, rulesTarget);
1587
1905
  // Sweep stale devflow:* skill dirs — ungated: runs on every install shape,
1588
1906
  // including partial installs, so renamed/deleted skills are pruned promptly.
1589
1907
  // knownNames spans ALL plugins (getAllSkillNames) so skills from uninstalled
@@ -1596,7 +1914,7 @@ export async function installViaFileCopy(options) {
1596
1914
  // are never swept here. Their lifecycle is managed by convergeComplianceArtifacts, which
1597
1915
  // runs after installViaFileCopy in init.ts. Before I09 the compliance skill was swept and
1598
1916
  // 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));
1917
+ recordSweep(report, 'skill', await sweepOrphanedAssets(path.join(claudeDir, 'skills'), new Set([...getAllSkillNames(folderWorkflows), ...FEATURE_OWNED_SKILLS]), (entry) => entry.startsWith(SKILL_NAMESPACE) ? unprefixSkillName(entry) : null));
1600
1918
  // Pre-clean the prefixed install targets before re-copying so stale content
1601
1919
  // never bleeds into a fresh install. Bare pre-namespace dirs at
1602
1920
  // ~/.claude/skills/{name} are owned solely by the frozen LEGACY_SKILL_NAMES
@@ -1632,22 +1950,35 @@ export async function installViaFileCopy(options) {
1632
1950
  // Everything else in the directory is still emptied, so a stale hand-authored skill
1633
1951
  // file — including a reference at the references ROOT, which the overlay may replace
1634
1952
  // but never delete (D-OVERLAY-FLAT-UNIT) — does not survive a full install.
1953
+ //
1954
+ // A target that is a link is unlinked and reported in `replacedLinks`, never emptied
1955
+ // through: the folder it points to is not Devflow's, and the install below writes a real
1956
+ // folder in its place.
1635
1957
  if (!isPartialInstall) {
1636
- const overlayOwned = overlayOwnedSkillPaths(installedReferenceManifest());
1958
+ const overlayOwned = overlayOwnedSkillPaths(refsManifest);
1637
1959
  for (const skill of skillsMap.keys()) {
1638
1960
  // Empty the prefixed directory (its contents are re-created during the install
1639
1961
  // phase), minus whatever another converger owns inside it.
1640
1962
  const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
1641
1963
  try {
1642
1964
  if (skill === SKILL_REFS_SKILL_NAME) {
1643
- await emptyDirectoryExcept(target, overlayOwned);
1965
+ const emptied = await emptyDirectoryExcept(target, overlayOwned);
1966
+ report.replacedLinks.push(...emptied.replacedLinks);
1967
+ awaitingWrite.push(...emptied.awaitingWrite);
1644
1968
  }
1645
1969
  else {
1646
- await fs.rm(target, { recursive: true, force: true });
1970
+ report.replacedLinks.push(...await removeReportingLink(target));
1647
1971
  }
1648
1972
  }
1649
1973
  catch { /* ignore */ }
1650
1974
  }
1975
+ // A folder is replaced whole by its install, so the pre-clean only unlinks a link at it, as it does
1976
+ // for a skill. A link it cannot remove is refused by the install below.
1977
+ for (const name of selectedWorkflowFolders(plugins, folderWorkflows)) {
1978
+ const target = path.join(claudeDir, 'skills', prefixSkillName(name));
1979
+ if ((await unlinkLinkAt(target)) === 'unlinked')
1980
+ report.replacedLinks.push(target);
1981
+ }
1651
1982
  }
1652
1983
  // Remove the skills no selected plugin owns or requires — the deselection half
1653
1984
  // of the scoped install. Empty on a partial install by construction
@@ -1655,7 +1986,7 @@ export async function installViaFileCopy(options) {
1655
1986
  // (AC-22). Failures are per-item and non-fatal. `removedSkills` lists only what
1656
1987
  // was on disk and is now gone: the plan is registry arithmetic and mostly names
1657
1988
  // skills that were never installed.
1658
- const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), skillPlan.remove);
1989
+ const deselected = await removeDeselectedSkills(path.join(claudeDir, 'skills'), [...skillPlan.remove, ...skillPlan.removeFolders]);
1659
1990
  report.removedSkills.push(...deselected.removed);
1660
1991
  for (const failure of deselected.failed) {
1661
1992
  warn(`Could not remove deselected skill "${prefixSkillName(failure.name)}" — ${String(failure.error)}`);
@@ -1668,8 +1999,9 @@ export async function installViaFileCopy(options) {
1668
1999
  // Ungated: runs on every install shape. knownNames spans ALL plugins
1669
2000
  // (getAllAgentNames) so agents from uninstalled plugins survive a partial run. Only
1670
2001
  // 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);
2002
+ const agentSweep = agentsRefused
2003
+ ? { scanned: 0, removed: [], failed: [] }
2004
+ : await sweepOrphanedAssets(agentsTarget, new Set(getAllAgentNames()), mdEntryName);
1673
2005
  recordSweep(report, 'agent', agentSweep);
1674
2006
  // A partial install writes the selected plugins' commands and agents and leaves every
1675
2007
  // other plugin's files alone, which is only safe while those files still name agents
@@ -1688,26 +2020,37 @@ export async function installViaFileCopy(options) {
1688
2020
  agentsMap: commandAgentMap,
1689
2021
  claudeDir,
1690
2022
  curation,
2023
+ compliance,
2024
+ packageRoot,
1691
2025
  trackerServers,
1692
2026
  agentSourceDirs: options.agentSourceDirs,
2027
+ skip: { commands: commandsRefused, agents: agentsRefused },
2028
+ links: report,
2029
+ folderWorkflows,
1693
2030
  warn,
1694
2031
  });
1695
2032
  // Sweep stale command files — ungated: runs on every install shape.
1696
2033
  // knownNames spans ALL plugins (getAllCommandNames) so commands from uninstalled
1697
2034
  // 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));
2035
+ // A workflow that ships as a skill folder is not in the set, so its stale command file is swept.
2036
+ if (!commandsRefused) {
2037
+ recordSweep(report, 'command', await sweepOrphanedAssets(commandsTarget, new Set(getAllCommandNames(folderWorkflows)), mdEntryName));
2038
+ }
1699
2039
  // D-LANGUAGE-FOCUS-STAMP: record the language focuses the EFFECTIVE selection installs on the
1700
2040
  // installed /code-review, on every install shape. This is the language gate the command reads
1701
2041
  // (no run-time probe of Claude Code's directory), so it runs after the copy and the sweep and
1702
2042
  // whether or not code-review's own plugin is in this run: `--plugin=devflow-typescript` copies no
1703
2043
  // command, yet must update a code-review an earlier run installed. The copy above wrote the shipped
1704
2044
  // `(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
- });
2045
+ // pre-clean can defeat it. A failure is a warning, never an abort. A refused commands folder is not
2046
+ // restamped: the rewrite would go through the link.
2047
+ if (!commandsRefused) {
2048
+ await restampInstalledCommands({
2049
+ claudeDir,
2050
+ effectivePlugins: options.effectivePlugins ?? plugins,
2051
+ warn,
2052
+ });
2053
+ }
1711
2054
  // Install skills from ALL plugins (skillsMap covers all plugins, not just selected).
1712
2055
  // Resolved from flat src/assets/skills/{name}/ (no per-plugin subdirectory).
1713
2056
  // A declared skill whose source directory is absent is a build/packaging failure
@@ -1716,12 +2059,32 @@ export async function installViaFileCopy(options) {
1716
2059
  // D-LEARNING-VARIANT-INSTALL: a curation-off machine skips the curation-gated
1717
2060
  // skills here. The pre-clean above walked the UNFILTERED map, so on a full
1718
2061
  // install a leftover copy is already gone; a partial install leaves removal to
1719
- // convergeCurationVariants, which init runs straight after this call.
2062
+ // convergeVariantAxes, which init runs straight after this call.
1720
2063
  spinner.message('Installing skills...');
1721
2064
  for (const [skillName] of omitCurationGatedSkills(skillsMap, curation)) {
1722
- const resolved = await resolveSkillSource(skillName, devflowDir);
1723
2065
  const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
1724
- await copyDirectory(resolved.dir, skillTarget);
2066
+ // A full install's pre-clean replaced a link at the skill or its references entry. A partial
2067
+ // install has no pre-clean, and a link the pre-clean could not remove is still standing, so the
2068
+ // copy and the overlay below would write through such a link into another directory: the whole
2069
+ // skill is refused instead and listed in `refusedFolders`. The references skill is also reported
2070
+ // like a refused overlay, one failure for each unit.
2071
+ if (skillName === SKILL_REFS_SKILL_NAME) {
2072
+ const host = await referencesHostState(skillTarget);
2073
+ if (host.kind === 'refused') {
2074
+ report.refusedFolders.push(host.path);
2075
+ report.overlayFailures.push(...await refusedOverlayFailures(host.path, path.join(skillTarget, SKILL_REFERENCES_DIRNAME), refsManifest));
2076
+ continue;
2077
+ }
2078
+ }
2079
+ else if (await refuseUnrealFolder(report, skillTarget)) {
2080
+ continue;
2081
+ }
2082
+ // With compliance off the copy leaves out the posting files of COMPLIANCE_OFF_REMOVED_SKILL_FILES.
2083
+ // They are named below the source the copy reads, shadow or shipped, so the posting text is never
2084
+ // on the machine, even when the install stops or a removal is refused later. With compliance on
2085
+ // nothing is excluded.
2086
+ const resolved = await resolveSkillSource(skillName, devflowDir);
2087
+ await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
1725
2088
  if (resolved.shadow === 'valid') {
1726
2089
  report.shadowedSkills.push(skillName);
1727
2090
  }
@@ -1737,7 +2100,9 @@ export async function installViaFileCopy(options) {
1737
2100
  referencesTarget: path.join(skillTarget, 'references'),
1738
2101
  // Every provider's mechanics (D-INSTALL-ALL-PROVIDERS). The overlay
1739
2102
  // converges rather than merges, so a retired generated document is pruned.
1740
- manifest: installedReferenceManifest(),
2103
+ // The manifest and the source root follow the machine compliance switch together.
2104
+ sourceRoot: refsSourceRoot,
2105
+ manifest: refsManifest,
1741
2106
  warn,
1742
2107
  });
1743
2108
  report.overlaidRefs.push(...overlay.overlaidRefs);
@@ -1746,13 +2111,47 @@ export async function installViaFileCopy(options) {
1746
2111
  recordSweep(report, 'reference', overlay.pruned);
1747
2112
  }
1748
2113
  }
2114
+ // The skill copy and the overlay have written. A link the pre-clean removed at a path they write is
2115
+ // reported now if a real file or folder stands there, and not at all if a unit failed or was refused.
2116
+ await reportLinksOnceWritten(report, awaitingWrite);
2117
+ // With compliance off, the flat skill files that only the posting steps load are removed by
2118
+ // exact name (COMPLIANCE_OFF_REMOVED_SKILL_FILES), on every install shape. The copy above wrote
2119
+ // none of them; this step removes the ones an earlier compliance-on install left, which a partial
2120
+ // install copies over without clearing. A removal that was refused is a reported failure and
2121
+ // never an abort; the compliance converge that init runs next fails closed on the same files.
2122
+ if (!compliance) {
2123
+ const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
2124
+ for (const refusal of removal.refused) {
2125
+ report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
2126
+ }
2127
+ }
2128
+ // D-WORKFLOW-FOLDER-INSTALL: the workflow skill folders of the plugins whose commands this run
2129
+ // writes, each from the exact-key tree read above. Before the scripts step, so the hand-off
2130
+ // sentinel that step installs never stands without the folder it names. A widened run may add a
2131
+ // folder the early read did not cover. Each folder is read, installed and reported on its own, and
2132
+ // a read failure is a warning, since the pre-clean has run: one broken folder never hides the next.
2133
+ const foldersToInstall = selectedWorkflowFolders(commandAgentPlugins, folderWorkflows);
2134
+ if (foldersToInstall.size > 0)
2135
+ spinner.message('Installing workflow skills...');
2136
+ for (const folder of folderRegistry) {
2137
+ if (!foldersToInstall.has(folder.name))
2138
+ continue;
2139
+ const read = await readFolder(folder);
2140
+ if (!read.ok) {
2141
+ warn(`workflow skill folder: ${read.reason}`);
2142
+ continue;
2143
+ }
2144
+ const installed = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links: report });
2145
+ if (installed.outcome === 'failed') {
2146
+ warn(`workflow skill folder: could not install ${prefixSkillName(folder.name)} (${installed.reason ?? 'unknown error'})`);
2147
+ }
2148
+ }
1749
2149
  // Install rules from selected plugins (rulesMap covers selected plugins only).
1750
2150
  // Rules are flat .md files resolved from src/assets/rules/{name}.md (no per-plugin subdir).
1751
2151
  spinner.message('Installing rules...');
1752
- const rulesTarget = path.join(claudeDir, 'rules', 'devflow');
1753
- if (rulesMap.size > 0) {
2152
+ if (rulesMap.size > 0 && !rulesRefused) {
1754
2153
  await fs.mkdir(rulesTarget, { recursive: true });
1755
- const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget);
2154
+ const outcomes = await installAllRules(rulesMap, devflowDir, rulesTarget, report);
1756
2155
  for (const { ruleName, outcome } of outcomes) {
1757
2156
  if (outcome === 'shadow') {
1758
2157
  report.shadowedRules.push(ruleName);
@@ -1769,7 +2168,13 @@ export async function installViaFileCopy(options) {
1769
2168
  // (no dev tooling, no raw TypeScript).
1770
2169
  spinner.message('Installing scripts...');
1771
2170
  const scriptsTarget = path.join(devflowDir, 'scripts');
1772
- await composeScripts(scriptsTarget);
2171
+ // The charter and the sentinel render from the EFFECTIVE selection (a partial install
2172
+ // keeps what earlier runs installed).
2173
+ await installScripts({
2174
+ scriptsTarget,
2175
+ plugins: options.effectivePlugins ?? plugins,
2176
+ warn,
2177
+ });
1773
2178
  spinner.stop('Components installed via file copy');
1774
2179
  return report;
1775
2180
  }