devflow-kit 3.12.0 → 3.13.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 (109) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/agents/architect.md +14 -9
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/debug.md +3 -3
  5. package/dist/agents/design.md +7 -7
  6. package/dist/agents/harden.md +6 -8
  7. package/dist/agents/review.md +5 -6
  8. package/dist/cli/commands/init-seed.js +5 -1
  9. package/dist/cli/commands/init.js +27 -19
  10. package/dist/cli/commands/tdd.js +230 -0
  11. package/dist/cli/commands/uninstall.js +3 -3
  12. package/dist/cli.js +2 -0
  13. package/dist/commands/bug-analysis.md +4 -4
  14. package/dist/commands/code-review.md +4 -4
  15. package/dist/commands/dynamic-build.md +4 -2
  16. package/dist/commands/implement.md +11 -11
  17. package/dist/commands/plan.md +27 -65
  18. package/dist/commands/release.md +4 -4
  19. package/dist/commands/research.md +6 -6
  20. package/dist/commands/resolve.md +4 -4
  21. package/dist/commands/self-review.md +4 -4
  22. package/dist/compliance-off/commands/bug-analysis.md +4 -4
  23. package/dist/compliance-off/commands/code-review.md +4 -4
  24. package/dist/compliance-off/commands/dynamic-build.md +4 -2
  25. package/dist/compliance-off/commands/implement.md +11 -11
  26. package/dist/compliance-off/commands/plan.md +27 -65
  27. package/dist/compliance-off/commands/release.md +4 -4
  28. package/dist/compliance-off/commands/resolve.md +4 -4
  29. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +3 -2
  30. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -2
  31. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -2
  32. package/dist/compliance-tdd-off/agents/triage.md +173 -0
  33. package/dist/core/assets.js +40 -33
  34. package/dist/core/curation-variants.js +191 -76
  35. package/dist/core/feature-switch.js +3 -2
  36. package/dist/core/installed-variants.js +45 -14
  37. package/dist/core/manifest.js +3 -0
  38. package/dist/core/mds-variants.js +13 -6
  39. package/dist/core/plugins.js +38 -9
  40. package/dist/curation-compliance-off/commands/bug-analysis.md +4 -4
  41. package/dist/curation-compliance-off/commands/code-review.md +4 -4
  42. package/dist/curation-compliance-off/commands/dynamic-build.md +4 -2
  43. package/dist/curation-compliance-off/commands/implement.md +11 -11
  44. package/dist/curation-compliance-off/commands/plan.md +27 -65
  45. package/dist/curation-compliance-off/commands/release.md +4 -4
  46. package/dist/curation-compliance-off/commands/resolve.md +4 -4
  47. package/dist/curation-compliance-tdd-off/agents/triage.md +170 -0
  48. package/dist/curation-off/agents/architect.md +14 -9
  49. package/dist/curation-off/agents/code.md +6 -6
  50. package/dist/curation-off/agents/debug.md +3 -3
  51. package/dist/curation-off/agents/design.md +7 -7
  52. package/dist/curation-off/agents/harden.md +6 -8
  53. package/dist/curation-off/agents/review.md +5 -6
  54. package/dist/curation-off/commands/bug-analysis.md +4 -4
  55. package/dist/curation-off/commands/code-review.md +4 -4
  56. package/dist/curation-off/commands/dynamic-build.md +4 -2
  57. package/dist/curation-off/commands/implement.md +11 -11
  58. package/dist/curation-off/commands/plan.md +27 -65
  59. package/dist/curation-off/commands/release.md +4 -4
  60. package/dist/curation-off/commands/research.md +6 -6
  61. package/dist/curation-off/commands/resolve.md +4 -4
  62. package/dist/curation-off/commands/self-review.md +4 -4
  63. package/dist/curation-tdd-off/agents/code.md +173 -0
  64. package/dist/curation-tdd-off/agents/triage.md +171 -0
  65. package/dist/hud/components/version-badge.js +1 -1
  66. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +3 -2
  67. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -2
  68. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -2
  69. package/dist/targets/claude-code/agent-type-owner.js +20 -15
  70. package/dist/targets/claude-code/curation-install.js +120 -99
  71. package/dist/targets/claude-code/installer.js +26 -29
  72. package/dist/targets/claude-code/shipped-set.js +2 -3
  73. package/dist/tdd-off/agents/code.md +176 -0
  74. package/dist/tdd-off/agents/triage.md +174 -0
  75. package/dist/variant-axes.json +54 -27
  76. package/package.json +1 -1
  77. package/src/assets/agents/_partials/_design.mds +7 -7
  78. package/src/assets/agents/architect.mds +7 -2
  79. package/src/assets/agents/code.mds +19 -7
  80. package/src/assets/agents/debug.mds +3 -3
  81. package/src/assets/agents/harden.mds +6 -8
  82. package/src/assets/agents/plan.md +63 -0
  83. package/src/assets/agents/review.mds +5 -6
  84. package/src/assets/agents/triage.mds +4 -0
  85. package/src/assets/commands/_partials/_engine.mds +4 -2
  86. package/src/assets/commands/_partials/_knowledge.mds +4 -4
  87. package/src/assets/commands/implement.mds +8 -8
  88. package/src/assets/commands/plan.mds +22 -68
  89. package/src/assets/commands/research.mds +2 -2
  90. package/src/assets/mds/git/_ops_steps.mds +3 -2
  91. package/src/assets/scripts/ci-wait.cjs +10 -7
  92. package/src/assets/scripts/resolve-root.cjs +7 -3
  93. package/src/assets/scripts/verify-evidence.cjs +213 -20
  94. package/src/assets/skills/agent-references/references/commands/plan-levels.md +3 -3
  95. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -9
  96. package/src/assets/skills/design-review/SKILL.md +26 -18
  97. package/src/assets/skills/design-review/references/anti-patterns.md +4 -1
  98. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  99. package/src/assets/skills/git/references/patterns.md +3 -0
  100. package/src/assets/skills/patterns/SKILL.md +2 -4
  101. package/src/assets/skills/quality-gates/SKILL.md +2 -2
  102. package/src/assets/skills/quality-gates/references/patterns.md +3 -3
  103. package/src/assets/skills/quality-gates/references/report-template.md +2 -2
  104. package/src/assets/skills/quality-gates/references/violations.md +5 -5
  105. package/src/assets/skills/test-driven-development/SKILL.md +5 -5
  106. package/src/assets/skills/test-driven-development/references/rationalization-prevention.md +1 -1
  107. package/src/assets/skills/testing/SKILL.md +9 -9
  108. package/src/assets/skills/testing/references/report-template.md +1 -1
  109. package/src/assets/skills/testing/references/violations.md +1 -1
@@ -1,25 +1,26 @@
1
1
  /**
2
- * Prompt-variant installer for the Claude Code target: the curation and compliance switches.
2
+ * Prompt-variant installer for the Claude Code target: the curation, compliance and tdd switches.
3
3
  *
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
4
+ * D-LEARNING-VARIANT-INSTALL: the install follows the machine's switches
5
+ * (`features.curation`, `features.compliance` and `features.tdd` in ~/.devflow/manifest.json)
6
+ * and nothing else. Each command and agent that has an arm for a switch that is off is
7
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,
8
+ * (the trees of OFF_OUTPUT_DIRS, such as dist/{curation-off,compliance-off,tdd-off}/{commands,agents},
9
+ * built by D-LEARNING-VARIANTS), and a skill that an off switch gates is not installed (the
10
+ * apply-decisions skill with curation off, the test-driven-development skill with tdd off), so
11
+ * the text reaches no prompt and the skill has no reader. With a switch on,
11
12
  * its on files are installed. A compliance-off machine carries no posting step in any prompt
12
13
  * that has a compliance arm. Three pieces carry it:
13
14
  *
14
15
  * - the source order (src/core/assets.ts commandSourceDirs / agentSourceDirs):
15
16
  * an off combination takes its own tree, and only the all-on combination takes the
16
17
  * normal order, so a host with no arm installs its one file as before;
17
- * - the skill condition (CURATION_GATED_SKILLS in src/core/plugins.ts):
18
- * installViaFileCopy leaves the skill out when curation is off;
18
+ * - the skill condition (AXIS_GATED_SKILLS in src/core/plugins.ts):
19
+ * installViaFileCopy leaves out the skills an off switch gates;
19
20
  * - {@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
+ * ALREADY-installed tree match every switch, run by `devflow init` after the
22
+ * file copy and by `devflow curation`, `devflow compliance` and `devflow tdd` after
23
+ * the switch is written ({@link applyVariantToggle}).
23
24
  *
24
25
  * The converge, decided in turn:
25
26
  * - Its roster is the files that exist under the off trees, joined with the hosts the build
@@ -78,18 +79,18 @@
78
79
  */
79
80
  import { promises as fs } from 'fs';
80
81
  import * as path from 'path';
81
- import { agentSourceDirs, bothOffDir, commandSourceDirs, complianceOffDir, curationOffDir, } from '../../core/assets.js';
82
+ import { agentSourceDirs, commandSourceDirs, curationOffDir, variantOffDir, } from '../../core/assets.js';
82
83
  import { carryStampedTools } from '../../core/agent-frontmatter.js';
83
84
  import { carryAgentOverrides, reapplyAgentMapping } from '../../core/agent-models.js';
84
85
  import { describeEchoedValue } from '../../core/echo-value.js';
85
- import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineVariantKey, parseVariantManifest, variantOffRelPath, } from '../../core/curation-variants.js';
86
+ import { ALL_SWITCHES_ON, OFF_VARIANT_KEYS, VARIANT_AXES, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, machineSwitches, machineVariantKey, offAxesOf, parseVariantManifest, perAxis, sameSwitches, variantOffRelPath, } from '../../core/curation-variants.js';
86
87
  import { readInstalledVariants } from '../../core/installed-variants.js';
87
88
  import { readManifest, writeManifest } from '../../core/manifest.js';
88
89
  import { withManifestLock } from '../../core/manifest-lock.js';
89
90
  import { getPackageRoot } from '../../core/paths.js';
90
91
  import { isProxyEnabled } from '../../core/proxy-state.js';
91
92
  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 { DEVFLOW_PLUGINS, AXIS_GATED_SKILLS, installedLanguageFocuses, prefixSkillName, selectedWorkflowFolders, skillsOf, workflowFolderNames, } from '../../core/plugins.js';
93
94
  import { convergeFromManifest } from './compliance-install.js';
94
95
  import { swapFolderIn } from './folder-swap.js';
95
96
  import { copyDirectory, resolveSkillSource } from './installer.js';
@@ -159,11 +160,11 @@ async function readVariantHosts(root) {
159
160
  /**
160
161
  * The roster of one kind: the prompts that differ on an axis, sorted by file name.
161
162
  *
162
- * The files under the three off trees name them (the build owns which hosts have an arm), and a host the
163
+ * The files under the off trees name them (the build owns which hosts have an arm), and a host the
163
164
  * 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).
165
+ * An arm comes from the manifest entry, or from the trees the file is in when the manifest does not list it:
166
+ * a file in a tree has an arm on each axis its tree turns off (offAxesOf), so a curation arm has a file under
167
+ * curation-off or both-off, a compliance arm under compliance-off or both-off, and so on for the other trees.
167
168
  *
168
169
  * `present` is false when no tree holds this kind and the manifest lists none (the build has not run, or
169
170
  * only `build:cli` did), so the caller can say so instead of reporting a converge that had nothing to do.
@@ -173,32 +174,26 @@ async function readVariantHosts(root) {
173
174
  * build accepts a wider name), already warned: they are left out of `hosts` and the caller fails closed.
174
175
  */
175
176
  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
- };
177
+ const listings = new Map();
178
+ for (const key of OFF_VARIANT_KEYS) {
179
+ listings.set(key, await listVariantDir(variantOffDir(key, kind, root), kind, warn));
180
+ }
181
181
  const inferred = new Map();
182
- const note = (names, axes) => {
182
+ for (const [key, names] of listings) {
183
183
  if (typeof names === 'string')
184
- return;
184
+ continue;
185
+ const turnedOff = offAxesOf(key);
185
186
  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
- });
187
+ const arms = inferred.get(name) ?? perAxis(() => false);
188
+ inferred.set(name, perAxis(axis => arms[axis] || turnedOff.includes(axis)));
191
189
  }
192
- };
193
- note(listings.curation, ['curation']);
194
- note(listings.compliance, ['compliance']);
195
- note(listings.both, ['curation', 'compliance']);
190
+ }
196
191
  const files = new Map();
197
192
  const refused = [];
198
193
  for (const [file, axes] of inferred)
199
194
  files.set(file, { file, axes });
200
195
  for (const [key, host] of manifestHosts ?? []) {
201
- if (!key.startsWith(`${kind}/`) || !(host.axes.curation || host.axes.compliance))
196
+ if (!key.startsWith(`${kind}/`) || !VARIANT_AXES.some(axis => host.axes[axis]))
202
197
  continue;
203
198
  if (ROSTER_NAME_RE.test(host.file)) {
204
199
  files.set(host.file, host);
@@ -209,12 +204,13 @@ async function readRoster(kind, root, manifestHosts, warn) {
209
204
  }
210
205
  }
211
206
  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');
207
+ const listed = [...listings.values()];
208
+ const present = hosts.length > 0 || listed.some(listing => typeof listing !== 'string');
213
209
  if (hosts.length > MAX_CURATION_ROSTER) {
214
210
  warn(`prompt variants: ${hosts.length} ${kind} variants exceed the bound of ${MAX_CURATION_ROSTER}; converging the first ${MAX_CURATION_ROSTER}`);
215
211
  return { hosts: hosts.slice(0, MAX_CURATION_ROSTER), present: true, incomplete: 'truncated', refused };
216
212
  }
217
- const unreadable = Object.values(listings).some(listing => listing === 'unreadable');
213
+ const unreadable = listed.some(listing => listing === 'unreadable');
218
214
  return { hosts, present, incomplete: unreadable ? 'unreadable' : null, refused };
219
215
  }
220
216
  async function readIfPresent(file) {
@@ -450,48 +446,54 @@ async function removeSkillFolder(target, backups) {
450
446
  }
451
447
  }
452
448
  }
453
- /** Converge the curation-gated skill directories. A skill folder that was a link and was swapped is recorded in `links`. Never throws. */
449
+ /**
450
+ * Converge the gated skill directories of every axis (AXIS_GATED_SKILLS): the state each axis'
451
+ * skills were left in. An axis that gates no skill is `unchanged`. A skill folder that was a link
452
+ * and was swapped is recorded in `links`. Never throws.
453
+ */
454
454
  async function convergeSkills(opts, links) {
455
- const { claudeDir, devflowDir, curation, plugins, warn } = opts;
455
+ const { claudeDir, devflowDir, switches, plugins, warn } = opts;
456
456
  const selected = skillsOf(plugins);
457
- let state = 'unchanged';
457
+ const states = perAxis(() => 'unchanged');
458
458
  // A skills folder that is a link or not a folder is refused once. The removal and the swap below
459
459
  // would reach the folder the link points to: `directoryState` of a skill folder follows the link
460
460
  // above it. The refusal is recorded in `links`, and the converge is not clean.
461
461
  if (await refuseUnrealFolder(links, path.join(claudeDir, 'skills')))
462
- return 'failed';
463
- // Every gated skill is converged: a failure on one must not skip the next.
464
- for (const skill of CURATION_GATED_SKILLS) {
465
- const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
466
- try {
467
- if (!curation) {
468
- const removal = await removeSkillFolder(target, opts.backups);
469
- if (removal === 'removed') {
470
- state = 'removed';
462
+ return perAxis(() => 'failed');
463
+ // Every gated skill of every axis is converged: a failure on one must not skip the next.
464
+ for (const axis of VARIANT_AXES) {
465
+ for (const skill of AXIS_GATED_SKILLS[axis]) {
466
+ const target = path.join(claudeDir, 'skills', prefixSkillName(skill));
467
+ try {
468
+ if (!switches[axis]) {
469
+ const removal = await removeSkillFolder(target, opts.backups);
470
+ if (removal === 'removed') {
471
+ states[axis] = 'removed';
472
+ }
473
+ else if (removal === 'kept') {
474
+ warn(`${axis} variants: the ${prefixSkillName(skill)} skill folder (${target}) still holds a file that could not be moved aside, so it was not removed`);
475
+ states[axis] = 'failed';
476
+ }
477
+ continue;
471
478
  }
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';
479
+ if (!selected.has(skill)) {
480
+ if (states[axis] === 'unchanged')
481
+ states[axis] = 'not-selected';
482
+ continue;
475
483
  }
476
- continue;
484
+ const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
485
+ if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
486
+ continue;
487
+ await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
488
+ states[axis] = 'installed';
477
489
  }
478
- if (!selected.has(skill)) {
479
- if (state === 'unchanged')
480
- state = 'not-selected';
481
- continue;
490
+ catch (err) {
491
+ warn(`${axis} variants: could not converge the ${prefixSkillName(skill)} skill (${target}) — ${String(err)}`);
492
+ states[axis] = 'failed';
482
493
  }
483
- const resolved = await resolveSkillSource(skill, devflowDir, opts.packageRoot);
484
- if ((await pathExists(target)) && (await treesIdentical(resolved.dir, target)))
485
- continue;
486
- await swapInSkillDirectory(claudeDir, resolved.dir, target, links, opts.backups);
487
- state = 'installed';
488
- }
489
- catch (err) {
490
- warn(`curation variants: could not converge the ${prefixSkillName(skill)} skill (${target}) — ${String(err)}`);
491
- state = 'failed';
492
494
  }
493
495
  }
494
- return state;
496
+ return states;
495
497
  }
496
498
  /** The result of a converge that did not run: nothing changed and, with compliance off, nothing is proven. */
497
499
  function notRun(switches, reason) {
@@ -507,7 +509,7 @@ function notRun(switches, reason) {
507
509
  };
508
510
  }
509
511
  /**
510
- * Make the installed commands and agents match the machine's two switches: the per-kind
512
+ * Make the installed commands and agents match the machine's switches: the per-kind
511
513
  * converge of the prompts.
512
514
  *
513
515
  * Each roster host is rewritten to the file built for the machine's exact switches (see
@@ -692,17 +694,20 @@ export function workflowFoldersPosting(folders, compliance) {
692
694
  return POSTING_CLEAR;
693
695
  }
694
696
  /**
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
+ * The one converge for every switch: it makes the installed tree match the machine's curation,
698
+ * compliance and tdd switches. `devflow init`, `devflow curation`, `devflow compliance` and
699
+ * `devflow tdd` all call it.
697
700
  *
698
701
  * 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;
702
+ * - {@link convergePromptVariants}: the commands and agents, for every axis;
703
+ * - the gated skills: apply-decisions follows the curation switch and test-driven-development
704
+ * the tdd switch;
701
705
  * - {@link convergeFromManifest}: the compliance skill and rule, the one place the manifest
702
706
  * becomes compliance options;
703
707
  * - {@link convergeReferences}: the `devflow:git` references, which follow the compliance switch
704
708
  * (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).
709
+ * - the workflow skill folders, which follow the curation and compliance switches (the folder of
710
+ * the exact key; a folder has no tdd arm).
706
711
  * A failed kind is a warning and `converged: false`, and the next kind still runs. A compliance-off
707
712
  * machine that still has a prompt or a reference file that posts, or cannot name one, is `posting`
708
713
  * and never `clear`: the caller decides what that costs (the compliance command exits non-zero,
@@ -711,8 +716,7 @@ export function workflowFoldersPosting(folders, compliance) {
711
716
  * Never throws.
712
717
  */
713
718
  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 };
719
+ const { claudeDir, devflowDir, switches, frameworks, rulesEnabled, plugins, warn, packageRoot, backups } = opts;
716
720
  // Precondition, asserted in production code: a relative claudeDir would resolve
717
721
  // every install target somewhere unexpected, the skill removal included.
718
722
  if (!path.isAbsolute(claudeDir)) {
@@ -720,6 +724,7 @@ export async function convergeVariantAxes(opts) {
720
724
  return {
721
725
  ...notRun(switches, 'the Claude directory is not an absolute path'),
722
726
  skill: 'unchanged',
727
+ tddSkill: 'unchanged',
723
728
  compliance: { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] },
724
729
  references: {
725
730
  converged: false,
@@ -735,9 +740,16 @@ export async function convergeVariantAxes(opts) {
735
740
  }
736
741
  const prompts = await attempt('prompt variants', warn, notRun(switches, 'the prompt converge failed'), () => convergePromptVariants({ claudeDir, switches, plugins, warn, packageRoot }));
737
742
  const skillLinks = newLinkReport();
738
- const skill = await attempt('curation variants', warn, 'failed', () => convergeSkills({ claudeDir, devflowDir, curation: switches.curation, plugins, warn, packageRoot, backups }, skillLinks));
743
+ const skills = await attempt('gated skills', warn, perAxis(() => 'failed'), () => convergeSkills({ claudeDir, devflowDir, switches, plugins, warn, packageRoot, backups }, skillLinks));
744
+ const { curation: skill, tdd: tddSkill } = skills;
739
745
  warnLinkReport(skillLinks, warn);
740
- const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({ claudeDir, devflowDir, manifest, warn, backups }));
746
+ const compliance = await attempt('compliance artifacts', warn, { removedPreexisting: false, converged: false, replacedLinks: [], refusedFolders: [] }, () => convergeFromManifest({
747
+ claudeDir,
748
+ devflowDir,
749
+ manifest: { features: { compliance: { enabled: switches.compliance, frameworks }, rules: rulesEnabled } },
750
+ warn,
751
+ backups,
752
+ }));
741
753
  const references = await attempt('reference converge', warn, {
742
754
  converged: false,
743
755
  rewritten: [],
@@ -757,12 +769,17 @@ export async function convergeVariantAxes(opts) {
757
769
  backups,
758
770
  }));
759
771
  return {
760
- converged: prompts.converged && skill !== 'failed' && compliance.converged && references.converged && workflowFolders.converged,
772
+ converged: prompts.converged &&
773
+ VARIANT_AXES.every(axis => skills[axis] !== 'failed') &&
774
+ compliance.converged &&
775
+ references.converged &&
776
+ workflowFolders.converged,
761
777
  commandsRewritten: prompts.commandsRewritten,
762
778
  agentsRewritten: prompts.agentsRewritten,
763
779
  unchanged: prompts.unchanged,
764
780
  notInstalled: prompts.notInstalled,
765
781
  skill,
782
+ tddSkill,
766
783
  compliance,
767
784
  references,
768
785
  workflowFolders,
@@ -819,7 +836,8 @@ export function describeCurationConverge(result, curation) {
819
836
  }
820
837
  /**
821
838
  * 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.
839
+ * rewritten for the variant the axis now holds and the gated skills it installed or removed
840
+ * (apply-decisions with curation, test-driven-development with tdd).
823
841
  */
824
842
  export function describeVariantConverge(result, axis, enabled) {
825
843
  const parts = [];
@@ -828,10 +846,16 @@ export function describeVariantConverge(result, axis, enabled) {
828
846
  parts.push(`${result.commandsRewritten.length} command(s) and ${result.agentsRewritten.length} agent(s) ` +
829
847
  `rewritten for the ${axis}-${enabled ? 'on' : 'off'} variant`);
830
848
  }
831
- if (result.skill === 'installed')
832
- parts.push('the apply-decisions skill installed');
833
- if (result.skill === 'removed')
834
- parts.push('the apply-decisions skill removed');
849
+ const skillStates = [
850
+ [AXIS_GATED_SKILLS.curation[0], result.skill],
851
+ [AXIS_GATED_SKILLS.tdd[0], result.tddSkill],
852
+ ];
853
+ for (const [skill, state] of skillStates) {
854
+ if (state === 'installed')
855
+ parts.push(`the ${skill} skill installed`);
856
+ if (state === 'removed')
857
+ parts.push(`the ${skill} skill removed`);
858
+ }
835
859
  const references = result.references;
836
860
  if (references !== undefined) {
837
861
  const changed = references.rewritten.length + references.restored.length;
@@ -943,7 +967,7 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
943
967
  const manifest = await readManifest(devflowDir);
944
968
  if (manifest === null)
945
969
  return 'no-manifest';
946
- if (manifest.features.curation !== switches.curation || manifest.features.compliance.enabled !== switches.compliance) {
970
+ if (!sameSwitches(machineSwitches(manifest.features), switches)) {
947
971
  // The record names switches that no longer hold: remove it, so the status reads unknown.
948
972
  if (!Object.hasOwn(manifest, 'installedVariants'))
949
973
  return 'switches-changed';
@@ -954,11 +978,11 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
954
978
  return 'switches-changed';
955
979
  }
956
980
  const recorded = readInstalledVariants(manifest.installedVariants);
957
- if (recorded !== null && recorded.curation === switches.curation && recorded.compliance === switches.compliance)
981
+ if (recorded !== null && sameSwitches(recorded, switches))
958
982
  return 'current';
959
983
  if (!(await lock.stillHeld()))
960
984
  return 'lock-lost';
961
- await writeManifest(devflowDir, { ...manifest, installedVariants: { curation: switches.curation, compliance: switches.compliance } });
985
+ await writeManifest(devflowDir, { ...manifest, installedVariants: perAxis(axis => switches[axis]) });
962
986
  return 'written';
963
987
  });
964
988
  if (!locked.ok || locked.value === 'lock-lost') {
@@ -976,11 +1000,11 @@ async function recordInstalledVariants(devflowDir, switches, warn) {
976
1000
  }
977
1001
  }
978
1002
  /**
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
1003
+ * What `devflow curation`, `devflow compliance` and `devflow tdd` do after the switch is
1004
+ * written: converge the installed tree onto every switch with the one converge, record the
981
1005
  * installed variants when it finished clean, then reapply the saved agent model and effort mapping.
982
1006
  *
983
- * The switch of the toggled axis is the value the caller just set. The other axis and the
1007
+ * The switch of the toggled axis is the value the caller just set. The other axes and the
984
1008
  * compliance frameworks come from the manifest, which the caller has already written.
985
1009
  *
986
1010
  * A version skew keeps the check this toggle has always had: the installed prompts are not the
@@ -1005,24 +1029,21 @@ export async function applyVariantToggle(opts) {
1005
1029
  if (skew !== null)
1006
1030
  return { kind: 'skipped', reason: 'version-skew', message: skew };
1007
1031
  const selection = new Set(manifest.plugins);
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
- };
1032
+ // The toggled axis takes the value the caller just set; every other axis is the manifest's.
1033
+ const switches = { ...machineSwitches(manifest.features), [axis]: enabled };
1015
1034
  const result = await convergeVariantAxes({
1016
1035
  claudeDir,
1017
1036
  devflowDir,
1018
- manifest: state,
1037
+ switches,
1038
+ frameworks: manifest.features.compliance.frameworks,
1039
+ rulesEnabled: manifest.features.rules,
1019
1040
  plugins: DEVFLOW_PLUGINS.filter(plugin => selection.has(plugin.name)),
1020
1041
  warn,
1021
1042
  packageRoot: opts.packageRoot,
1022
1043
  backups: opts.backups,
1023
1044
  });
1024
1045
  if (result.converged) {
1025
- await recordInstalledVariants(devflowDir, { curation: state.features.curation, compliance: state.features.compliance.enabled }, warn);
1046
+ await recordInstalledVariants(devflowDir, switches, warn);
1026
1047
  }
1027
1048
  let agentMappingReapplied = false;
1028
1049
  if (result.agentsRewritten.length > 0) {
@@ -1,10 +1,10 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import { existsSync } from 'fs';
3
3
  import * as path from 'path';
4
- import { SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitCurationGatedSkills, selectedWorkflowFolders, workflowFolderNames } from '../../core/plugins.js';
4
+ import { SKILL_NAMESPACE, WORKFLOW_FOLDER_NAMES, buildAssetMaps, prefixSkillName, unprefixSkillName, getAllSkillNames, getAllAgentNames, getAllCommandNames, FEATURE_OWNED_SKILLS, resolveSkillInstallPlan, omitGatedSkills, selectedWorkflowFolders, workflowFolderNames } from '../../core/plugins.js';
5
5
  import { skillsDir, agentSourceDirs, rulesDir, commandSourceDirs, scriptsDir, compiledSkillRefsDir, skillRefsSourceDir } from '../../core/assets.js';
6
6
  import { getPackageRoot, isContainedIn } from '../../core/paths.js';
7
- import { ALL_SWITCHES_ON, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, parseVariantManifest, requiredComplianceVariantFiles, } from '../../core/curation-variants.js';
7
+ import { ALL_SWITCHES_ON, VARIANT_AXES, VARIANT_MANIFEST_REL_PATH, describeVariantManifestParseError, parseVariantManifest, requiredComplianceVariantFiles, } from '../../core/curation-variants.js';
8
8
  import { sweepOrphanedAssets, mdFileName, mdEntryName } from '../../core/orphan-sweep.js';
9
9
  import { generatedReferenceManifest, installedReferenceManifest, PR_HOST_DESTINATION_ROOT, SKILL_REFS_SKILL_NAME, TRACKER_DESTINATION_ROOT, WORKFLOW_FOLDERS } from '../../core/mds-variants.js';
10
10
  import { sweepOrphanedReferences, MAX_REFERENCE_SWEEP_DEPTH } from '../../core/reference-sweep.js';
@@ -1404,7 +1404,7 @@ async function writeStampedAgent(srcFile, target, servers, warn) {
1404
1404
  }
1405
1405
  /**
1406
1406
  * D-EXPLORE-NO-CLOBBER, install half: true when `agentName` takes over a built-in
1407
- * agent type (Explore) and the user already owns an agent of that type under
1407
+ * agent type (Explore, Plan) and the user already owns an agent of that type under
1408
1408
  * `{claudeDir}/agents`, so devflow's copy must not be installed.
1409
1409
  *
1410
1410
  * The user's file is only ever read. A copy of devflow's own left by an earlier install
@@ -1708,10 +1708,10 @@ function namedVariantFiles(items) {
1708
1708
  * An injected agent list wins as given.
1709
1709
  */
1710
1710
  function copySourceDirs(opts) {
1711
- const { curation, compliance, packageRoot } = opts;
1711
+ const { switches, packageRoot } = opts;
1712
1712
  return {
1713
- commands: commandSourceDirs({ curation, compliance }, packageRoot),
1714
- agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, { curation, compliance }),
1713
+ commands: commandSourceDirs(switches, packageRoot),
1714
+ agents: opts.agentSourceDirs ?? agentSourceDirs(packageRoot, switches),
1715
1715
  };
1716
1716
  }
1717
1717
  /**
@@ -1737,8 +1737,8 @@ function copySourceDirs(opts) {
1737
1737
  * naming the build step. Only the file reads, so it leaves no partial install behind.
1738
1738
  */
1739
1739
  async function requireComplianceVariantFiles(opts) {
1740
- const { curation, compliance, packageRoot } = opts;
1741
- if (compliance)
1740
+ const { switches, packageRoot } = opts;
1741
+ if (switches.compliance)
1742
1742
  return;
1743
1743
  const manifestFile = path.join(packageRoot, ...VARIANT_MANIFEST_REL_PATH.split('/'));
1744
1744
  const remedy = `Run \`npm run build:mds\` to regenerate ${VARIANT_MANIFEST_REL_PATH} and the variant trees before install ` +
@@ -1757,12 +1757,12 @@ async function requireComplianceVariantFiles(opts) {
1757
1757
  throw new Error(`Variant manifest is not valid: ${manifestFile} (${describeVariantManifestParseError(parsed.error)}). ` +
1758
1758
  `No file was written. ${remedy}`);
1759
1759
  }
1760
- const sourceDirs = copySourceDirs({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1761
- const machine = `curation ${curation ? 'on' : 'off'} and compliance off`;
1760
+ const sourceDirs = copySourceDirs({ switches, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1761
+ const machine = VARIANT_AXES.map(axis => `${axis} ${switches[axis] ? 'on' : 'off'}`).join(', ');
1762
1762
  const missing = [];
1763
1763
  const shadowed = [];
1764
1764
  for (const host of parsed.value) {
1765
- const [rel] = requiredComplianceVariantFiles({ curation, compliance }, [host]);
1765
+ const [rel] = requiredComplianceVariantFiles(switches, [host]);
1766
1766
  if (rel === undefined)
1767
1767
  continue;
1768
1768
  const required = path.join(packageRoot, ...rel.split('/'));
@@ -1821,15 +1821,14 @@ async function requireComplianceVariantFiles(opts) {
1821
1821
  * known and leaves it alone.
1822
1822
  */
1823
1823
  export async function installCommandsAndAgents(opts) {
1824
- const { plugins, agentsMap, claudeDir, curation, compliance, trackerServers, warn } = opts;
1824
+ const { plugins, agentsMap, claudeDir, switches, trackerServers, warn } = opts;
1825
1825
  const skip = opts.skip ?? {};
1826
1826
  const packageRoot = opts.packageRoot ?? getPackageRoot();
1827
1827
  // Before the first write. installViaFileCopy checks earlier still, ahead of its pre-clean;
1828
1828
  // this call covers the caller that reaches the copy alone (the selective uninstall).
1829
- await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1829
+ await requireComplianceVariantFiles({ switches, packageRoot, agentSourceDirs: opts.agentSourceDirs });
1830
1830
  const { commands: commandDirs, agents: agentDirs } = copySourceDirs({
1831
- curation,
1832
- compliance,
1831
+ switches,
1833
1832
  packageRoot,
1834
1833
  agentSourceDirs: opts.agentSourceDirs,
1835
1834
  });
@@ -1902,20 +1901,19 @@ export async function installCommandsAndAgents(opts) {
1902
1901
  * Returns an InstallReport describing which shadows were applied and which were skipped.
1903
1902
  */
1904
1903
  export async function installViaFileCopy(options) {
1905
- const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, curation, compliance, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
1904
+ const { plugins, claudeDir, devflowDir, skillsMap, agentsMap, rulesMap = new Map(), isPartialInstall, spinner, switches, trackerServers = TRACKER_MCP_SERVERS_DEFAULT, warn = () => { }, backups, } = options;
1906
1905
  const packageRoot = options.packageRoot ?? getPackageRoot();
1907
1906
  // D-WORKFLOW-FOLDER-INSTALL: the workflows that ship as skill folders. The default registry is
1908
1907
  // empty, and with it every folder step below is a no-op over an empty set.
1909
1908
  const folderRegistry = options.workflowFolders ?? WORKFLOW_FOLDERS;
1910
1909
  const folderWorkflows = workflowFolderNames(folderRegistry);
1911
- const folderSwitches = { curation, compliance };
1912
1910
  const folderFiles = new Map();
1913
1911
  /** The files of one registered folder, read once. A failed read is a reason and never a throw. */
1914
1912
  const readFolder = async (folder) => {
1915
1913
  const cached = folderFiles.get(folder.name);
1916
1914
  if (cached !== undefined)
1917
1915
  return { ok: true, files: cached };
1918
- const read = await readWorkflowFolder(folder, folderSwitches, options.workflowFoldersRoot ?? packageRoot);
1916
+ const read = await readWorkflowFolder(folder, switches, options.workflowFoldersRoot ?? packageRoot);
1919
1917
  if (read.ok)
1920
1918
  folderFiles.set(folder.name, read.files);
1921
1919
  return read;
@@ -1931,7 +1929,7 @@ export async function installViaFileCopy(options) {
1931
1929
  };
1932
1930
  // The compliance-off refusal comes first, ahead of the pre-clean below: a refused install
1933
1931
  // must leave the installed tree as it found it.
1934
- await requireComplianceVariantFiles({ curation, compliance, packageRoot, agentSourceDirs: options.agentSourceDirs });
1932
+ await requireComplianceVariantFiles({ switches, packageRoot, agentSourceDirs: options.agentSourceDirs });
1935
1933
  // The folders this run installs are read from the exact-key tree before the pre-clean for the
1936
1934
  // same reason, so a build that lacks one refuses the install and leaves the installed tree alone.
1937
1935
  await readFolders(selectedWorkflowFolders(plugins, folderWorkflows));
@@ -1939,9 +1937,9 @@ export async function installViaFileCopy(options) {
1939
1937
  // root they are overlaid from. With compliance off the root is the compliance-off tree, and its
1940
1938
  // absence refuses the install here, before the pre-clean, so a refused install leaves the
1941
1939
  // installed tree as it found it and never falls through to the compliance-on references (AC-50).
1942
- const refsManifest = installedReferenceManifest({ compliance });
1943
- const refsSourceRoot = skillRefsSourceDir(compliance, packageRoot);
1944
- if (omitCurationGatedSkills(skillsMap, curation).has(SKILL_REFS_SKILL_NAME)) {
1940
+ const refsManifest = installedReferenceManifest({ compliance: switches.compliance });
1941
+ const refsSourceRoot = skillRefsSourceDir(switches.compliance, packageRoot);
1942
+ if (omitGatedSkills(skillsMap, switches).has(SKILL_REFS_SKILL_NAME)) {
1945
1943
  await requireGeneratedTree(refsSourceRoot, refsManifest);
1946
1944
  }
1947
1945
  const report = {
@@ -2203,8 +2201,7 @@ export async function installViaFileCopy(options) {
2203
2201
  plugins: commandAgentPlugins,
2204
2202
  agentsMap: commandAgentMap,
2205
2203
  claudeDir,
2206
- curation,
2207
- compliance,
2204
+ switches,
2208
2205
  packageRoot,
2209
2206
  trackerServers,
2210
2207
  agentSourceDirs: options.agentSourceDirs,
@@ -2240,15 +2237,15 @@ export async function installViaFileCopy(options) {
2240
2237
  // A declared skill whose source directory is absent is a build/packaging failure
2241
2238
  // and throws rather than silently skipping (matches command pattern).
2242
2239
  //
2243
- // D-LEARNING-VARIANT-INSTALL: a curation-off machine skips the curation-gated
2244
- // skills here. The pre-clean above walked the UNFILTERED map, so on a full
2240
+ // D-LEARNING-VARIANT-INSTALL: a machine with an axis off skips the skills that axis
2241
+ // gates here (omitGatedSkills). The pre-clean above walked the UNFILTERED map, so on a full
2245
2242
  // install a leftover copy is already gone; a partial install leaves removal to
2246
2243
  // convergeVariantAxes, which init runs straight after this call.
2247
2244
  //
2248
2245
  // A refused skills root (a link or not a folder) gets no skill: every copy, and the overlay
2249
2246
  // below it, would write into the folder the link points to.
2250
2247
  spinner.message('Installing skills...');
2251
- const skillsToInstall = skillsRefused ? [] : [...omitCurationGatedSkills(skillsMap, curation).keys()];
2248
+ const skillsToInstall = skillsRefused ? [] : [...omitGatedSkills(skillsMap, switches).keys()];
2252
2249
  for (const skillName of skillsToInstall) {
2253
2250
  const skillTarget = path.join(claudeDir, 'skills', prefixSkillName(skillName));
2254
2251
  // A full install's pre-clean replaced a link at the skill or its references entry. A partial
@@ -2272,7 +2269,7 @@ export async function installViaFileCopy(options) {
2272
2269
  // on the machine, even when the install stops or a removal is refused later. With compliance on
2273
2270
  // nothing is excluded.
2274
2271
  const resolved = await resolveSkillSource(skillName, devflowDir);
2275
- await copyDirectory(resolved.dir, skillTarget, compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
2272
+ await copyDirectory(resolved.dir, skillTarget, switches.compliance ? undefined : complianceOffCopyExclusions(skillName, resolved.dir), report);
2276
2273
  if (resolved.shadow === 'valid') {
2277
2274
  report.shadowedSkills.push(skillName);
2278
2275
  }
@@ -2309,7 +2306,7 @@ export async function installViaFileCopy(options) {
2309
2306
  // install copies over without clearing. A removal that was refused is a reported failure and
2310
2307
  // never an abort; the compliance converge that init runs next fails closed on the same files.
2311
2308
  // A refused skills root is not entered: the removal would reach files in the folder a link points to.
2312
- if (!compliance && !skillsRefused) {
2309
+ if (!switches.compliance && !skillsRefused) {
2313
2310
  const removal = await removeComplianceOffFiles(path.join(claudeDir, 'skills'));
2314
2311
  for (const refusal of removal.refused) {
2315
2312
  report.sweepFailures.push({ kind: 'reference', name: refusal.entry, error: refusal.reason });
@@ -24,8 +24,7 @@ import * as path from 'path';
24
24
  import { skillsDir } from '../../core/assets.js';
25
25
  import { complianceSkillFiles } from '../../core/compliance.js';
26
26
  import { COMPLIANCE_OFF_REMOVED_SKILL_FILES } from '../../core/compliance-off-files.js';
27
- import { VARIANT_KEYS } from '../../core/curation-variants.js';
28
- import { SKILL_REFS_SKILL_NAME, installedReferenceManifest, workflowFolderFiles, } from '../../core/mds-variants.js';
27
+ import { FOLDER_VARIANT_KEYS, SKILL_REFS_SKILL_NAME, installedReferenceManifest, workflowFolderFiles, } from '../../core/mds-variants.js';
29
28
  import { createPathSet } from '../../core/path-set.js';
30
29
  import { FEATURE_OWNED_RULES, getAllAgentNames, getAllCommandNames, getAllRuleNames, getAllSkillNames, prefixSkillName, } from '../../core/plugins.js';
31
30
  import { validateSkillShadow } from './installer.js';
@@ -69,7 +68,7 @@ export function shippedPaths(inputs) {
69
68
  for (const file of complianceSkillFiles())
70
69
  paths.add(skillPath('compliance', file));
71
70
  for (const folder of inputs.workflowFolders) {
72
- for (const key of VARIANT_KEYS) {
71
+ for (const key of FOLDER_VARIANT_KEYS) {
73
72
  for (const file of workflowFolderFiles(folder, key))
74
73
  paths.add(skillPath(folder.name, file));
75
74
  }