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,28 +1,36 @@
1
1
  /**
2
- * Curation-variant installer for the Claude Code target.
2
+ * Prompt-variant installer for the Claude Code target: the curation and compliance switches.
3
3
  *
4
- * D-LEARNING-VARIANT-INSTALL: the install follows the machine's curation switch
5
- * (`features.curation` in ~/.devflow/manifest.json) and nothing else. When the
6
- * switch is off, every command and agent that has a curation-off variant
7
- * (dist/curation-off/{commands,agents}, built by D-LEARNING-VARIANTS) is installed
8
- * from it, and the apply-decisions skill is not installed, so the decisions text
9
- * reaches no prompt and the skill has no reader. When the switch is on, the
10
- * curation-on files and the skill are installed. Three pieces carry it:
4
+ * D-LEARNING-VARIANT-INSTALL: the install follows the machine's two switches
5
+ * (`features.curation` and `features.compliance` in ~/.devflow/manifest.json) and
6
+ * nothing else. Each command and agent that has an arm for a switch that is off is
7
+ * installed from the build tree of the machine's exact switch combination
8
+ * (dist/{curation-off,compliance-off,curation-compliance-off}/{commands,agents}, built by
9
+ * D-LEARNING-VARIANTS), and the apply-decisions skill is not installed when curation is
10
+ * off, so the decisions text reaches no prompt and the skill has no reader. With a switch on,
11
+ * its on files are installed. A compliance-off machine carries no posting step in any prompt
12
+ * that has a compliance arm. Three pieces carry it:
11
13
  *
12
14
  * - the source order (src/core/assets.ts commandSourceDirs / agentSourceDirs):
13
- * with curation off the curation-off directory is consulted first, then the
15
+ * an off combination takes its own tree, and only the all-on combination takes the
14
16
  * normal order, so a host with no arm installs its one file as before;
15
17
  * - the skill condition (CURATION_GATED_SKILLS in src/core/plugins.ts):
16
18
  * installViaFileCopy leaves the skill out when curation is off;
17
- * - {@link convergeCurationVariants}, below: the converge that makes an
18
- * ALREADY-installed tree match the switch, run by `devflow init` after the
19
- * file copy and by `devflow curation --enable/--disable` after the switch is
20
- * written ({@link applyCurationToggle}).
19
+ * - {@link convergeVariantAxes}, below: the one converge that makes an
20
+ * ALREADY-installed tree match both switches, run by `devflow init` after the
21
+ * file copy and by `devflow curation` and `devflow compliance` after the switch is
22
+ * written ({@link applyVariantToggle}).
21
23
  *
22
24
  * The converge, decided in turn:
23
- * - Its roster is the files that exist under dist/curation-off, not a list typed
24
- * here: the build owns which hosts have an arm, so a new arm is converged with
25
- * no edit to this file.
25
+ * - Its roster is the files that exist under the off trees, joined with the hosts the build
26
+ * manifest (dist/variant-axes.json) says carry an arm, not a list typed here: the build owns
27
+ * which hosts have an arm, so a new arm is converged with no edit to this file.
28
+ * - It fails closed on the posting question. With compliance off, an installed
29
+ * compliance-armed prompt that could not be converged is named in `posting`, and a build
30
+ * manifest that cannot name those hosts leaves `posting` unknown. Callers decide the cost:
31
+ * `devflow compliance` exits non-zero, init and curation warn.
32
+ * - After a clean converge, {@link applyVariantToggle} and init record which variants are
33
+ * installed (`installedVariants` in the manifest, see src/core/installed-variants.ts).
26
34
  * - It rewrites only files that are ALREADY installed. A `--plugin` install
27
35
  * carries one plugin's files; a toggle must not add another plugin's. For the
28
36
  * same reason the skill is installed on a switch-on only when the machine's
@@ -47,13 +55,14 @@
47
55
  * applied again the moment curation comes back on.
48
56
  * - Version skew: the variants in this package were built with this version.
49
57
  * When the manifest records another version, the installed files are not the
50
- * ones this package would rewrite, so {@link applyCurationToggle} skips the
51
- * converge and says to run `devflow init`.
58
+ * ones this package would rewrite, so {@link applyVariantToggle} skips the
59
+ * converge and says to run `devflow init`. For a compliance-off toggle the message also
60
+ * says that posting steps remain installed until then.
52
61
  * - An agent's installed `model:` and `effort:` are carried into the variant before
53
62
  * the byte comparison (D-AGENT-OVERRIDE-CARRY, see {@link withInstalledState}),
54
63
  * so the overrides `devflow agents` and the proxy put there are never written
55
64
  * back to the shipped values and a no-op converge writes no agent.
56
- * {@link applyCurationToggle} still runs reapplyAgentMapping straight after a
65
+ * {@link applyVariantToggle} still runs reapplyAgentMapping straight after a
57
66
  * converge that rewrote an agent: it reads agent-models.json and is the authority.
58
67
  *
59
68
  * D-LEARNING-PRELOAD-MACHINE-ONLY (amended): the apply-decisions preload follows
@@ -69,17 +78,24 @@
69
78
  */
70
79
  import { promises as fs } from 'fs';
71
80
  import * as path from 'path';
72
- import { agentSourceDirs, commandSourceDirs, curationOffDir, } from '../../core/assets.js';
81
+ import { agentSourceDirs, bothOffDir, commandSourceDirs, complianceOffDir, curationOffDir, } from '../../core/assets.js';
73
82
  import { carryStampedTools } from '../../core/agent-frontmatter.js';
74
83
  import { carryAgentOverrides, reapplyAgentMapping } from '../../core/agent-models.js';
75
84
  import { describeEchoedValue } from '../../core/echo-value.js';
76
- import { writeFileAtomicExclusive } from '../../core/fs-atomic.js';
77
- import { readManifest } from '../../core/manifest.js';
85
+ import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineVariantKey, parseVariantManifest, variantOffRelPath, } from '../../core/curation-variants.js';
86
+ import { readInstalledVariants } from '../../core/installed-variants.js';
87
+ import { readManifest, writeManifest } from '../../core/manifest.js';
88
+ import { withManifestLock } from '../../core/manifest-lock.js';
78
89
  import { getPackageRoot } from '../../core/paths.js';
79
90
  import { isProxyEnabled } from '../../core/proxy-state.js';
80
- import { DEVFLOW_PLUGINS, CURATION_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, skillsOf, } from '../../core/plugins.js';
91
+ import { WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
92
+ import { DEVFLOW_PLUGINS, CURATION_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, selectedWorkflowFolders, skillsOf, workflowFolderNames, } from '../../core/plugins.js';
93
+ import { convergeFromManifest } from './compliance-install.js';
81
94
  import { copyDirectory, resolveSkillSource } from './installer.js';
82
95
  import { LANGUAGE_STAMPED_COMMANDS, stampForConverge } from './language-stamp.js';
96
+ import { entryIsLink, newLinkReport, refuseUnrealFolder, refusedFolderMessage, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
97
+ import { convergeReferences } from './reference-converge.js';
98
+ import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
83
99
  // ── Bounds ─────────────────────────────────────────────────────────────────
84
100
  /**
85
101
  * The most variant files one kind may carry. The shipped roster is 14 commands and
@@ -92,38 +108,113 @@ const MAX_SKILL_TREE_DEPTH = 4;
92
108
  /** A roster entry is a flat, lowercase, hyphenated markdown basename. */
93
109
  const ROSTER_NAME_RE = /^[a-z0-9][a-z0-9-]*\.md$/;
94
110
  const KINDS = ['commands', 'agents'];
111
+ const POSTING_CLEAR = { kind: 'clear' };
95
112
  // ── Internals ──────────────────────────────────────────────────────────────
96
113
  function installedFile(claudeDir, kind, fileName) {
97
114
  return path.join(claudeDir, kind, 'devflow', fileName);
98
115
  }
99
- /** The curation-ON source directories for a kind, most-preferred first. */
116
+ /**
117
+ * The both-axes-ON source directories for a kind, most-preferred first: the files a host
118
+ * with no off variant for the machine installs.
119
+ */
100
120
  function onSourceDirs(kind, root) {
101
- return kind === 'commands' ? commandSourceDirs(true, root) : agentSourceDirs(root, true);
121
+ return kind === 'commands' ? commandSourceDirs(ALL_SWITCHES_ON, root) : agentSourceDirs(root, ALL_SWITCHES_ON);
122
+ }
123
+ /** The roster-shaped file names directly under `dir`, `'absent'` when it does not exist and `'unreadable'` (warned) when it cannot be read. */
124
+ async function listVariantDir(dir, kind, warn) {
125
+ try {
126
+ return (await fs.readdir(dir)).filter(entry => ROSTER_NAME_RE.test(entry));
127
+ }
128
+ catch (err) {
129
+ if (err.code === 'ENOENT')
130
+ return 'absent';
131
+ warn(`prompt variants: cannot read the ${kind} variants under ${dir} — ${String(err)}`);
132
+ return 'unreadable';
133
+ }
102
134
  }
103
135
  /**
104
- * The roster of one kind: the `.md` files under dist/curation-off/<kind>, sorted.
105
- *
106
- * `present` is false when the directory does not exist (the build has not run, or
107
- * only `build:cli` did), so the caller can say so instead of reporting a converge
108
- * that had nothing to do.
136
+ * The compiled prompts the build says carry an arm, read from its variant manifest. An error names the
137
+ * cause in a line a person can act on. Compliance on never reads it: only a compliance-off machine needs
138
+ * the manifest to know which prompts it must not leave unconverged.
109
139
  */
110
- async function readRoster(kind, root, warn) {
111
- let entries;
140
+ async function readVariantHosts(root) {
141
+ const file = path.join(root, ...VARIANT_MANIFEST_REL_PATH.split('/'));
142
+ let text;
112
143
  try {
113
- entries = await fs.readdir(curationOffDir(kind, root));
144
+ text = await fs.readFile(file, 'utf-8');
114
145
  }
115
146
  catch (err) {
116
- if (err.code === 'ENOENT')
117
- return { names: [], present: false };
118
- warn(`curation variants: cannot read the ${kind} variants — ${String(err)}`);
119
- return { names: [], present: false };
147
+ const cause = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
148
+ return { ok: false, reason: `the variant manifest ${cause}: ${file}` };
149
+ }
150
+ const parsed = parseVariantManifest(text);
151
+ if (!parsed.ok)
152
+ return { ok: false, reason: `the variant manifest is not valid: ${file} (${describeVariantManifestParseError(parsed.error)})` };
153
+ const hosts = new Map();
154
+ for (const host of parsed.value)
155
+ hosts.set(`${host.kind}/${host.name}.md`, { file: `${host.name}.md`, axes: host.axes });
156
+ return { ok: true, hosts };
157
+ }
158
+ /**
159
+ * The roster of one kind: the prompts that differ on an axis, sorted by file name.
160
+ *
161
+ * The files under the three off trees name them (the build owns which hosts have an arm), and a host the
162
+ * build manifest lists is added, so a tree removed from the package still leaves its hosts in the roster.
163
+ * An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it
164
+ * (a curation arm has a file under curation-off or both-off, a compliance arm under compliance-off or
165
+ * both-off).
166
+ *
167
+ * `present` is false when no tree holds this kind and the manifest lists none (the build has not run, or
168
+ * only `build:cli` did), so the caller can say so instead of reporting a converge that had nothing to do.
169
+ * `incomplete` says the roster was read in part, already warned: a tree could not be listed
170
+ * (`unreadable`), or the roster passed {@link MAX_CURATION_ROSTER} and the hosts after it were left out
171
+ * (`truncated`). `refused` lists the manifest hosts whose name {@link ROSTER_NAME_RE} does not hold (the
172
+ * build accepts a wider name), already warned: they are left out of `hosts` and the caller fails closed.
173
+ */
174
+ async function readRoster(kind, root, manifestHosts, warn) {
175
+ const listings = {
176
+ curation: await listVariantDir(curationOffDir(kind, root), kind, warn),
177
+ compliance: await listVariantDir(complianceOffDir(kind, root), kind, warn),
178
+ both: await listVariantDir(bothOffDir(kind, root), kind, warn),
179
+ };
180
+ const inferred = new Map();
181
+ const note = (names, axes) => {
182
+ if (typeof names === 'string')
183
+ return;
184
+ for (const name of names) {
185
+ const arms = inferred.get(name) ?? { curation: false, compliance: false };
186
+ inferred.set(name, {
187
+ curation: arms.curation || axes.includes('curation'),
188
+ compliance: arms.compliance || axes.includes('compliance'),
189
+ });
190
+ }
191
+ };
192
+ note(listings.curation, ['curation']);
193
+ note(listings.compliance, ['compliance']);
194
+ note(listings.both, ['curation', 'compliance']);
195
+ const files = new Map();
196
+ const refused = [];
197
+ for (const [file, axes] of inferred)
198
+ files.set(file, { file, axes });
199
+ for (const [key, host] of manifestHosts ?? []) {
200
+ if (!key.startsWith(`${kind}/`) || !(host.axes.curation || host.axes.compliance))
201
+ continue;
202
+ if (ROSTER_NAME_RE.test(host.file)) {
203
+ files.set(host.file, host);
204
+ }
205
+ else {
206
+ warn(`prompt variants: the build manifest lists ${kind}/${host.file}, a name the converge cannot hold; it was not converged`);
207
+ refused.push(host);
208
+ }
120
209
  }
121
- const names = entries.filter(entry => ROSTER_NAME_RE.test(entry)).sort();
122
- if (names.length > MAX_CURATION_ROSTER) {
123
- warn(`curation variants: ${names.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
124
- return { names: names.slice(0, MAX_CURATION_ROSTER), present: true };
210
+ const hosts = [...files.values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
211
+ const present = hosts.length > 0 || Object.values(listings).some(listing => typeof listing !== 'string');
212
+ if (hosts.length > MAX_CURATION_ROSTER) {
213
+ warn(`prompt variants: ${hosts.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
214
+ return { hosts: hosts.slice(0, MAX_CURATION_ROSTER), present: true, incomplete: 'truncated', refused };
125
215
  }
126
- return { names, present: true };
216
+ const unreadable = Object.values(listings).some(listing => listing === 'unreadable');
217
+ return { hosts, present, incomplete: unreadable ? 'unreadable' : null, refused };
127
218
  }
128
219
  async function readIfPresent(file) {
129
220
  try {
@@ -183,29 +274,38 @@ function withInstalledState(kind, fileName, source, installed, focuses) {
183
274
  : source;
184
275
  }
185
276
  /**
186
- * Converge ONE installed prompt onto the variant the switch wants.
277
+ * Converge ONE installed prompt onto the variant the machine's switches want.
278
+ *
279
+ * The file is the one built for the machine's exact switches (`machineVariantKey`): an off
280
+ * variant is read from its own tree and no other, so a host with a compliance arm on a
281
+ * compliance-off machine never falls through to a file that carries the posting steps. A host
282
+ * the machine needs no off variant for comes from the normal source order.
187
283
  *
188
284
  * Never throws: every failure is a warning and the outcome `failed`, so the next
189
285
  * file is still tried.
190
286
  */
191
- async function convergeFile(kind, fileName, opts) {
192
- const { claudeDir, curation, warn, root, focuses } = opts;
287
+ async function convergeFile(kind, host, opts) {
288
+ const { claudeDir, switches, warn, root, focuses, folderRefused, links } = opts;
289
+ const fileName = host.file;
193
290
  const target = installedFile(claudeDir, kind, fileName);
194
291
  const installed = await readIfPresent(target);
195
292
  if (installed === 'absent')
196
293
  return 'not-installed';
294
+ // Reading through a link is harmless and tells a prompt that is there from one that is not. It is
295
+ // left as it is: the folder is reported once, by the caller.
296
+ if (folderRefused)
297
+ return 'failed';
197
298
  if (installed === 'unreadable') {
198
- warn(`curation variants: cannot read the installed ${kind}/${fileName} (${target}) — left as it is`);
299
+ warn(`prompt variants: cannot read the installed ${kind}/${fileName} (${target}) — left as it is`);
199
300
  return 'failed';
200
301
  }
201
- // Curation off: the variant is the roster file itself. Curation on: the normal
202
- // source order, which is where the curation-on file of that host lives.
203
- const candidates = curation
302
+ const key = machineVariantKey(switches, host.axes);
303
+ const candidates = key === 'on'
204
304
  ? onSourceDirs(kind, root).map(dir => path.join(dir, fileName))
205
- : [path.join(curationOffDir(kind, root), fileName)];
305
+ : [path.join(root, ...variantOffRelPath(key, kind, fileName).split('/'))];
206
306
  const source = await firstReadable(candidates);
207
307
  if (source === undefined) {
208
- warn(`curation variants: no source for ${kind}/${fileName} (searched: ${candidates.join(', ')}) — left as it is`);
308
+ warn(`prompt variants: no source for ${kind}/${fileName} (searched: ${candidates.join(', ')}) — left as it is`);
209
309
  return 'failed';
210
310
  }
211
311
  const wanted = withInstalledState(kind, fileName, source.bytes, installed, focuses);
@@ -215,11 +315,11 @@ async function convergeFile(kind, fileName, opts) {
215
315
  return 'unchanged';
216
316
  try {
217
317
  // Prompts are UTF-8 text; the atomic writer takes a string and preserves the
218
- // target's permission mode.
219
- await writeFileAtomicExclusive(target, wanted.toString('utf-8'));
318
+ // target's permission mode. A link at the target is replaced by the rename, never followed.
319
+ await writeFileReplacingLink(target, wanted.toString('utf-8'), links);
220
320
  }
221
321
  catch (err) {
222
- warn(`curation variants: could not rewrite ${kind}/${fileName} (${target}) — ${String(err)}`);
322
+ warn(`prompt variants: could not rewrite ${kind}/${fileName} (${target}) — ${String(err)}`);
223
323
  return 'failed';
224
324
  }
225
325
  return 'rewritten';
@@ -286,14 +386,18 @@ async function pathExists(p) {
286
386
  *
287
387
  * Staged under `claudeDir` itself, not under `skills/`: a staging directory a
288
388
  * crash leaves behind must not look like a second skill to Claude Code.
389
+ *
390
+ * A target that is a link is moved aside as the link itself, never followed, so what it points to is
391
+ * left as it was; the link is recorded in `links` once the real folder stands in its place.
289
392
  */
290
- async function swapInSkillDirectory(claudeDir, sourceDir, target) {
393
+ async function swapInSkillDirectory(claudeDir, sourceDir, target, links) {
291
394
  const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${Date.now().toString(36)}.tmp`);
292
395
  const backup = `${staging}.old`;
293
396
  try {
294
397
  await copyDirectory(sourceDir, staging);
295
398
  await fs.mkdir(path.dirname(target), { recursive: true });
296
- const hadTarget = await pathExists(target);
399
+ const wasLink = await entryIsLink(target);
400
+ const hadTarget = wasLink || await pathExists(target);
297
401
  if (hadTarget)
298
402
  await fs.rename(target, backup);
299
403
  try {
@@ -304,14 +408,16 @@ async function swapInSkillDirectory(claudeDir, sourceDir, target) {
304
408
  await fs.rename(backup, target);
305
409
  throw err;
306
410
  }
411
+ if (wasLink)
412
+ links.replacedLinks.push(target);
307
413
  }
308
414
  finally {
309
415
  await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
310
416
  await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
311
417
  }
312
418
  }
313
- /** Converge the curation-gated skill directories. Never throws. */
314
- async function convergeSkills(opts) {
419
+ /** Converge the curation-gated skill directories. A skill folder that was a link and was swapped is recorded in `links`. Never throws. */
420
+ async function convergeSkills(opts, links) {
315
421
  const { claudeDir, devflowDir, curation, plugins, warn } = opts;
316
422
  const selected = skillsOf(plugins);
317
423
  let state = 'unchanged';
@@ -334,7 +440,7 @@ async function convergeSkills(opts) {
334
440
  const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
335
441
  if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
336
442
  continue;
337
- await swapInSkillDirectory(claudeDir, resolved.dir, target);
443
+ await swapInSkillDirectory(claudeDir, resolved.dir, target, links);
338
444
  state = 'installed';
339
445
  }
340
446
  catch (err) {
@@ -344,65 +450,310 @@ async function convergeSkills(opts) {
344
450
  }
345
451
  return state;
346
452
  }
347
- // ── Convergence ────────────────────────────────────────────────────────────
453
+ /** The result of a converge that did not run: nothing changed and, with compliance off, nothing is proven. */
454
+ function notRun(switches, reason) {
455
+ return {
456
+ converged: false,
457
+ commandsRewritten: [],
458
+ agentsRewritten: [],
459
+ unchanged: 0,
460
+ notInstalled: 0,
461
+ posting: switches.compliance ? POSTING_CLEAR : { kind: 'unknown', reason },
462
+ replacedLinks: [],
463
+ refusedFolders: [],
464
+ };
465
+ }
348
466
  /**
349
- * Make the installed commands, agents and apply-decisions skill match the
350
- * machine's curation switch.
467
+ * Make the installed commands and agents match the machine's two switches: the per-kind
468
+ * converge of the prompts.
469
+ *
470
+ * Each roster host is rewritten to the file built for the machine's exact switches (see
471
+ * {@link convergeFile}). With compliance off the converge also answers the fail-closed
472
+ * question: the build manifest names the hosts that carry a compliance arm, and an installed one
473
+ * that could not be converged is listed in `posting` as still posting. A compliance-off machine whose
474
+ * build manifest is unusable answers `unknown`, because it cannot name those hosts.
351
475
  *
352
- * Never throws. A caller reads `converged`; it does not catch. A claudeDir that is
353
- * not absolute is reported the same way rather than thrown, because an install
354
- * must not die on it.
476
+ * A kind's `devflow` folder that is a link, or not a folder, is refused: nothing is written in it, the
477
+ * installed prompts there count as not converged, and the folder is named in `refusedFolders` and in
478
+ * a warning. A prompt that is a link in a real folder is replaced by a real file, and named in
479
+ * `replacedLinks` and in a warning.
480
+ *
481
+ * Never throws. A caller reads `converged` and `posting`; it does not catch.
355
482
  */
356
- export async function convergeCurationVariants(opts) {
357
- const { claudeDir, curation, warn } = opts;
358
- const empty = {
359
- converged: false, commandsRewritten: [], agentsRewritten: [], unchanged: 0, notInstalled: 0, skill: 'unchanged',
360
- };
483
+ export async function convergePromptVariants(opts) {
484
+ const { claudeDir, switches, warn } = opts;
361
485
  // Precondition, asserted in production code: a relative claudeDir would resolve
362
486
  // the install targets somewhere unexpected.
363
487
  if (!path.isAbsolute(claudeDir)) {
364
- warn(`curation variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
365
- return empty;
488
+ warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
489
+ return notRun(switches, 'the Claude directory is not an absolute path');
366
490
  }
367
491
  const root = opts.packageRoot ?? getPackageRoot();
368
492
  const focuses = installedLanguageFocuses(opts.plugins);
369
493
  let failed = false;
494
+ let unknownReason = null;
495
+ let manifestHosts = null;
496
+ if (!switches.compliance) {
497
+ const manifest = await readVariantHosts(root);
498
+ if (manifest.ok) {
499
+ manifestHosts = manifest.hosts;
500
+ }
501
+ else {
502
+ warn(`prompt variants: ${manifest.reason} — the installed prompts that still post cannot be named. Run \`npm run build:mds\`, then \`devflow init\`.`);
503
+ unknownReason = manifest.reason;
504
+ failed = true;
505
+ }
506
+ }
370
507
  let unchanged = 0;
371
508
  let notInstalled = 0;
509
+ const stillPosting = [];
372
510
  const rewritten = { commands: [], agents: [] };
511
+ const links = newLinkReport();
373
512
  for (const kind of KINDS) {
374
- const roster = await readRoster(kind, root, warn);
513
+ // A link at the folder sends every write below it into another place, so the folder is proven first.
514
+ const folderRefused = await refuseUnrealFolder(links, path.join(claudeDir, kind, 'devflow'));
515
+ if (folderRefused)
516
+ failed = true;
517
+ const roster = await readRoster(kind, root, manifestHosts, warn);
518
+ // A roster read in part is a failed converge. Past the bound, a host left out may carry a
519
+ // compliance arm, so a compliance-off machine can no longer name every prompt that still posts.
520
+ if (roster.incomplete !== null)
521
+ failed = true;
522
+ if (roster.incomplete === 'truncated' && !switches.compliance && unknownReason === null) {
523
+ unknownReason = `more than ${MAX_CURATION_ROSTER} ${kind} carry a variant, and the ones past the bound were not converged`;
524
+ }
525
+ // A manifest host the roster cannot hold was never converged. If it carries a compliance arm, it may still post.
526
+ if (roster.refused.length > 0)
527
+ failed = true;
528
+ const refusedPosting = roster.refused.find(host => host.axes.compliance);
529
+ if (refusedPosting !== undefined && !switches.compliance && unknownReason === null) {
530
+ unknownReason = `the build manifest lists ${kind}/${refusedPosting.file}, a name the converge cannot hold, so it was not converged`;
531
+ }
375
532
  if (!roster.present) {
376
- warn(`curation variants: no ${kind} variants under ${curationOffDir(kind, root)} — run \`npm run build:mds\`; installed ${kind} left as they are`);
533
+ warn(`prompt variants: no ${kind} variants under ${curationOffDir(kind, root)} — run \`npm run build:mds\`; installed ${kind} left as they are`);
377
534
  failed = true;
378
535
  continue;
379
536
  }
380
- for (const fileName of roster.names) {
381
- const outcome = await convergeFile(kind, fileName, { claudeDir, curation, warn, root, focuses });
537
+ for (const host of roster.hosts) {
538
+ const outcome = await convergeFile(kind, host, { claudeDir, switches, warn, root, focuses, folderRefused, links });
382
539
  if (outcome === 'rewritten')
383
- rewritten[kind].push(fileName);
540
+ rewritten[kind].push(host.file);
384
541
  else if (outcome === 'unchanged')
385
542
  unchanged++;
386
543
  else if (outcome === 'not-installed')
387
544
  notInstalled++;
388
- else
545
+ else {
389
546
  failed = true;
547
+ if (!switches.compliance && host.axes.compliance)
548
+ stillPosting.push(installedFile(claudeDir, kind, host.file));
549
+ }
390
550
  }
391
551
  }
392
- // Evaluated unconditionally, after the files: a failed prompt never skips the skill
393
- // (one failure must not leave the rest of the fan-out half-converged).
394
- const skill = await convergeSkills(opts);
395
- if (skill === 'failed')
396
- failed = true;
552
+ let posting = POSTING_CLEAR;
553
+ if (unknownReason !== null)
554
+ posting = { kind: 'unknown', reason: unknownReason };
555
+ else if (stillPosting.length > 0)
556
+ posting = { kind: 'left', files: stillPosting };
557
+ warnLinkReport(links, warn);
397
558
  return {
398
559
  converged: !failed,
399
560
  commandsRewritten: rewritten.commands,
400
561
  agentsRewritten: rewritten.agents,
401
562
  unchanged,
402
563
  notInstalled,
564
+ posting,
565
+ replacedLinks: links.replacedLinks,
566
+ refusedFolders: links.refusedFolders,
567
+ };
568
+ }
569
+ /**
570
+ * Run one per-kind converge and fold an unexpected throw into its fallback result, so the
571
+ * composed converge never throws and a failed kind never skips the next.
572
+ */
573
+ async function attempt(label, warn, fallback, run) {
574
+ try {
575
+ return await run();
576
+ }
577
+ catch (err) {
578
+ warn(`${label}: the converge failed — ${err instanceof Error ? err.message : String(err)}`);
579
+ return fallback;
580
+ }
581
+ }
582
+ /**
583
+ * Converge the workflow skill folders of the selected plugins onto the machine's switches.
584
+ *
585
+ * D-WORKFLOW-FOLDER-INSTALL: a folder is complete under every variant key, so a switch of either axis
586
+ * moves it to the folder of the new exact key (the same read and the same swap as the install), and a
587
+ * folder that already holds that key's files is left alone. A switch to compliance off writes the
588
+ * compliance-off folder, which leaves out the files its file list omits, so none of them stays. A
589
+ * workflow whose plugin is not selected is not installed here and is not removed either: a full
590
+ * install owns the removal. Never throws.
591
+ */
592
+ async function convergeWorkflowFolders(opts) {
593
+ const { claudeDir, switches, plugins, folders, root, warn } = opts;
594
+ const installed = [];
595
+ const unchanged = [];
596
+ const failures = [];
597
+ const stalled = [];
598
+ const owned = selectedWorkflowFolders(plugins, workflowFolderNames(folders));
599
+ for (const folder of folders) {
600
+ if (!owned.has(folder.name))
601
+ continue;
602
+ const target = path.join(claudeDir, 'skills', prefixSkillName(folder.name));
603
+ const fail = (message) => {
604
+ warn(`workflow folders: ${message}`);
605
+ failures.push(message);
606
+ stalled.push(target);
607
+ };
608
+ const read = await readWorkflowFolder(folder, switches, root);
609
+ if (!read.ok) {
610
+ fail(read.reason);
611
+ continue;
612
+ }
613
+ const links = newLinkReport();
614
+ const result = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links });
615
+ switch (result.outcome) {
616
+ case 'installed':
617
+ installed.push(folder.name);
618
+ break;
619
+ case 'unchanged':
620
+ unchanged.push(folder.name);
621
+ break;
622
+ case 'refused':
623
+ fail(refusedFolderMessage(links.refusedFolders[0] ?? target));
624
+ break;
625
+ case 'failed':
626
+ fail(`could not converge ${prefixSkillName(folder.name)} (${result.reason ?? 'unknown error'})`);
627
+ break;
628
+ default: {
629
+ const unhandled = result.outcome;
630
+ fail(`unhandled folder outcome ${String(unhandled)}`);
631
+ }
632
+ }
633
+ }
634
+ return { converged: failures.length === 0, installed, unchanged, failures, stalled };
635
+ }
636
+ /**
637
+ * The posting answer of the workflow-folder converge. Compliance on is clear. With compliance off, a
638
+ * folder that was not converged still holds the text of the previous key, which may post, so it is named.
639
+ * A converge that did not finish and names no folder (it threw) cannot say which folders post, so it is
640
+ * `unknown` and never `clear`.
641
+ */
642
+ export function workflowFoldersPosting(folders, compliance) {
643
+ if (compliance)
644
+ return POSTING_CLEAR;
645
+ if (folders.stalled.length > 0)
646
+ return { kind: 'left', files: folders.stalled };
647
+ if (!folders.converged)
648
+ return { kind: 'unknown', reason: `the workflow folders: ${folders.failures.join('; ')}` };
649
+ return POSTING_CLEAR;
650
+ }
651
+ /**
652
+ * The one converge for both switches: it makes the installed tree match the machine's curation
653
+ * and compliance switches. `devflow init`, `devflow curation` and `devflow compliance` all call it.
654
+ *
655
+ * It composes five per-kind converges, each a result and never a throw:
656
+ * - {@link convergePromptVariants}: the commands and agents, for both axes;
657
+ * - the apply-decisions skill, which follows the curation switch;
658
+ * - {@link convergeFromManifest}: the compliance skill and rule, the one place the manifest
659
+ * becomes compliance options;
660
+ * - {@link convergeReferences}: the `devflow:git` references, which follow the compliance switch
661
+ * (the overlay from the one source root of the switch, then the removal list or the restore);
662
+ * - the workflow skill folders, which follow both switches (the folder of the exact key).
663
+ * A failed kind is a warning and `converged: false`, and the next kind still runs. A compliance-off
664
+ * machine that still has a prompt or a reference file that posts, or cannot name one, is `posting`
665
+ * and never `clear`: the caller decides what that costs (the compliance command exits non-zero,
666
+ * init and curation warn).
667
+ *
668
+ * Never throws.
669
+ */
670
+ export async function convergeVariantAxes(opts) {
671
+ const { claudeDir, devflowDir, manifest, plugins, warn, packageRoot } = opts;
672
+ const switches = { curation: manifest.features.curation, compliance: manifest.features.compliance.enabled };
673
+ // Precondition, asserted in production code: a relative claudeDir would resolve
674
+ // every install target somewhere unexpected, the skill removal included.
675
+ if (!path.isAbsolute(claudeDir)) {
676
+ warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
677
+ return {
678
+ ...notRun(switches, 'the Claude directory is not an absolute path'),
679
+ skill: 'unchanged',
680
+ compliance: { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] },
681
+ references: {
682
+ converged: false,
683
+ rewritten: [],
684
+ removed: [],
685
+ restored: [],
686
+ failures: ['the Claude directory is not an absolute path'],
687
+ stillPosting: [],
688
+ unproven: !switches.compliance,
689
+ },
690
+ workflowFolders: { converged: false, installed: [], unchanged: [], failures: ['the Claude directory is not an absolute path'], stalled: [] },
691
+ };
692
+ }
693
+ const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
694
+ const skillLinks = newLinkReport();
695
+ const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot }, skillLinks));
696
+ warnLinkReport(skillLinks, warn);
697
+ const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn }));
698
+ const references = await attempt('reference converge', warn, {
699
+ converged: false,
700
+ rewritten: [],
701
+ removed: [],
702
+ restored: [],
703
+ failures: ['the reference converge failed'],
704
+ stillPosting: [],
705
+ unproven: !switches.compliance,
706
+ }, () => convergeReferences({ claudeDir, devflowDir, compliance: switches.compliance, warn, packageRoot }));
707
+ const workflowFolders = await attempt('workflow folders', warn, { converged: false, installed: [], unchanged: [], failures: ['the workflow folder converge failed'], stalled: [] }, () => convergeWorkflowFolders({
708
+ claudeDir,
709
+ switches,
710
+ plugins,
711
+ folders: opts.workflowFolders ?? WORKFLOW_FOLDERS,
712
+ root: opts.workflowFoldersRoot ?? packageRoot ?? getPackageRoot(),
713
+ warn,
714
+ }));
715
+ return {
716
+ converged: prompts.converged && skill !== 'failed' && compliance.converged && references.converged && workflowFolders.converged,
717
+ commandsRewritten: prompts.commandsRewritten,
718
+ agentsRewritten: prompts.agentsRewritten,
719
+ unchanged: prompts.unchanged,
720
+ notInstalled: prompts.notInstalled,
403
721
  skill,
722
+ compliance,
723
+ references,
724
+ workflowFolders,
725
+ posting: mergePosting(mergePosting(prompts.posting, referencesPosting(references)), workflowFoldersPosting(workflowFolders, switches.compliance)),
726
+ replacedLinks: [...prompts.replacedLinks, ...skillLinks.replacedLinks, ...compliance.replacedLinks],
727
+ refusedFolders: [...prompts.refusedFolders, ...compliance.refusedFolders],
404
728
  };
405
729
  }
730
+ /**
731
+ * The posting answer of the references converge. Compliance on, and a converge that finished, are clear.
732
+ * A failure that left the overlay unproven cannot name the files that post, so it is `unknown`; a failure
733
+ * that only left named files in place is `left`.
734
+ */
735
+ function referencesPosting(references) {
736
+ if (references.unproven)
737
+ return { kind: 'unknown', reason: `the installed references: ${references.failures.join('; ')}` };
738
+ if (references.stillPosting.length > 0)
739
+ return { kind: 'left', files: references.stillPosting };
740
+ return POSTING_CLEAR;
741
+ }
742
+ /**
743
+ * Join the posting answers of two kinds. `unknown` wins, because a file list that leaves a kind out
744
+ * would claim more than was proven; two unknowns keep both reasons; two lists join.
745
+ */
746
+ function mergePosting(a, b) {
747
+ if (a.kind === 'unknown' && b.kind === 'unknown')
748
+ return { kind: 'unknown', reason: `${a.reason}; ${b.reason}` };
749
+ if (a.kind === 'unknown')
750
+ return a;
751
+ if (b.kind === 'unknown')
752
+ return b;
753
+ if (a.kind === 'left' && b.kind === 'left')
754
+ return { kind: 'left', files: [...a.files, ...b.files] };
755
+ return a.kind === 'left' ? a : b;
756
+ }
406
757
  // ── The toggle ─────────────────────────────────────────────────────────────
407
758
  /** The running package's version, or null when its package.json cannot be read or holds none. */
408
759
  export async function readRunningVersion(packageRoot = getPackageRoot()) {
@@ -420,18 +771,57 @@ export async function readRunningVersion(packageRoot = getPackageRoot()) {
420
771
  * The caller prints it only on a toggle: a run that moved nothing says nothing.
421
772
  */
422
773
  export function describeCurationConverge(result, curation) {
774
+ return describeVariantConverge(result, 'curation', curation);
775
+ }
776
+ /**
777
+ * One line for what a converge changed on an axis, or null when it changed nothing: the prompts
778
+ * rewritten for the variant the axis now holds and, for curation, the skill it installed or removed.
779
+ */
780
+ export function describeVariantConverge(result, axis, enabled) {
423
781
  const parts = [];
424
782
  const prompts = result.commandsRewritten.length + result.agentsRewritten.length;
425
783
  if (prompts > 0) {
426
784
  parts.push(`${result.commandsRewritten.length} command(s) and ${result.agentsRewritten.length} agent(s) ` +
427
- `rewritten for the curation-${curation ? 'on' : 'off'} variant`);
785
+ `rewritten for the ${axis}-${enabled ? 'on' : 'off'} variant`);
428
786
  }
429
787
  if (result.skill === 'installed')
430
788
  parts.push('the apply-decisions skill installed');
431
789
  if (result.skill === 'removed')
432
790
  parts.push('the apply-decisions skill removed');
791
+ const references = result.references;
792
+ if (references !== undefined) {
793
+ const changed = references.rewritten.length + references.restored.length;
794
+ if (changed > 0)
795
+ parts.push(`${changed} reference file(s) written`);
796
+ if (references.removed.length > 0)
797
+ parts.push(`${references.removed.length} reference file(s) removed`);
798
+ }
433
799
  return parts.length === 0 ? null : parts.join('; ');
434
800
  }
801
+ /**
802
+ * The lines that say a compliance-off machine may still hold a prompt that posts, or none when
803
+ * the converge proved it clear. The files are the installed ones that could not be converged.
804
+ */
805
+ export function describePostingLeft(posting) {
806
+ switch (posting.kind) {
807
+ case 'clear':
808
+ return [];
809
+ case 'left':
810
+ return [
811
+ `${posting.files.length} installed file(s) still post: ${posting.files.join(', ')}`,
812
+ 'Run `devflow init` to install the compliance-off files. If init cannot remove one, delete it by hand.',
813
+ ];
814
+ case 'unknown':
815
+ return [
816
+ `The installed files that still post cannot be named: ${posting.reason}`,
817
+ 'Run `devflow init` to install the compliance-off files.',
818
+ ];
819
+ default: {
820
+ const unhandled = posting;
821
+ return [`unhandled posting state ${JSON.stringify(unhandled)}`];
822
+ }
823
+ }
824
+ }
435
825
  /**
436
826
  * Whether the version the manifest records is the version of the running package.
437
827
  * Pure. An unknown running version (null) never matches: the installed files are then
@@ -448,10 +838,27 @@ export function installedVersionMatches(installedVersion, runningVersion) {
448
838
  * `describeEchoedValue`.
449
839
  */
450
840
  export function curationToggleSkew(installedVersion, runningVersion, curation) {
841
+ return variantToggleSkew(installedVersion, runningVersion, 'curation', curation);
842
+ }
843
+ /**
844
+ * The version-skew check for a toggle of either axis. A compliance-off toggle also says that posting
845
+ * steps remain installed, because that is the unsafe side of the mismatch: the installed prompts are
846
+ * the ones that carry them.
847
+ */
848
+ export function variantToggleSkew(installedVersion, runningVersion, axis, enabled) {
451
849
  if (installedVersionMatches(installedVersion, runningVersion))
452
850
  return null;
453
- return (`The installed prompts come from devflow ${describeEchoedValue(installedVersion)} and this CLI is ${runningVersion ?? 'of unknown version'}, ` +
454
- `so the curation-${curation ? 'on' : 'off'} variants were not applied. Run \`devflow init\` to apply them.`);
851
+ const skew = `The installed prompts come from devflow ${describeEchoedValue(installedVersion)} and this CLI is ${runningVersion ?? 'of unknown version'}, ` +
852
+ `so the ${axis}-${enabled ? 'on' : 'off'} variants were not applied`;
853
+ return axis === 'compliance' && !enabled
854
+ ? `${skew}, and posting steps remain installed until devflow init. Run \`devflow init\` to apply them.`
855
+ : `${skew}. Run \`devflow init\` to apply them.`;
856
+ }
857
+ /** The line for a toggle that found no readable install manifest. */
858
+ function unreadableManifestMessage(axis, enabled) {
859
+ return axis === 'compliance' && !enabled
860
+ ? 'No readable install manifest, so the compliance-off variants were not applied, and posting steps remain installed until devflow init. Run `devflow init`.'
861
+ : `No readable install manifest, so the ${axis} variants were not applied. Run \`devflow init\`.`;
455
862
  }
456
863
  /**
457
864
  * The version-skew line for a command that writes curation data, or null when the
@@ -474,9 +881,66 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
474
881
  `${running ?? 'of unknown version'}. Run \`devflow init\` first. Nothing was written.`);
475
882
  }
476
883
  /**
477
- * What `devflow curation --enable/--disable` does after the switch is written:
478
- * converge the installed tree onto the new value, then reapply the saved agent
479
- * model and effort mapping.
884
+ * Record the variants the installed prompts now hold in the manifest, after a converge that
885
+ * finished clean (AC-29). A record that already says so is left alone, so a toggle to the value
886
+ * already installed writes nothing. A failed write is a warning: the status line then reads
887
+ * the record as it stood, which says `mismatch` or `unknown` and names `devflow init`.
888
+ *
889
+ * The read and the write run under the manifest lock, as the switch writers do, so a switch that
890
+ * another devflow run writes in between is never overwritten with the older copy read here. The
891
+ * record is written only when the manifest's switches are still the ones the run converged to: if
892
+ * they changed during the run, nothing is recorded, the record that stood is removed in the same
893
+ * locked write (the status then reads unknown) and a warning says so. A lock that cannot be
894
+ * taken, or is lost before the write, withholds the record with a warning.
895
+ */
896
+ async function recordInstalledVariants(devflowDir, switches, warn) {
897
+ try {
898
+ const locked = await withManifestLock(devflowDir, {}, async (lock) => {
899
+ const manifest = await readManifest(devflowDir);
900
+ if (manifest === null)
901
+ return 'no-manifest';
902
+ if (manifest.features.curation !== switches.curation || manifest.features.compliance.enabled !== switches.compliance) {
903
+ // The record names switches that no longer hold: remove it, so the status reads unknown.
904
+ if (!Object.hasOwn(manifest, 'installedVariants'))
905
+ return 'switches-changed';
906
+ if (!(await lock.stillHeld()))
907
+ return 'lock-lost';
908
+ const { installedVariants: _stale, ...unrecorded } = manifest;
909
+ await writeManifest(devflowDir, unrecorded);
910
+ return 'switches-changed';
911
+ }
912
+ const recorded = readInstalledVariants(manifest.installedVariants);
913
+ if (recorded !== null && recorded.curation === switches.curation && recorded.compliance === switches.compliance)
914
+ return 'current';
915
+ if (!(await lock.stillHeld()))
916
+ return 'lock-lost';
917
+ await writeManifest(devflowDir, { ...manifest, installedVariants: { curation: switches.curation, compliance: switches.compliance } });
918
+ return 'written';
919
+ });
920
+ if (!locked.ok || locked.value === 'lock-lost') {
921
+ warn('install record: the install manifest lock could not be held, so the installed variants were not recorded. Run `devflow init`.');
922
+ }
923
+ else if (locked.value === 'no-manifest') {
924
+ warn('install record: no readable install manifest, so the installed variants were not recorded. Run `devflow init`.');
925
+ }
926
+ else if (locked.value === 'switches-changed') {
927
+ warn('install record: the switches changed during the run, so the installed variants were not recorded. Run `devflow init`.');
928
+ }
929
+ }
930
+ catch (err) {
931
+ warn(`install record: could not record the installed variants — ${String(err)}`);
932
+ }
933
+ }
934
+ /**
935
+ * What `devflow curation --enable/--disable` and `devflow compliance` do after the switch is
936
+ * written: converge the installed tree onto both switches with the one converge, record the
937
+ * installed variants when it finished clean, then reapply the saved agent model and effort mapping.
938
+ *
939
+ * The switch of the toggled axis is the value the caller just set. The other axis and the
940
+ * compliance frameworks come from the manifest, which the caller has already written.
941
+ *
942
+ * A version skew keeps the check this toggle has always had: the installed prompts are not the
943
+ * ones this package would rewrite, so nothing is converged and the outcome is `skipped`.
480
944
  *
481
945
  * The converge carries each installed agent's model and effort into the variant it
482
946
  * writes (D-AGENT-OVERRIDE-CARRY), so the overrides are already on the file and this
@@ -487,28 +951,34 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
487
951
  *
488
952
  * Never throws.
489
953
  */
490
- export async function applyCurationToggle(opts) {
491
- const { claudeDir, devflowDir, curation, runningVersion, warn } = opts;
954
+ export async function applyVariantToggle(opts) {
955
+ const { claudeDir, devflowDir, axis, enabled, runningVersion, warn } = opts;
492
956
  const manifest = await readManifest(devflowDir);
493
957
  if (manifest === null) {
494
- return {
495
- kind: 'skipped',
496
- reason: 'no-manifest',
497
- message: 'No readable install manifest, so the curation variants were not applied. Run `devflow init`.',
498
- };
958
+ return { kind: 'skipped', reason: 'no-manifest', message: unreadableManifestMessage(axis, enabled) };
499
959
  }
500
- const skew = curationToggleSkew(manifest.version, runningVersion, curation);
960
+ const skew = variantToggleSkew(manifest.version, runningVersion, axis, enabled);
501
961
  if (skew !== null)
502
962
  return { kind: 'skipped', reason: 'version-skew', message: skew };
503
963
  const selection = new Set(manifest.plugins);
504
- const result = await convergeCurationVariants({
964
+ const state = {
965
+ features: {
966
+ curation: axis === 'curation' ? enabled : manifest.features.curation,
967
+ compliance: axis === 'compliance' ? { ...manifest.features.compliance, enabled } : manifest.features.compliance,
968
+ rules: manifest.features.rules,
969
+ },
970
+ };
971
+ const result = await convergeVariantAxes({
505
972
  claudeDir,
506
973
  devflowDir,
507
- curation,
974
+ manifest: state,
508
975
  plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
509
976
  warn,
510
977
  packageRoot: opts.packageRoot,
511
978
  });
979
+ if (result.converged) {
980
+ await recordInstalledVariants(devflowDir, { curation: state.features.curation, compliance: state.features.compliance.enabled }, warn);
981
+ }
512
982
  let agentMappingReapplied = false;
513
983
  if (result.agentsRewritten.length > 0) {
514
984
  try {
@@ -521,7 +991,7 @@ export async function applyCurationToggle(opts) {
521
991
  agentMappingReapplied = true;
522
992
  }
523
993
  catch (err) {
524
- warn(`curation variants: could not reapply the agent model mapping — ${String(err)}`);
994
+ warn(`prompt variants: could not reapply the agent model mapping — ${String(err)}`);
525
995
  }
526
996
  }
527
997
  return { kind: 'converged', result, agentMappingReapplied };