devflow-kit 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/backup-session.js +131 -0
  9. package/dist/cli/commands/compliance-prompts.js +9 -4
  10. package/dist/cli/commands/compliance.js +184 -24
  11. package/dist/cli/commands/curation.js +23 -10
  12. package/dist/cli/commands/gate-level-prompts.js +39 -25
  13. package/dist/cli/commands/gates.js +5 -7
  14. package/dist/cli/commands/init.js +169 -184
  15. package/dist/cli/commands/install-report.js +129 -0
  16. package/dist/cli/commands/legacy-sweep.js +159 -0
  17. package/dist/cli/commands/proxy.js +6 -1
  18. package/dist/cli/commands/rules.js +75 -21
  19. package/dist/cli/commands/skills.js +42 -9
  20. package/dist/cli/commands/tracker.js +9 -3
  21. package/dist/cli/commands/uninstall.js +339 -75
  22. package/dist/commands/bug-analysis.md +69 -120
  23. package/dist/commands/dynamic-build.md +23 -23
  24. package/dist/commands/implement.md +46 -32
  25. package/dist/commands/release.md +1 -1
  26. package/dist/commands/resolve.md +35 -31
  27. package/dist/commands/self-review.md +8 -7
  28. package/dist/compliance-off/agents/git.md +120 -0
  29. package/dist/compliance-off/agents/triage.md +180 -0
  30. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  31. package/dist/compliance-off/commands/code-review.md +520 -0
  32. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  33. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  34. package/dist/compliance-off/commands/implement.md +736 -0
  35. package/dist/compliance-off/commands/plan.md +671 -0
  36. package/dist/compliance-off/commands/release.md +309 -0
  37. package/dist/compliance-off/commands/resolve.md +742 -0
  38. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  39. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  41. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  42. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  43. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  45. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  52. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  59. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  66. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  67. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  68. package/dist/core/agent-models.js +19 -4
  69. package/dist/core/assets.js +126 -17
  70. package/dist/core/compliance-off-files.js +27 -0
  71. package/dist/core/compliance.js +14 -0
  72. package/dist/core/curation-variants.js +460 -112
  73. package/dist/core/flags.js +2 -2
  74. package/dist/core/fs-atomic.js +5 -1
  75. package/dist/core/installed-variants.js +63 -0
  76. package/dist/core/manifest.js +48 -5
  77. package/dist/core/mds-variants.js +700 -3
  78. package/dist/core/orphan-sweep.js +29 -2
  79. package/dist/core/path-set.js +15 -0
  80. package/dist/core/plugins.js +68 -10
  81. package/dist/core/reference-sweep.js +42 -25
  82. package/dist/core/retired-shipped-names.js +278 -0
  83. package/dist/curation-compliance-off/agents/triage.md +176 -0
  84. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  85. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  86. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  87. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  88. package/dist/curation-compliance-off/commands/implement.md +689 -0
  89. package/dist/curation-compliance-off/commands/plan.md +631 -0
  90. package/dist/curation-compliance-off/commands/release.md +279 -0
  91. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  92. package/dist/curation-off/agents/code.md +6 -6
  93. package/dist/curation-off/agents/fix.md +6 -6
  94. package/dist/curation-off/agents/harden.md +7 -6
  95. package/dist/curation-off/agents/refactor.md +6 -6
  96. package/dist/curation-off/commands/bug-analysis.md +69 -119
  97. package/dist/curation-off/commands/dynamic-build.md +23 -23
  98. package/dist/curation-off/commands/implement.md +46 -32
  99. package/dist/curation-off/commands/release.md +1 -1
  100. package/dist/curation-off/commands/resolve.md +35 -31
  101. package/dist/curation-off/commands/self-review.md +8 -7
  102. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  103. package/dist/targets/claude-code/behavior-install.js +23 -9
  104. package/dist/targets/claude-code/charter-render.js +374 -0
  105. package/dist/targets/claude-code/compliance-install.js +62 -17
  106. package/dist/targets/claude-code/compliance-off-files.js +249 -0
  107. package/dist/targets/claude-code/curation-install.js +632 -117
  108. package/dist/targets/claude-code/folder-swap.js +142 -0
  109. package/dist/targets/claude-code/install-backup.js +780 -0
  110. package/dist/targets/claude-code/installer.js +795 -199
  111. package/dist/targets/claude-code/language-stamp.js +6 -0
  112. package/dist/targets/claude-code/legacy-skill-hashes.js +170 -0
  113. package/dist/targets/claude-code/linked-folder.js +176 -0
  114. package/dist/targets/claude-code/reference-converge.js +131 -0
  115. package/dist/targets/claude-code/rules-folder.js +34 -0
  116. package/dist/targets/claude-code/shipped-set.js +160 -0
  117. package/dist/targets/claude-code/tracker-install.js +21 -8
  118. package/dist/targets/claude-code/tracker-servers.js +20 -6
  119. package/dist/targets/claude-code/workflow-folder-install.js +251 -0
  120. package/dist/variant-axes.json +109 -0
  121. package/package.json +1 -1
  122. package/src/assets/agents/_partials/_code.mds +6 -6
  123. package/src/assets/agents/evaluate.md +1 -1
  124. package/src/assets/agents/git.mds +32 -0
  125. package/src/assets/agents/harden.mds +7 -6
  126. package/src/assets/agents/test.md +21 -12
  127. package/src/assets/agents/triage.mds +2 -0
  128. package/src/assets/agents/validate.md +10 -10
  129. package/src/assets/commands/_partials/_compliance.mds +5 -3
  130. package/src/assets/commands/_partials/_engine.mds +9 -8
  131. package/src/assets/commands/_partials/_publication.mds +10 -1
  132. package/src/assets/commands/bug-analysis.mds +80 -126
  133. package/src/assets/commands/code-review.mds +43 -0
  134. package/src/assets/commands/dynamic-build.mds +109 -15
  135. package/src/assets/commands/dynamic-tickets.mds +13 -0
  136. package/src/assets/commands/implement.mds +145 -33
  137. package/src/assets/commands/plan.mds +19 -0
  138. package/src/assets/commands/release.mds +50 -1
  139. package/src/assets/commands/resolve.mds +217 -32
  140. package/src/assets/commands/self-review.mds +8 -7
  141. package/src/assets/mds/git/_ops.mds +23 -0
  142. package/src/assets/mds/git/_ops_steps.mds +17 -0
  143. package/src/assets/mds/git/_pr.mds +1 -1
  144. package/src/assets/mds/git/_references.mds +6 -0
  145. package/src/assets/mds/tracker/_common.mds +4 -0
  146. package/src/assets/mds/tracker/_github.mds +42 -0
  147. package/src/assets/mds/tracker/_jira.mds +43 -0
  148. package/src/assets/mds/tracker/_linear.mds +43 -0
  149. package/src/assets/rules/quality.md +2 -0
  150. package/src/assets/scripts/ci-wait.cjs +204 -126
  151. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  152. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  153. package/src/assets/scripts/hooks/preamble +18 -8
  154. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  155. package/src/assets/scripts/pr-evidence.cjs +41 -2
  156. package/src/assets/scripts/verify-evidence.cjs +408 -56
  157. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  158. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  159. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  160. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  161. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  162. package/src/assets/skills/git/SKILL.md +3 -3
  163. package/src/assets/skills/git/references/github-api.md +2 -139
  164. package/src/assets/skills/git/references/github-posting.md +158 -0
  165. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  166. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  167. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  168. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -1,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,25 @@
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';
94
+ import { swapFolderIn } from './folder-swap.js';
81
95
  import { copyDirectory, resolveSkillSource } from './installer.js';
82
96
  import { LANGUAGE_STAMPED_COMMANDS, stampForConverge } from './language-stamp.js';
97
+ import { directoryState, newLinkReport, refuseUnrealFolder, refusedFolderMessage, unlinkLinkAt, warnLinkReport, writeFileReplacingLink } from './linked-folder.js';
98
+ import { convergeReferences } from './reference-converge.js';
99
+ import { installWorkflowFolder, readWorkflowFolder } from './workflow-folder-install.js';
83
100
  // ── Bounds ─────────────────────────────────────────────────────────────────
84
101
  /**
85
102
  * The most variant files one kind may carry. The shipped roster is 14 commands and
@@ -92,38 +109,113 @@ const MAX_SKILL_TREE_DEPTH = 4;
92
109
  /** A roster entry is a flat, lowercase, hyphenated markdown basename. */
93
110
  const ROSTER_NAME_RE = /^[a-z0-9][a-z0-9-]*\.md$/;
94
111
  const KINDS = ['commands', 'agents'];
112
+ const POSTING_CLEAR = { kind: 'clear' };
95
113
  // ── Internals ──────────────────────────────────────────────────────────────
96
114
  function installedFile(claudeDir, kind, fileName) {
97
115
  return path.join(claudeDir, kind, 'devflow', fileName);
98
116
  }
99
- /** The curation-ON source directories for a kind, most-preferred first. */
117
+ /**
118
+ * The both-axes-ON source directories for a kind, most-preferred first: the files a host
119
+ * with no off variant for the machine installs.
120
+ */
100
121
  function onSourceDirs(kind, root) {
101
- return kind === 'commands' ? commandSourceDirs(true, root) : agentSourceDirs(root, true);
122
+ return kind === 'commands' ? commandSourceDirs(ALL_SWITCHES_ON, root) : agentSourceDirs(root, ALL_SWITCHES_ON);
123
+ }
124
+ /** The roster-shaped file names directly under `dir`, `'absent'` when it does not exist and `'unreadable'` (warned) when it cannot be read. */
125
+ async function listVariantDir(dir, kind, warn) {
126
+ try {
127
+ return (await fs.readdir(dir)).filter(entry => ROSTER_NAME_RE.test(entry));
128
+ }
129
+ catch (err) {
130
+ if (err.code === 'ENOENT')
131
+ return 'absent';
132
+ warn(`prompt variants: cannot read the ${kind} variants under ${dir} — ${String(err)}`);
133
+ return 'unreadable';
134
+ }
102
135
  }
103
136
  /**
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.
137
+ * The compiled prompts the build says carry an arm, read from its variant manifest. An error names the
138
+ * cause in a line a person can act on. Compliance on never reads it: only a compliance-off machine needs
139
+ * the manifest to know which prompts it must not leave unconverged.
109
140
  */
110
- async function readRoster(kind, root, warn) {
111
- let entries;
141
+ async function readVariantHosts(root) {
142
+ const file = path.join(root, ...VARIANT_MANIFEST_REL_PATH.split('/'));
143
+ let text;
112
144
  try {
113
- entries = await fs.readdir(curationOffDir(kind, root));
145
+ text = await fs.readFile(file, 'utf-8');
114
146
  }
115
147
  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 };
148
+ const cause = err.code === 'ENOENT' ? 'does not exist' : `cannot be read (${String(err)})`;
149
+ return { ok: false, reason: `the variant manifest ${cause}: ${file}` };
150
+ }
151
+ const parsed = parseVariantManifest(text);
152
+ if (!parsed.ok)
153
+ return { ok: false, reason: `the variant manifest is not valid: ${file} (${describeVariantManifestParseError(parsed.error)})` };
154
+ const hosts = new Map();
155
+ for (const host of parsed.value)
156
+ hosts.set(`${host.kind}/${host.name}.md`, { file: `${host.name}.md`, axes: host.axes });
157
+ return { ok: true, hosts };
158
+ }
159
+ /**
160
+ * The roster of one kind: the prompts that differ on an axis, sorted by file name.
161
+ *
162
+ * The files under the three off trees name them (the build owns which hosts have an arm), and a host the
163
+ * build manifest lists is added, so a tree removed from the package still leaves its hosts in the roster.
164
+ * An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it
165
+ * (a curation arm has a file under curation-off or both-off, a compliance arm under compliance-off or
166
+ * both-off).
167
+ *
168
+ * `present` is false when no tree holds this kind and the manifest lists none (the build has not run, or
169
+ * only `build:cli` did), so the caller can say so instead of reporting a converge that had nothing to do.
170
+ * `incomplete` says the roster was read in part, already warned: a tree could not be listed
171
+ * (`unreadable`), or the roster passed {@link MAX_CURATION_ROSTER} and the hosts after it were left out
172
+ * (`truncated`). `refused` lists the manifest hosts whose name {@link ROSTER_NAME_RE} does not hold (the
173
+ * build accepts a wider name), already warned: they are left out of `hosts` and the caller fails closed.
174
+ */
175
+ async function readRoster(kind, root, manifestHosts, warn) {
176
+ const listings = {
177
+ curation: await listVariantDir(curationOffDir(kind, root), kind, warn),
178
+ compliance: await listVariantDir(complianceOffDir(kind, root), kind, warn),
179
+ both: await listVariantDir(bothOffDir(kind, root), kind, warn),
180
+ };
181
+ const inferred = new Map();
182
+ const note = (names, axes) => {
183
+ if (typeof names === 'string')
184
+ return;
185
+ for (const name of names) {
186
+ const arms = inferred.get(name) ?? { curation: false, compliance: false };
187
+ inferred.set(name, {
188
+ curation: arms.curation || axes.includes('curation'),
189
+ compliance: arms.compliance || axes.includes('compliance'),
190
+ });
191
+ }
192
+ };
193
+ note(listings.curation, ['curation']);
194
+ note(listings.compliance, ['compliance']);
195
+ note(listings.both, ['curation', 'compliance']);
196
+ const files = new Map();
197
+ const refused = [];
198
+ for (const [file, axes] of inferred)
199
+ files.set(file, { file, axes });
200
+ for (const [key, host] of manifestHosts ?? []) {
201
+ if (!key.startsWith(`${kind}/`) || !(host.axes.curation || host.axes.compliance))
202
+ continue;
203
+ if (ROSTER_NAME_RE.test(host.file)) {
204
+ files.set(host.file, host);
205
+ }
206
+ else {
207
+ warn(`prompt variants: the build manifest lists ${kind}/${host.file}, a name the converge cannot hold; it was not converged`);
208
+ refused.push(host);
209
+ }
120
210
  }
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 };
211
+ const hosts = [...files.values()].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
212
+ const present = hosts.length > 0 || Object.values(listings).some(listing => typeof listing !== 'string');
213
+ if (hosts.length > MAX_CURATION_ROSTER) {
214
+ warn(`prompt variants: ${hosts.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
215
+ return { hosts: hosts.slice(0, MAX_CURATION_ROSTER), present: true, incomplete: 'truncated', refused };
125
216
  }
126
- return { names, present: true };
217
+ const unreadable = Object.values(listings).some(listing => listing === 'unreadable');
218
+ return { hosts, present, incomplete: unreadable ? 'unreadable' : null, refused };
127
219
  }
128
220
  async function readIfPresent(file) {
129
221
  try {
@@ -183,29 +275,38 @@ function withInstalledState(kind, fileName, source, installed, focuses) {
183
275
  : source;
184
276
  }
185
277
  /**
186
- * Converge ONE installed prompt onto the variant the switch wants.
278
+ * Converge ONE installed prompt onto the variant the machine's switches want.
279
+ *
280
+ * The file is the one built for the machine's exact switches (`machineVariantKey`): an off
281
+ * variant is read from its own tree and no other, so a host with a compliance arm on a
282
+ * compliance-off machine never falls through to a file that carries the posting steps. A host
283
+ * the machine needs no off variant for comes from the normal source order.
187
284
  *
188
285
  * Never throws: every failure is a warning and the outcome `failed`, so the next
189
286
  * file is still tried.
190
287
  */
191
- async function convergeFile(kind, fileName, opts) {
192
- const { claudeDir, curation, warn, root, focuses } = opts;
288
+ async function convergeFile(kind, host, opts) {
289
+ const { claudeDir, switches, warn, root, focuses, folderRefused, links } = opts;
290
+ const fileName = host.file;
193
291
  const target = installedFile(claudeDir, kind, fileName);
194
292
  const installed = await readIfPresent(target);
195
293
  if (installed === 'absent')
196
294
  return 'not-installed';
295
+ // Reading through a link is harmless and tells a prompt that is there from one that is not. It is
296
+ // left as it is: the folder is reported once, by the caller.
297
+ if (folderRefused)
298
+ return 'failed';
197
299
  if (installed === 'unreadable') {
198
- warn(`curation variants: cannot read the installed ${kind}/${fileName} (${target}) — left as it is`);
300
+ warn(`prompt variants: cannot read the installed ${kind}/${fileName} (${target}) — left as it is`);
199
301
  return 'failed';
200
302
  }
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
303
+ const key = machineVariantKey(switches, host.axes);
304
+ const candidates = key === 'on'
204
305
  ? onSourceDirs(kind, root).map(dir => path.join(dir, fileName))
205
- : [path.join(curationOffDir(kind, root), fileName)];
306
+ : [path.join(root, ...variantOffRelPath(key, kind, fileName).split('/'))];
206
307
  const source = await firstReadable(candidates);
207
308
  if (source === undefined) {
208
- warn(`curation variants: no source for ${kind}/${fileName} (searched: ${candidates.join(', ')}) — left as it is`);
309
+ warn(`prompt variants: no source for ${kind}/${fileName} (searched: ${candidates.join(', ')}) — left as it is`);
209
310
  return 'failed';
210
311
  }
211
312
  const wanted = withInstalledState(kind, fileName, source.bytes, installed, focuses);
@@ -215,11 +316,11 @@ async function convergeFile(kind, fileName, opts) {
215
316
  return 'unchanged';
216
317
  try {
217
318
  // 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'));
319
+ // target's permission mode. A link at the target is replaced by the rename, never followed.
320
+ await writeFileReplacingLink(target, wanted.toString('utf-8'), links);
220
321
  }
221
322
  catch (err) {
222
- warn(`curation variants: could not rewrite ${kind}/${fileName} (${target}) — ${String(err)}`);
323
+ warn(`prompt variants: could not rewrite ${kind}/${fileName} (${target}) — ${String(err)}`);
223
324
  return 'failed';
224
325
  }
225
326
  return 'rewritten';
@@ -280,50 +381,98 @@ async function pathExists(p) {
280
381
  return false;
281
382
  }
282
383
  }
384
+ /**
385
+ * The two folders a skill swap uses, both directly under `claudeDir`: the staging copy, and the
386
+ * backup the old skill folder waits in while the copy is renamed into place.
387
+ *
388
+ * @param now - The clock reading that makes the name unique, in milliseconds.
389
+ */
390
+ export function curationSwapPaths(claudeDir, now) {
391
+ const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${now.toString(36)}.tmp`);
392
+ return { staging, backup: `${staging}.old` };
393
+ }
283
394
  /**
284
395
  * Install one skill directory atomically: stage a copy beside the claude dir's
285
396
  * skills, then swap it in, restoring the old directory if the swap fails.
286
397
  *
287
398
  * Staged under `claudeDir` itself, not under `skills/`: a staging directory a
288
399
  * crash leaves behind must not look like a second skill to Claude Code.
400
+ *
401
+ * A file in the old directory that devflow did not ship moves to the backup folder first. If one
402
+ * cannot move, nothing is swapped: the shipped files are written into the old directory and the
403
+ * file stays (see {@link swapFolderIn}).
404
+ *
405
+ * A target that is a link is moved aside as the link itself, never followed, so what it points to is
406
+ * left as it was; the link is recorded in `links` once the real folder stands in its place.
289
407
  */
290
- async function swapInSkillDirectory(claudeDir, sourceDir, target) {
291
- const staging = path.join(claudeDir, `.devflow-curation-${process.pid}-${Date.now().toString(36)}.tmp`);
292
- const backup = `${staging}.old`;
408
+ async function swapInSkillDirectory(claudeDir, sourceDir, target, links, backups) {
409
+ const { staging, backup } = curationSwapPaths(claudeDir, Date.now());
293
410
  try {
294
411
  await copyDirectory(sourceDir, staging);
295
412
  await fs.mkdir(path.dirname(target), { recursive: true });
296
- const hadTarget = await pathExists(target);
297
- if (hadTarget)
298
- await fs.rename(target, backup);
299
- try {
300
- await fs.rename(staging, target);
301
- }
302
- catch (err) {
303
- if (hadTarget)
304
- await fs.rename(backup, target);
305
- throw err;
306
- }
413
+ await swapFolderIn({
414
+ backups,
415
+ target,
416
+ staged: staging,
417
+ aside: backup,
418
+ links,
419
+ mergeInto: () => copyDirectory(staging, target, undefined, links),
420
+ });
307
421
  }
308
422
  finally {
309
423
  await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
310
- await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
311
424
  }
312
425
  }
313
- /** Converge the curation-gated skill directories. Never throws. */
314
- async function convergeSkills(opts) {
426
+ /**
427
+ * Remove a curation-gated skill folder. The files devflow shipped go by the session's list, anything
428
+ * else moves to the backup folder first, and the folder goes with a plain `rmdir`: 'kept' means it
429
+ * still stands because a file could not move. A link is unlinked and a file in its place moves aside.
430
+ */
431
+ async function removeSkillFolder(target, backups) {
432
+ const state = await directoryState(target);
433
+ switch (state) {
434
+ case 'absent':
435
+ return 'absent';
436
+ case 'link':
437
+ return (await unlinkLinkAt(target)) === 'unlinked' ? 'removed' : 'kept';
438
+ case 'unusable': {
439
+ const moved = await backups.moveWhole(target);
440
+ return moved.status === 'moved' ? 'removed' : moved.status === 'absent' ? 'absent' : 'kept';
441
+ }
442
+ case 'real': {
443
+ await backups.prepass([target]);
444
+ return (await backups.removeListed(target)).folderRemoved ? 'removed' : 'kept';
445
+ }
446
+ default: {
447
+ const unreachable = state;
448
+ void unreachable;
449
+ return 'kept';
450
+ }
451
+ }
452
+ }
453
+ /** Converge the curation-gated skill directories. A skill folder that was a link and was swapped is recorded in `links`. Never throws. */
454
+ async function convergeSkills(opts, links) {
315
455
  const { claudeDir, devflowDir, curation, plugins, warn } = opts;
316
456
  const selected = skillsOf(plugins);
317
457
  let state = 'unchanged';
458
+ // A skills folder that is a link or not a folder is refused once. The removal and the swap below
459
+ // would reach the folder the link points to: `directoryState` of a skill folder follows the link
460
+ // above it. The refusal is recorded in `links`, and the converge is not clean.
461
+ if (await refuseUnrealFolder(links, path.join(claudeDir, 'skills')))
462
+ return 'failed';
318
463
  // Every gated skill is converged: a failure on one must not skip the next.
319
464
  for (const skill of CURATION_GATED_SKILLS) {
320
465
  const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
321
466
  try {
322
467
  if (!curation) {
323
- if (!(await pathExists(target)))
324
- continue;
325
- await fs.rm(target, { recursive: true });
326
- state = 'removed';
468
+ const removal = await removeSkillFolder(target, opts.backups);
469
+ if (removal === 'removed') {
470
+ state = 'removed';
471
+ }
472
+ else if (removal === 'kept') {
473
+ warn(`curation variants: the ${prefixSkillName(skill)} skill folder (${target}) still holds a file that could not be moved aside, so it was not removed`);
474
+ state = 'failed';
475
+ }
327
476
  continue;
328
477
  }
329
478
  if (!selected.has(skill)) {
@@ -334,7 +483,7 @@ async function convergeSkills(opts) {
334
483
  const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
335
484
  if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
336
485
  continue;
337
- await swapInSkillDirectory(claudeDir, resolved.dir, target);
486
+ await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
338
487
  state = 'installed';
339
488
  }
340
489
  catch (err) {
@@ -344,65 +493,311 @@ async function convergeSkills(opts) {
344
493
  }
345
494
  return state;
346
495
  }
347
- // ── Convergence ────────────────────────────────────────────────────────────
496
+ /** The result of a converge that did not run: nothing changed and, with compliance off, nothing is proven. */
497
+ function notRun(switches, reason) {
498
+ return {
499
+ converged: false,
500
+ commandsRewritten: [],
501
+ agentsRewritten: [],
502
+ unchanged: 0,
503
+ notInstalled: 0,
504
+ posting: switches.compliance ? POSTING_CLEAR : { kind: 'unknown', reason },
505
+ replacedLinks: [],
506
+ refusedFolders: [],
507
+ };
508
+ }
348
509
  /**
349
- * Make the installed commands, agents and apply-decisions skill match the
350
- * machine's curation switch.
510
+ * Make the installed commands and agents match the machine's two switches: the per-kind
511
+ * converge of the prompts.
512
+ *
513
+ * Each roster host is rewritten to the file built for the machine's exact switches (see
514
+ * {@link convergeFile}). With compliance off the converge also answers the fail-closed
515
+ * question: the build manifest names the hosts that carry a compliance arm, and an installed one
516
+ * that could not be converged is listed in `posting` as still posting. A compliance-off machine whose
517
+ * build manifest is unusable answers `unknown`, because it cannot name those hosts.
351
518
  *
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.
519
+ * A kind's `devflow` folder that is a link, or not a folder, is refused: nothing is written in it, the
520
+ * installed prompts there count as not converged, and the folder is named in `refusedFolders` and in
521
+ * a warning. A prompt that is a link in a real folder is replaced by a real file, and named in
522
+ * `replacedLinks` and in a warning.
523
+ *
524
+ * Never throws. A caller reads `converged` and `posting`; it does not catch.
355
525
  */
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
- };
526
+ export async function convergePromptVariants(opts) {
527
+ const { claudeDir, switches, warn } = opts;
361
528
  // Precondition, asserted in production code: a relative claudeDir would resolve
362
529
  // the install targets somewhere unexpected.
363
530
  if (!path.isAbsolute(claudeDir)) {
364
- warn(`curation variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
365
- return empty;
531
+ warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
532
+ return notRun(switches, 'the Claude directory is not an absolute path');
366
533
  }
367
534
  const root = opts.packageRoot ?? getPackageRoot();
368
535
  const focuses = installedLanguageFocuses(opts.plugins);
369
536
  let failed = false;
537
+ let unknownReason = null;
538
+ let manifestHosts = null;
539
+ if (!switches.compliance) {
540
+ const manifest = await readVariantHosts(root);
541
+ if (manifest.ok) {
542
+ manifestHosts = manifest.hosts;
543
+ }
544
+ else {
545
+ warn(`prompt variants: ${manifest.reason} — the installed prompts that still post cannot be named. Run \`npm run build:mds\`, then \`devflow init\`.`);
546
+ unknownReason = manifest.reason;
547
+ failed = true;
548
+ }
549
+ }
370
550
  let unchanged = 0;
371
551
  let notInstalled = 0;
552
+ const stillPosting = [];
372
553
  const rewritten = { commands: [], agents: [] };
554
+ const links = newLinkReport();
373
555
  for (const kind of KINDS) {
374
- const roster = await readRoster(kind, root, warn);
556
+ // A link at the folder sends every write below it into another place, so the folder is proven first.
557
+ const folderRefused = await refuseUnrealFolder(links, path.join(claudeDir, kind, 'devflow'));
558
+ if (folderRefused)
559
+ failed = true;
560
+ const roster = await readRoster(kind, root, manifestHosts, warn);
561
+ // A roster read in part is a failed converge. Past the bound, a host left out may carry a
562
+ // compliance arm, so a compliance-off machine can no longer name every prompt that still posts.
563
+ if (roster.incomplete !== null)
564
+ failed = true;
565
+ if (roster.incomplete === 'truncated' && !switches.compliance && unknownReason === null) {
566
+ unknownReason = `more than ${MAX_CURATION_ROSTER} ${kind} carry a variant, and the ones past the bound were not converged`;
567
+ }
568
+ // A manifest host the roster cannot hold was never converged. If it carries a compliance arm, it may still post.
569
+ if (roster.refused.length > 0)
570
+ failed = true;
571
+ const refusedPosting = roster.refused.find(host => host.axes.compliance);
572
+ if (refusedPosting !== undefined && !switches.compliance && unknownReason === null) {
573
+ unknownReason = `the build manifest lists ${kind}/${refusedPosting.file}, a name the converge cannot hold, so it was not converged`;
574
+ }
375
575
  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`);
576
+ warn(`prompt variants: no ${kind} variants under ${curationOffDir(kind, root)} — run \`npm run build:mds\`; installed ${kind} left as they are`);
377
577
  failed = true;
378
578
  continue;
379
579
  }
380
- for (const fileName of roster.names) {
381
- const outcome = await convergeFile(kind, fileName, { claudeDir, curation, warn, root, focuses });
580
+ for (const host of roster.hosts) {
581
+ const outcome = await convergeFile(kind, host, { claudeDir, switches, warn, root, focuses, folderRefused, links });
382
582
  if (outcome === 'rewritten')
383
- rewritten[kind].push(fileName);
583
+ rewritten[kind].push(host.file);
384
584
  else if (outcome === 'unchanged')
385
585
  unchanged++;
386
586
  else if (outcome === 'not-installed')
387
587
  notInstalled++;
388
- else
588
+ else {
389
589
  failed = true;
590
+ if (!switches.compliance && host.axes.compliance)
591
+ stillPosting.push(installedFile(claudeDir, kind, host.file));
592
+ }
390
593
  }
391
594
  }
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;
595
+ let posting = POSTING_CLEAR;
596
+ if (unknownReason !== null)
597
+ posting = { kind: 'unknown', reason: unknownReason };
598
+ else if (stillPosting.length > 0)
599
+ posting = { kind: 'left', files: stillPosting };
600
+ warnLinkReport(links, warn);
397
601
  return {
398
602
  converged: !failed,
399
603
  commandsRewritten: rewritten.commands,
400
604
  agentsRewritten: rewritten.agents,
401
605
  unchanged,
402
606
  notInstalled,
607
+ posting,
608
+ replacedLinks: links.replacedLinks,
609
+ refusedFolders: links.refusedFolders,
610
+ };
611
+ }
612
+ /**
613
+ * Run one per-kind converge and fold an unexpected throw into its fallback result, so the
614
+ * composed converge never throws and a failed kind never skips the next.
615
+ */
616
+ async function attempt(label, warn, fallback, run) {
617
+ try {
618
+ return await run();
619
+ }
620
+ catch (err) {
621
+ warn(`${label}: the converge failed — ${err instanceof Error ? err.message : String(err)}`);
622
+ return fallback;
623
+ }
624
+ }
625
+ /**
626
+ * Converge the workflow skill folders of the selected plugins onto the machine's switches.
627
+ *
628
+ * D-WORKFLOW-FOLDER-INSTALL: a folder is complete under every variant key, so a switch of either axis
629
+ * moves it to the folder of the new exact key (the same read and the same swap as the install), and a
630
+ * folder that already holds that key's files is left alone. A switch to compliance off writes the
631
+ * compliance-off folder, which leaves out the files its file list omits, so none of them stays. A
632
+ * workflow whose plugin is not selected is not installed here and is not removed either: a full
633
+ * install owns the removal. Never throws.
634
+ */
635
+ async function convergeWorkflowFolders(opts) {
636
+ const { claudeDir, switches, plugins, folders, root, warn, backups } = opts;
637
+ const installed = [];
638
+ const unchanged = [];
639
+ const failures = [];
640
+ const stalled = [];
641
+ const owned = selectedWorkflowFolders(plugins, workflowFolderNames(folders));
642
+ for (const folder of folders) {
643
+ if (!owned.has(folder.name))
644
+ continue;
645
+ const target = path.join(claudeDir, 'skills', prefixSkillName(folder.name));
646
+ const fail = (message) => {
647
+ warn(`workflow folders: ${message}`);
648
+ failures.push(message);
649
+ stalled.push(target);
650
+ };
651
+ const read = await readWorkflowFolder(folder, switches, root);
652
+ if (!read.ok) {
653
+ fail(read.reason);
654
+ continue;
655
+ }
656
+ const links = newLinkReport();
657
+ const result = await installWorkflowFolder({ claudeDir, name: folder.name, files: read.files, links, backups });
658
+ switch (result.outcome) {
659
+ case 'installed':
660
+ installed.push(folder.name);
661
+ break;
662
+ case 'unchanged':
663
+ unchanged.push(folder.name);
664
+ break;
665
+ case 'refused':
666
+ fail(refusedFolderMessage(links.refusedFolders[0] ?? target));
667
+ break;
668
+ case 'failed':
669
+ fail(`could not converge ${prefixSkillName(folder.name)} (${result.reason ?? 'unknown error'})`);
670
+ break;
671
+ default: {
672
+ const unhandled = result.outcome;
673
+ fail(`unhandled folder outcome ${String(unhandled)}`);
674
+ }
675
+ }
676
+ }
677
+ return { converged: failures.length === 0, installed, unchanged, failures, stalled };
678
+ }
679
+ /**
680
+ * The posting answer of the workflow-folder converge. Compliance on is clear. With compliance off, a
681
+ * folder that was not converged still holds the text of the previous key, which may post, so it is named.
682
+ * A converge that did not finish and names no folder (it threw) cannot say which folders post, so it is
683
+ * `unknown` and never `clear`.
684
+ */
685
+ export function workflowFoldersPosting(folders, compliance) {
686
+ if (compliance)
687
+ return POSTING_CLEAR;
688
+ if (folders.stalled.length > 0)
689
+ return { kind: 'left', files: folders.stalled };
690
+ if (!folders.converged)
691
+ return { kind: 'unknown', reason: `the workflow folders: ${folders.failures.join('; ')}` };
692
+ return POSTING_CLEAR;
693
+ }
694
+ /**
695
+ * The one converge for both switches: it makes the installed tree match the machine's curation
696
+ * and compliance switches. `devflow init`, `devflow curation` and `devflow compliance` all call it.
697
+ *
698
+ * It composes five per-kind converges, each a result and never a throw:
699
+ * - {@link convergePromptVariants}: the commands and agents, for both axes;
700
+ * - the apply-decisions skill, which follows the curation switch;
701
+ * - {@link convergeFromManifest}: the compliance skill and rule, the one place the manifest
702
+ * becomes compliance options;
703
+ * - {@link convergeReferences}: the `devflow:git` references, which follow the compliance switch
704
+ * (the overlay from the one source root of the switch, then the removal list or the restore);
705
+ * - the workflow skill folders, which follow both switches (the folder of the exact key).
706
+ * A failed kind is a warning and `converged: false`, and the next kind still runs. A compliance-off
707
+ * machine that still has a prompt or a reference file that posts, or cannot name one, is `posting`
708
+ * and never `clear`: the caller decides what that costs (the compliance command exits non-zero,
709
+ * init and curation warn).
710
+ *
711
+ * Never throws.
712
+ */
713
+ export async function convergeVariantAxes(opts) {
714
+ const { claudeDir, devflowDir, manifest, plugins, warn, packageRoot, backups } = opts;
715
+ const switches = { curation: manifest.features.curation, compliance: manifest.features.compliance.enabled };
716
+ // Precondition, asserted in production code: a relative claudeDir would resolve
717
+ // every install target somewhere unexpected, the skill removal included.
718
+ if (!path.isAbsolute(claudeDir)) {
719
+ warn(`prompt variants: claudeDir is not an absolute path ("${claudeDir}") — skipping convergence`);
720
+ return {
721
+ ...notRun(switches, 'the Claude directory is not an absolute path'),
722
+ skill: 'unchanged',
723
+ compliance: { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] },
724
+ references: {
725
+ converged: false,
726
+ rewritten: [],
727
+ removed: [],
728
+ restored: [],
729
+ failures: ['the Claude directory is not an absolute path'],
730
+ stillPosting: [],
731
+ unproven: !switches.compliance,
732
+ },
733
+ workflowFolders: { converged: false, installed: [], unchanged: [], failures: ['the Claude directory is not an absolute path'], stalled: [] },
734
+ };
735
+ }
736
+ const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
737
+ const skillLinks = newLinkReport();
738
+ const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot, backups }, skillLinks));
739
+ warnLinkReport(skillLinks, warn);
740
+ const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn, backups }));
741
+ const references = await attempt('reference converge', warn, {
742
+ converged: false,
743
+ rewritten: [],
744
+ removed: [],
745
+ restored: [],
746
+ failures: ['the reference converge failed'],
747
+ stillPosting: [],
748
+ unproven: !switches.compliance,
749
+ }, () => convergeReferences({ claudeDir, devflowDir, compliance: switches.compliance, warn, packageRoot, backups }));
750
+ const workflowFolders = await attempt('workflow folders', warn, { converged: false, installed: [], unchanged: [], failures: ['the workflow folder converge failed'], stalled: [] }, () => convergeWorkflowFolders({
751
+ claudeDir,
752
+ switches,
753
+ plugins,
754
+ folders: opts.workflowFolders ?? WORKFLOW_FOLDERS,
755
+ root: opts.workflowFoldersRoot ?? packageRoot ?? getPackageRoot(),
756
+ warn,
757
+ backups,
758
+ }));
759
+ return {
760
+ converged: prompts.converged && skill !== 'failed' && compliance.converged && references.converged && workflowFolders.converged,
761
+ commandsRewritten: prompts.commandsRewritten,
762
+ agentsRewritten: prompts.agentsRewritten,
763
+ unchanged: prompts.unchanged,
764
+ notInstalled: prompts.notInstalled,
403
765
  skill,
766
+ compliance,
767
+ references,
768
+ workflowFolders,
769
+ posting: mergePosting(mergePosting(prompts.posting, referencesPosting(references)), workflowFoldersPosting(workflowFolders, switches.compliance)),
770
+ replacedLinks: [...prompts.replacedLinks, ...skillLinks.replacedLinks, ...compliance.replacedLinks],
771
+ refusedFolders: [...prompts.refusedFolders, ...compliance.refusedFolders],
404
772
  };
405
773
  }
774
+ /**
775
+ * The posting answer of the references converge. Compliance on, and a converge that finished, are clear.
776
+ * A failure that left the overlay unproven cannot name the files that post, so it is `unknown`; a failure
777
+ * that only left named files in place is `left`.
778
+ */
779
+ function referencesPosting(references) {
780
+ if (references.unproven)
781
+ return { kind: 'unknown', reason: `the installed references: ${references.failures.join('; ')}` };
782
+ if (references.stillPosting.length > 0)
783
+ return { kind: 'left', files: references.stillPosting };
784
+ return POSTING_CLEAR;
785
+ }
786
+ /**
787
+ * Join the posting answers of two kinds. `unknown` wins, because a file list that leaves a kind out
788
+ * would claim more than was proven; two unknowns keep both reasons; two lists join.
789
+ */
790
+ function mergePosting(a, b) {
791
+ if (a.kind === 'unknown' && b.kind === 'unknown')
792
+ return { kind: 'unknown', reason: `${a.reason}; ${b.reason}` };
793
+ if (a.kind === 'unknown')
794
+ return a;
795
+ if (b.kind === 'unknown')
796
+ return b;
797
+ if (a.kind === 'left' && b.kind === 'left')
798
+ return { kind: 'left', files: [...a.files, ...b.files] };
799
+ return a.kind === 'left' ? a : b;
800
+ }
406
801
  // ── The toggle ─────────────────────────────────────────────────────────────
407
802
  /** The running package's version, or null when its package.json cannot be read or holds none. */
408
803
  export async function readRunningVersion(packageRoot = getPackageRoot()) {
@@ -420,18 +815,57 @@ export async function readRunningVersion(packageRoot = getPackageRoot()) {
420
815
  * The caller prints it only on a toggle: a run that moved nothing says nothing.
421
816
  */
422
817
  export function describeCurationConverge(result, curation) {
818
+ return describeVariantConverge(result, 'curation', curation);
819
+ }
820
+ /**
821
+ * One line for what a converge changed on an axis, or null when it changed nothing: the prompts
822
+ * rewritten for the variant the axis now holds and, for curation, the skill it installed or removed.
823
+ */
824
+ export function describeVariantConverge(result, axis, enabled) {
423
825
  const parts = [];
424
826
  const prompts = result.commandsRewritten.length + result.agentsRewritten.length;
425
827
  if (prompts > 0) {
426
828
  parts.push(`${result.commandsRewritten.length} command(s) and ${result.agentsRewritten.length} agent(s) ` +
427
- `rewritten for the curation-${curation ? 'on' : 'off'} variant`);
829
+ `rewritten for the ${axis}-${enabled ? 'on' : 'off'} variant`);
428
830
  }
429
831
  if (result.skill === 'installed')
430
832
  parts.push('the apply-decisions skill installed');
431
833
  if (result.skill === 'removed')
432
834
  parts.push('the apply-decisions skill removed');
835
+ const references = result.references;
836
+ if (references !== undefined) {
837
+ const changed = references.rewritten.length + references.restored.length;
838
+ if (changed > 0)
839
+ parts.push(`${changed} reference file(s) written`);
840
+ if (references.removed.length > 0)
841
+ parts.push(`${references.removed.length} reference file(s) removed`);
842
+ }
433
843
  return parts.length === 0 ? null : parts.join('; ');
434
844
  }
845
+ /**
846
+ * The lines that say a compliance-off machine may still hold a prompt that posts, or none when
847
+ * the converge proved it clear. The files are the installed ones that could not be converged.
848
+ */
849
+ export function describePostingLeft(posting) {
850
+ switch (posting.kind) {
851
+ case 'clear':
852
+ return [];
853
+ case 'left':
854
+ return [
855
+ `${posting.files.length} installed file(s) still post: ${posting.files.join(', ')}`,
856
+ 'Run `devflow init` to install the compliance-off files. If init cannot remove one, delete it by hand.',
857
+ ];
858
+ case 'unknown':
859
+ return [
860
+ `The installed files that still post cannot be named: ${posting.reason}`,
861
+ 'Run `devflow init` to install the compliance-off files.',
862
+ ];
863
+ default: {
864
+ const unhandled = posting;
865
+ return [`unhandled posting state ${JSON.stringify(unhandled)}`];
866
+ }
867
+ }
868
+ }
435
869
  /**
436
870
  * Whether the version the manifest records is the version of the running package.
437
871
  * Pure. An unknown running version (null) never matches: the installed files are then
@@ -448,10 +882,27 @@ export function installedVersionMatches(installedVersion, runningVersion) {
448
882
  * `describeEchoedValue`.
449
883
  */
450
884
  export function curationToggleSkew(installedVersion, runningVersion, curation) {
885
+ return variantToggleSkew(installedVersion, runningVersion, 'curation', curation);
886
+ }
887
+ /**
888
+ * The version-skew check for a toggle of either axis. A compliance-off toggle also says that posting
889
+ * steps remain installed, because that is the unsafe side of the mismatch: the installed prompts are
890
+ * the ones that carry them.
891
+ */
892
+ export function variantToggleSkew(installedVersion, runningVersion, axis, enabled) {
451
893
  if (installedVersionMatches(installedVersion, runningVersion))
452
894
  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.`);
895
+ const skew = `The installed prompts come from devflow ${describeEchoedValue(installedVersion)} and this CLI is ${runningVersion ?? 'of unknown version'}, ` +
896
+ `so the ${axis}-${enabled ? 'on' : 'off'} variants were not applied`;
897
+ return axis === 'compliance' && !enabled
898
+ ? `${skew}, and posting steps remain installed until devflow init. Run \`devflow init\` to apply them.`
899
+ : `${skew}. Run \`devflow init\` to apply them.`;
900
+ }
901
+ /** The line for a toggle that found no readable install manifest. */
902
+ function unreadableManifestMessage(axis, enabled) {
903
+ return axis === 'compliance' && !enabled
904
+ ? 'No readable install manifest, so the compliance-off variants were not applied, and posting steps remain installed until devflow init. Run `devflow init`.'
905
+ : `No readable install manifest, so the ${axis} variants were not applied. Run \`devflow init\`.`;
455
906
  }
456
907
  /**
457
908
  * The version-skew line for a command that writes curation data, or null when the
@@ -474,9 +925,66 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
474
925
  `${running ?? 'of unknown version'}. Run \`devflow init\` first. Nothing was written.`);
475
926
  }
476
927
  /**
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.
928
+ * Record the variants the installed prompts now hold in the manifest, after a converge that
929
+ * finished clean (AC-29). A record that already says so is left alone, so a toggle to the value
930
+ * already installed writes nothing. A failed write is a warning: the status line then reads
931
+ * the record as it stood, which says `mismatch` or `unknown` and names `devflow init`.
932
+ *
933
+ * The read and the write run under the manifest lock, as the switch writers do, so a switch that
934
+ * another devflow run writes in between is never overwritten with the older copy read here. The
935
+ * record is written only when the manifest's switches are still the ones the run converged to: if
936
+ * they changed during the run, nothing is recorded, the record that stood is removed in the same
937
+ * locked write (the status then reads unknown) and a warning says so. A lock that cannot be
938
+ * taken, or is lost before the write, withholds the record with a warning.
939
+ */
940
+ async function recordInstalledVariants(devflowDir, switches, warn) {
941
+ try {
942
+ const locked = await withManifestLock(devflowDir, {}, async (lock) => {
943
+ const manifest = await readManifest(devflowDir);
944
+ if (manifest === null)
945
+ return 'no-manifest';
946
+ if (manifest.features.curation !== switches.curation || manifest.features.compliance.enabled !== switches.compliance) {
947
+ // The record names switches that no longer hold: remove it, so the status reads unknown.
948
+ if (!Object.hasOwn(manifest, 'installedVariants'))
949
+ return 'switches-changed';
950
+ if (!(await lock.stillHeld()))
951
+ return 'lock-lost';
952
+ const { installedVariants: _stale, ...unrecorded } = manifest;
953
+ await writeManifest(devflowDir, unrecorded);
954
+ return 'switches-changed';
955
+ }
956
+ const recorded = readInstalledVariants(manifest.installedVariants);
957
+ if (recorded !== null && recorded.curation === switches.curation && recorded.compliance === switches.compliance)
958
+ return 'current';
959
+ if (!(await lock.stillHeld()))
960
+ return 'lock-lost';
961
+ await writeManifest(devflowDir, { ...manifest, installedVariants: { curation: switches.curation, compliance: switches.compliance } });
962
+ return 'written';
963
+ });
964
+ if (!locked.ok || locked.value === 'lock-lost') {
965
+ warn('install record: the install manifest lock could not be held, so the installed variants were not recorded. Run `devflow init`.');
966
+ }
967
+ else if (locked.value === 'no-manifest') {
968
+ warn('install record: no readable install manifest, so the installed variants were not recorded. Run `devflow init`.');
969
+ }
970
+ else if (locked.value === 'switches-changed') {
971
+ warn('install record: the switches changed during the run, so the installed variants were not recorded. Run `devflow init`.');
972
+ }
973
+ }
974
+ catch (err) {
975
+ warn(`install record: could not record the installed variants — ${String(err)}`);
976
+ }
977
+ }
978
+ /**
979
+ * What `devflow curation --enable/--disable` and `devflow compliance` do after the switch is
980
+ * written: converge the installed tree onto both switches with the one converge, record the
981
+ * installed variants when it finished clean, then reapply the saved agent model and effort mapping.
982
+ *
983
+ * The switch of the toggled axis is the value the caller just set. The other axis and the
984
+ * compliance frameworks come from the manifest, which the caller has already written.
985
+ *
986
+ * A version skew keeps the check this toggle has always had: the installed prompts are not the
987
+ * ones this package would rewrite, so nothing is converged and the outcome is `skipped`.
480
988
  *
481
989
  * The converge carries each installed agent's model and effort into the variant it
482
990
  * writes (D-AGENT-OVERRIDE-CARRY), so the overrides are already on the file and this
@@ -487,28 +995,35 @@ export async function installedScriptsSkew(devflowDir, packageRoot) {
487
995
  *
488
996
  * Never throws.
489
997
  */
490
- export async function applyCurationToggle(opts) {
491
- const { claudeDir, devflowDir, curation, runningVersion, warn } = opts;
998
+ export async function applyVariantToggle(opts) {
999
+ const { claudeDir, devflowDir, axis, enabled, runningVersion, warn } = opts;
492
1000
  const manifest = await readManifest(devflowDir);
493
1001
  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
- };
1002
+ return { kind: 'skipped', reason: 'no-manifest', message: unreadableManifestMessage(axis, enabled) };
499
1003
  }
500
- const skew = curationToggleSkew(manifest.version, runningVersion, curation);
1004
+ const skew = variantToggleSkew(manifest.version, runningVersion, axis, enabled);
501
1005
  if (skew !== null)
502
1006
  return { kind: 'skipped', reason: 'version-skew', message: skew };
503
1007
  const selection = new Set(manifest.plugins);
504
- const result = await convergeCurationVariants({
1008
+ const state = {
1009
+ features: {
1010
+ curation: axis === 'curation' ? enabled : manifest.features.curation,
1011
+ compliance: axis === 'compliance' ? { ...manifest.features.compliance, enabled } : manifest.features.compliance,
1012
+ rules: manifest.features.rules,
1013
+ },
1014
+ };
1015
+ const result = await convergeVariantAxes({
505
1016
  claudeDir,
506
1017
  devflowDir,
507
- curation,
1018
+ manifest: state,
508
1019
  plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
509
1020
  warn,
510
1021
  packageRoot: opts.packageRoot,
1022
+ backups: opts.backups,
511
1023
  });
1024
+ if (result.converged) {
1025
+ await recordInstalledVariants(devflowDir, { curation: state.features.curation, compliance: state.features.compliance.enabled }, warn);
1026
+ }
512
1027
  let agentMappingReapplied = false;
513
1028
  if (result.agentsRewritten.length > 0) {
514
1029
  try {
@@ -521,7 +1036,7 @@ export async function applyCurationToggle(opts) {
521
1036
  agentMappingReapplied = true;
522
1037
  }
523
1038
  catch (err) {
524
- warn(`curation variants: could not reapply the agent model mapping — ${String(err)}`);
1039
+ warn(`prompt variants: could not reapply the agent model mapping — ${String(err)}`);
525
1040
  }
526
1041
  }
527
1042
  return { kind: 'converged', result, agentMappingReapplied };