devflow-kit 3.12.1 → 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 (75) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/agents/architect.md +10 -9
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/design.md +7 -7
  5. package/dist/agents/harden.md +6 -8
  6. package/dist/agents/review.md +5 -6
  7. package/dist/cli/commands/init-seed.js +5 -1
  8. package/dist/cli/commands/init.js +27 -19
  9. package/dist/cli/commands/tdd.js +230 -0
  10. package/dist/cli/commands/uninstall.js +2 -2
  11. package/dist/cli.js +2 -0
  12. package/dist/commands/implement.md +1 -1
  13. package/dist/commands/plan.md +23 -61
  14. package/dist/compliance-off/commands/implement.md +1 -1
  15. package/dist/compliance-off/commands/plan.md +23 -61
  16. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +1 -0
  17. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +1 -0
  18. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +1 -0
  19. package/dist/compliance-tdd-off/agents/triage.md +173 -0
  20. package/dist/core/assets.js +40 -33
  21. package/dist/core/curation-variants.js +191 -76
  22. package/dist/core/feature-switch.js +3 -2
  23. package/dist/core/installed-variants.js +45 -14
  24. package/dist/core/manifest.js +3 -0
  25. package/dist/core/mds-variants.js +13 -6
  26. package/dist/core/plugins.js +38 -9
  27. package/dist/curation-compliance-off/commands/implement.md +1 -1
  28. package/dist/curation-compliance-off/commands/plan.md +23 -61
  29. package/dist/curation-compliance-tdd-off/agents/triage.md +170 -0
  30. package/dist/curation-off/agents/architect.md +10 -9
  31. package/dist/curation-off/agents/code.md +6 -6
  32. package/dist/curation-off/agents/design.md +7 -7
  33. package/dist/curation-off/agents/harden.md +6 -8
  34. package/dist/curation-off/agents/review.md +5 -6
  35. package/dist/curation-off/commands/implement.md +1 -1
  36. package/dist/curation-off/commands/plan.md +23 -61
  37. package/dist/curation-tdd-off/agents/code.md +173 -0
  38. package/dist/curation-tdd-off/agents/triage.md +171 -0
  39. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +1 -0
  40. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +1 -0
  41. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +1 -0
  42. package/dist/targets/claude-code/agent-type-owner.js +20 -15
  43. package/dist/targets/claude-code/curation-install.js +120 -99
  44. package/dist/targets/claude-code/installer.js +26 -29
  45. package/dist/targets/claude-code/shipped-set.js +2 -3
  46. package/dist/tdd-off/agents/code.md +176 -0
  47. package/dist/tdd-off/agents/triage.md +174 -0
  48. package/dist/variant-axes.json +54 -27
  49. package/package.json +1 -1
  50. package/src/assets/agents/_partials/_design.mds +7 -7
  51. package/src/assets/agents/architect.mds +3 -2
  52. package/src/assets/agents/code.mds +19 -7
  53. package/src/assets/agents/harden.mds +6 -8
  54. package/src/assets/agents/plan.md +63 -0
  55. package/src/assets/agents/review.mds +5 -6
  56. package/src/assets/agents/triage.mds +4 -0
  57. package/src/assets/commands/implement.mds +1 -1
  58. package/src/assets/commands/plan.mds +22 -68
  59. package/src/assets/mds/git/_ops_steps.mds +1 -0
  60. package/src/assets/skills/agent-references/references/commands/plan-levels.md +3 -3
  61. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -9
  62. package/src/assets/skills/design-review/SKILL.md +9 -12
  63. package/src/assets/skills/design-review/references/anti-patterns.md +4 -1
  64. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  65. package/src/assets/skills/git/references/patterns.md +3 -0
  66. package/src/assets/skills/patterns/SKILL.md +2 -4
  67. package/src/assets/skills/quality-gates/SKILL.md +2 -2
  68. package/src/assets/skills/quality-gates/references/patterns.md +3 -3
  69. package/src/assets/skills/quality-gates/references/report-template.md +2 -2
  70. package/src/assets/skills/quality-gates/references/violations.md +5 -5
  71. package/src/assets/skills/test-driven-development/SKILL.md +5 -5
  72. package/src/assets/skills/test-driven-development/references/rationalization-prevention.md +1 -1
  73. package/src/assets/skills/testing/SKILL.md +9 -9
  74. package/src/assets/skills/testing/references/report-template.md +1 -1
  75. package/src/assets/skills/testing/references/violations.md +1 -1
@@ -1,7 +1,7 @@
1
1
  import { join } from 'path';
2
2
  import { getPackageRoot } from './paths.js';
3
3
  import { COMPLIANCE_OFF_SKILL_REFS_OUTPUT_DIR, SKILL_REFS_OUTPUT_DIR, workflowFolderKey, workflowFoldersRootRelPath, } from './mds-variants.js';
4
- import { ALL_SWITCHES_ON, BOTH_OFF_OUTPUT_DIR, COMPLIANCE_OFF_OUTPUT_DIR, CURATION_OFF_OUTPUT_DIR, } from './curation-variants.js';
4
+ import { ALL_SWITCHES_ON, OFF_OUTPUT_DIRS, consultedOffKeys, } from './curation-variants.js';
5
5
  /**
6
6
  * Flat skills source directory: src/assets/skills/{name}/
7
7
  * All plugins' skills live here directly (no per-plugin subdirectory).
@@ -47,6 +47,20 @@ export function scriptsDir() {
47
47
  export function commandsDir() {
48
48
  return join(getPackageRoot(), 'dist', 'commands');
49
49
  }
50
+ /**
51
+ * The variant directory of one off key: <the key's tree>/{commands,agents}/
52
+ *
53
+ * The spelling comes from OFF_OUTPUT_DIRS in curation-variants.ts, the build's own
54
+ * destinations, rather than being retyped here. Absent until the build has run, so every
55
+ * reader tolerates its absence.
56
+ *
57
+ * @param key - The off variant key.
58
+ * @param kind - Which prompt kind's variants.
59
+ * @param root - Package root to resolve against (see agentsDir).
60
+ */
61
+ export function variantOffDir(key, kind, root = getPackageRoot()) {
62
+ return join(root, ...OFF_OUTPUT_DIRS[key].split('/'), kind);
63
+ }
50
64
  /**
51
65
  * Curation-off variant directory: dist/curation-off/{commands,agents}/
52
66
  *
@@ -54,14 +68,11 @@ export function commandsDir() {
54
68
  * (D-LEARNING-VARIANTS), and only those. Absent until the build has run, so every
55
69
  * reader tolerates its absence.
56
70
  *
57
- * The spelling comes from CURATION_OFF_OUTPUT_DIR in curation-variants.ts, the
58
- * build's own destination, rather than being retyped here.
59
- *
60
71
  * @param kind - Which prompt kind's variants.
61
72
  * @param root - Package root to resolve against (see agentsDir).
62
73
  */
63
74
  export function curationOffDir(kind, root = getPackageRoot()) {
64
- return join(root, ...CURATION_OFF_OUTPUT_DIR.split('/'), kind);
75
+ return variantOffDir('curation-off', kind, root);
65
76
  }
66
77
  /**
67
78
  * Compliance-off variant directory: dist/compliance-off/{commands,agents}/
@@ -75,7 +86,7 @@ export function curationOffDir(kind, root = getPackageRoot()) {
75
86
  * @param root - Package root to resolve against (see agentsDir).
76
87
  */
77
88
  export function complianceOffDir(kind, root = getPackageRoot()) {
78
- return join(root, ...COMPLIANCE_OFF_OUTPUT_DIR.split('/'), kind);
89
+ return variantOffDir('compliance-off', kind, root);
79
90
  }
80
91
  /**
81
92
  * Both-off variant directory: dist/curation-compliance-off/{commands,agents}/
@@ -87,28 +98,23 @@ export function complianceOffDir(kind, root = getPackageRoot()) {
87
98
  * @param root - Package root to resolve against (see agentsDir).
88
99
  */
89
100
  export function bothOffDir(kind, root = getPackageRoot()) {
90
- return join(root, ...BOTH_OFF_OUTPUT_DIR.split('/'), kind);
101
+ return variantOffDir('both-off', kind, root);
91
102
  }
92
103
  /**
93
104
  * The off-variant directories of one kind that a machine with these switches consults,
94
- * MOST-PREFERRED FIRST. Empty when both switches are on.
95
- *
96
- * The one place the two-axis order is spelled; commandSourceDirs and agentSourceDirs
97
- * both take it from here. A both-off machine consults the both-off tree first, then the
98
- * two single-axis trees. This order does not decide which file a compliance-armed host
99
- * must have. The installer requires the file built for the machine's exact switches, so
100
- * on a both-off machine a host with both arms needs its both-off file and never its
101
- * curation-off file.
102
- */
103
- function offVariantDirs(kind, { curation, compliance }, root) {
104
- if (!curation && !compliance) {
105
- return [bothOffDir(kind, root), curationOffDir(kind, root), complianceOffDir(kind, root)];
106
- }
107
- if (!compliance)
108
- return [complianceOffDir(kind, root)];
109
- if (!curation)
110
- return [curationOffDir(kind, root)];
111
- return [];
105
+ * MOST-PREFERRED FIRST. Empty when every switch is on.
106
+ *
107
+ * The one place the order is spelled; commandSourceDirs and agentSourceDirs both take it
108
+ * from here. It is generated from the variant table (consultedOffKeys): every off tree
109
+ * whose axes are all off on the machine, the tree with the most axes off first. The first
110
+ * tree that holds a host's file is therefore the file for the machine's off axes that the
111
+ * host carries an arm on. A machine with one switch off consults one tree. This order does
112
+ * not decide which file a compliance-armed host must have. The installer requires the
113
+ * file built for the machine's exact switches, so on a both-off machine a host with both
114
+ * arms needs its both-off file and never its curation-off file.
115
+ */
116
+ function offVariantDirs(kind, switches, root) {
117
+ return consultedOffKeys(switches).map(key => variantOffDir(key, kind, root));
112
118
  }
113
119
  /**
114
120
  * Prepend the off-variant directories to the normal lookup, keeping the list non-empty
@@ -122,10 +128,10 @@ function preferOff(off, normal) {
122
128
  *
123
129
  * D-LEARNING-VARIANT-INSTALL: with an axis off, the off-variant directories come
124
130
  * first, so a host with an arm installs its off variant and a host with none falls
125
- * through to the one compiled file. With both axes on the order is the normal
131
+ * through to the one compiled file. With every axis on the order is the normal
126
132
  * single-directory lookup.
127
133
  *
128
- * @param switches - The machine's settled curation and compliance switches.
134
+ * @param switches - The machine's settled switches (one per axis).
129
135
  * @param root - Package root to resolve against (see agentsDir).
130
136
  */
131
137
  export function commandSourceDirs(switches, root = getPackageRoot()) {
@@ -203,14 +209,15 @@ export function compiledWorkflowFoldersDir(key, root = getPackageRoot()) {
203
209
  /**
204
210
  * The workflow folders source root for a machine's switches: the tree built for exactly
205
211
  * those switches. One function, so the install, the converge and the status read cannot
206
- * pick different roots for one pair of switches.
212
+ * pick different roots for one set of switches.
207
213
  *
208
214
  * D-WORKFLOW-FOLDER-BUILD: a SINGLE root, never a preference list. A folder is complete
209
- * under every key, so no reader merges two trees, and a both-off machine never falls
210
- * through to a tree that still carries the text of an axis it switched off (the failure
211
- * commandSourceDirs' consumers guard against with an exact-key check).
215
+ * under every key it is built under (FOLDER_VARIANT_KEYS), so no reader merges two trees,
216
+ * and a both-off machine never falls through to a tree that still carries the text of an
217
+ * axis it switched off (the failure commandSourceDirs' consumers guard against with an
218
+ * exact-key check). A folder has no tdd arm, so the tdd switch does not pick a root.
212
219
  *
213
- * @param switches - The machine's settled curation and compliance switches.
220
+ * @param switches - The machine's settled switches (one per axis).
214
221
  * @param root - Package root to resolve against (see agentsDir).
215
222
  */
216
223
  export function workflowFoldersSourceDir(switches, root = getPackageRoot()) {
@@ -241,7 +248,7 @@ export function workflowFoldersSourceDir(switches, root = getPackageRoot()) {
241
248
  * reading the both-on tree.
242
249
  *
243
250
  * @param root - Package root to resolve against (see agentsDir).
244
- * @param switches - The machine's settled curation and compliance switches; every switch on by default.
251
+ * @param switches - The machine's settled switches (one per axis); every switch on by default.
245
252
  */
246
253
  export function agentSourceDirs(root = getPackageRoot(), switches = ALL_SWITCHES_ON) {
247
254
  return preferOff(offVariantDirs('agents', switches, root), [compiledAgentsDir(root), agentsDir(root)]);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Variant splitter: one compiled prompt body in, its variants out, on two marker
3
- * axes: curation and compliance.
2
+ * Variant splitter: one compiled prompt body in, its variants out, on the marker
3
+ * axes: curation, compliance and tdd.
4
4
  *
5
5
  * Pure module, zero I/O. Every refusal is a Result; the exiting shells are
6
6
  * scripts/build-mds.ts, which renders the split errors into the build's single exit,
@@ -12,11 +12,15 @@
12
12
  * the curation-on variant at today's path (dist/commands/x.md, dist/agents/x.md)
13
13
  * and the curation-off variant at dist/curation-off/{commands,agents}/x.md, only
14
14
  * for a host that has an arm. A prompt with no arm has one variant and no
15
- * curation-off file. The compliance axis works the same way: a body is split on
16
- * both axes into four variants, keyed `on`, `curation-off`, `compliance-off` and
17
- * `both-off`, and the build writes a variant only for a host that has an arm on
18
- * each axis that variant turns off. The off variants go to dist/curation-off/,
19
- * dist/compliance-off/ and dist/curation-compliance-off/, and the manifest
15
+ * curation-off file. The other axes work the same way: a body is split on every
16
+ * axis into one variant per combination of axes turned off (VARIANT_KEYS: `on`,
17
+ * `curation-off`, `compliance-off` and `both-off` for curation and compliance, and
18
+ * `tdd-off`, `curation-tdd-off`, `compliance-tdd-off` and `curation-compliance-tdd-off`
19
+ * for the same four with tdd off), and the build writes a variant only for a host
20
+ * that has an arm on each axis that variant turns off. The off variants go to the
21
+ * trees of OFF_OUTPUT_DIRS (dist/curation-off/, dist/compliance-off/,
22
+ * dist/curation-compliance-off/, dist/tdd-off/, dist/curation-tdd-off/,
23
+ * dist/compliance-tdd-off/ and dist/curation-compliance-tdd-off/), and the manifest
20
24
  * dist/variant-axes.json records which axes each host carries an arm on. The install
21
25
  * order of the trees per machine is owned by src/core/assets.ts, and the install refusal
22
26
  * for a compliance-off machine by src/targets/claude-code/installer.ts.
@@ -30,7 +34,8 @@
30
34
  * <!-- curation:end --> closes the arm
31
35
  *
32
36
  * and `<!-- compliance:on -->`, `<!-- compliance:off -->` and
33
- * `<!-- compliance:end -->` do the same for the compliance variants.
37
+ * `<!-- compliance:end -->` do the same for the compliance variants, and
38
+ * `<!-- tdd:on -->`, `<!-- tdd:off -->` and `<!-- tdd:end -->` for the tdd variants.
34
39
  *
35
40
  * A marker line matches VARIANT_MARKER_RE. It may be indented and may sit inside
36
41
  * a fenced block, a list, a define body or an agent's second frontmatter block,
@@ -43,9 +48,10 @@
43
48
  * A line goes into a variant only when every arm open at that line matches the
44
49
  * variant, so a body with no compliance marker splits on the curation axis to the
45
50
  * same bytes it always did, and the compliance state of the variant changes nothing.
51
+ * The same holds for a body with no tdd marker.
46
52
  *
47
- * One axis may nest fully inside an arm of the other. Two arms of one axis never
48
- * nest, and arms of two axes never overlap: at most one arm per axis is open, and
53
+ * One axis may nest fully inside an arm of another. Two arms of one axis never
54
+ * nest, and arms of different axes never overlap: at most one arm per axis is open, and
49
55
  * the arm opened last is the one that closes first.
50
56
  *
51
57
  * The markers are INTERIM. MDS 0.4.4 emits an `@if` inside a code fence or on an
@@ -56,31 +62,33 @@
56
62
  * @mdscript/mds upgrade it rides on proven byte-identical in dist/ and this module
57
63
  * deleted, is dean0x/devflow#439; the compliance axis rides the same swap
58
64
  * (dean0x/devflow#648). The mapping is one to one, per axis: `<!-- curation:on -->`
59
- * becomes `@if curation:` and `<!-- compliance:on -->` becomes `@if compliance:`,
65
+ * becomes `@if curation:`, `<!-- compliance:on -->` becomes `@if compliance:` and
66
+ * `<!-- tdd:on -->` becomes `@if tdd:`,
60
67
  * `off` becomes `@else:` and `end` becomes `@end`. A standalone `off` arm, with no
61
68
  * on arm before it, becomes an `@if <axis>:` with an empty body followed by
62
- * `@else:`. One axis nested inside the other becomes one `@if` inside the other,
69
+ * `@else:`. One axis nested inside another becomes one `@if` inside the other,
63
70
  * and an interleaving, which no `@if` nesting expresses, is a refusal here.
64
71
  *
65
72
  * What the splitter refuses, each as a VariantSplitError with the source line:
66
73
  * - nesting: an `on` marker while an arm of its axis is open, or a second `off`
67
74
  * in one arm;
68
- * - an interleaving: an `off` or `end` marker for an arm while an arm of the other
75
+ * - an interleaving: an `off` or `end` marker for an arm while an arm of another
69
76
  * axis, opened after it, is still open;
70
77
  * - an `end` with no open arm of its axis;
71
78
  * - an arm still open at the end of the body;
72
79
  * - an empty arm (no line with content between its marker and the next);
73
80
  * - a retired marker: a line that names the pre-rename spelling of the marker, in
74
81
  * any spacing or case, which would otherwise ship as prompt text;
75
- * - a stray marker: a line that names `<!-- curation:` or `<!-- compliance:` but
76
- * is not a whole-line marker, which would otherwise ship as prompt text.
82
+ * - a stray marker: a line that names `<!-- curation:`, `<!-- compliance:` or
83
+ * `<!-- tdd:` but is not a whole-line marker, which would otherwise ship as
84
+ * prompt text.
77
85
  *
78
86
  * The retired spelling is never accepted as an alias: the build fails on it, so a
79
87
  * host that still carries it cannot ship a prompt with both variants silently equal.
80
88
  *
81
89
  * Scope guarantee: this module answers one question (what are the variants of this
82
90
  * body, and where do they land?) and a cheap one per axis for the build to ask of a
83
- * body that must carry no arms (containsCurationMarker, containsComplianceMarker). It
91
+ * body that must carry no arms (containsAxisMarker). It
84
92
  * also renders the variant manifest the build writes beside the trees
85
93
  * (renderVariantManifest) and parses it for the installer (parseVariantManifest), so the
86
94
  * build and the installer share one layout. It decides
@@ -115,6 +123,19 @@ export const COMPLIANCE_OFF_OUTPUT_DIR = 'dist/compliance-off';
115
123
  * tree cannot reach the files of another.
116
124
  */
117
125
  export const BOTH_OFF_OUTPUT_DIR = 'dist/curation-compliance-off';
126
+ /**
127
+ * Repo-relative root of the tdd-off variants: tdd off, curation and compliance on.
128
+ *
129
+ * The three trees below are the same three off combinations of curation and compliance
130
+ * with tdd off as well. All are siblings under dist/, none inside another.
131
+ */
132
+ export const TDD_OFF_OUTPUT_DIR = 'dist/tdd-off';
133
+ /** Repo-relative root of the variants with curation and tdd off, compliance on. */
134
+ export const CURATION_TDD_OFF_OUTPUT_DIR = 'dist/curation-tdd-off';
135
+ /** Repo-relative root of the variants with compliance and tdd off, curation on. */
136
+ export const COMPLIANCE_TDD_OFF_OUTPUT_DIR = 'dist/compliance-tdd-off';
137
+ /** Repo-relative root of the variants with every axis off: curation, compliance and tdd. */
138
+ export const ALL_OFF_OUTPUT_DIR = 'dist/curation-compliance-tdd-off';
118
139
  /**
119
140
  * Repo-relative root of the retired off tree, from before the rename.
120
141
  *
@@ -132,6 +153,10 @@ export const OFF_OUTPUT_DIRS = {
132
153
  'curation-off': CURATION_OFF_OUTPUT_DIR,
133
154
  'compliance-off': COMPLIANCE_OFF_OUTPUT_DIR,
134
155
  'both-off': BOTH_OFF_OUTPUT_DIR,
156
+ 'tdd-off': TDD_OFF_OUTPUT_DIR,
157
+ 'curation-tdd-off': CURATION_TDD_OFF_OUTPUT_DIR,
158
+ 'compliance-tdd-off': COMPLIANCE_TDD_OFF_OUTPUT_DIR,
159
+ 'curation-compliance-tdd-off': ALL_OFF_OUTPUT_DIR,
135
160
  };
136
161
  /**
137
162
  * POSIX repo-relative path of an off variant of an output file: the kind's folder
@@ -146,44 +171,85 @@ export function variantOffRelPath(key, kind, fileName) {
146
171
  // ---------------------------------------------------------------------------
147
172
  // Markers
148
173
  // ---------------------------------------------------------------------------
149
- /** The two marker axes. Curation is listed first: it is the older axis and the first one a stray line is tested against. */
150
- export const VARIANT_AXES = ['curation', 'compliance'];
174
+ /**
175
+ * The marker axes. Curation is listed first: it is the older axis and the first one a stray line is tested against.
176
+ *
177
+ * The per-axis records below are built from this list. A new axis also needs its own entries in
178
+ * VARIANT_KEYS and VARIANT_STATES (every combination), the off-tree constants and OFF_OUTPUT_DIRS,
179
+ * PRUNE_REASONS, MachineSwitchFeatures with machineSwitches, and FOLDER_ARMS. The type checker
180
+ * flags a missing per-axis record. A missing VARIANT_KEYS combination it does not flag: the table
181
+ * test over `2 ** VARIANT_AXES.length` rows pins that VARIANT_KEYS covers every combination.
182
+ */
183
+ export const VARIANT_AXES = ['curation', 'compliance', 'tdd'];
184
+ /** A record with one entry per member of `keys`, built by `valueOf`. The one place a total record is cast from a loop. */
185
+ export function recordOf(keys, valueOf) {
186
+ const record = {};
187
+ for (const key of keys)
188
+ record[key] = valueOf(key);
189
+ return record;
190
+ }
191
+ /** A record with one entry per axis, built by `valueOf`. */
192
+ export function perAxis(valueOf) {
193
+ return recordOf(VARIANT_AXES, valueOf);
194
+ }
151
195
  /** Every switch on: the machine whose prompts come from the normal compiled files. */
152
- export const ALL_SWITCHES_ON = Object.freeze({ curation: true, compliance: true });
196
+ export const ALL_SWITCHES_ON = Object.freeze(perAxis(() => true));
197
+ /** True when two switch records agree on every axis. */
198
+ export function sameSwitches(a, b) {
199
+ return VARIANT_AXES.every(axis => a[axis] === b[axis]);
200
+ }
153
201
  /**
154
- * A whole-line marker of either axis. Group 1 captures the axis; group 2 captures
202
+ * The machine's settled switch on each axis, from its features.
203
+ *
204
+ * The ONE place the switches are built from the machine state. Every consumer takes the
205
+ * returned value whole (the installer, the converge, the folders, the install record) and
206
+ * never spells a switch per axis, so a new axis changes this function and the
207
+ * VariantSwitches type and no call site.
208
+ */
209
+ export function machineSwitches(features) {
210
+ return { curation: features.curation, compliance: features.compliance.enabled, tdd: features.tdd };
211
+ }
212
+ /**
213
+ * A whole-line marker of any axis. Group 1 captures the axis; group 2 captures
155
214
  * `on`, `off` or `end`.
156
215
  *
157
216
  * Anchored at both ends with no nested quantifier, so a match is linear in the
158
217
  * line length. Leading and trailing space or tab is allowed; anything else on the
159
- * line is not a marker.
218
+ * line is not a marker. The axis names are fixed words, never input.
160
219
  */
161
- export const VARIANT_MARKER_RE = /^[ \t]*<!-- (curation|compliance):(on|off|end) -->[ \t]*$/;
220
+ export const VARIANT_MARKER_RE = new RegExp(`^[ \\t]*<!-- (${VARIANT_AXES.join('|')}):(on|off|end) -->[ \\t]*$`);
162
221
  /**
163
222
  * The fragment that makes a line a marker ATTEMPT on one axis: loose on spacing and
164
223
  * case so a malformed marker is caught rather than shipped. Every valid marker of
165
224
  * the axis contains it. None is global, so `test` keeps no state between lines.
166
225
  */
167
- const MARKER_FRAGMENT_RES = {
168
- curation: /<!--\s*curation\s*:/i,
169
- compliance: /<!--\s*compliance\s*:/i,
170
- };
226
+ const MARKER_FRAGMENT_RES = perAxis(axis => new RegExp(`<!--\\s*${axis}\\s*:`, 'i'));
171
227
  /**
172
228
  * The same attempt test for the retired spelling of the marker. No spelling of it is
173
229
  * valid, so a line that matches is always refused.
174
230
  */
175
231
  const LEGACY_MARKER_FRAGMENT_RE = /<!--\s*learning\s*:/i;
232
+ /** The axis the retired spelling belonged to. */
233
+ const LEGACY_MARKER_AXIS = 'curation';
234
+ /** True when the text names a marker of the axis anywhere, valid or not; for curation also the retired spelling. */
235
+ export function containsAxisMarker(axis, text) {
236
+ return MARKER_FRAGMENT_RES[axis].test(text) || (axis === LEGACY_MARKER_AXIS && LEGACY_MARKER_FRAGMENT_RE.test(text));
237
+ }
176
238
  /** True when the text names a curation marker anywhere, in the current or the retired spelling, valid or not. */
177
239
  export function containsCurationMarker(text) {
178
- return MARKER_FRAGMENT_RES.curation.test(text) || LEGACY_MARKER_FRAGMENT_RE.test(text);
240
+ return containsAxisMarker('curation', text);
179
241
  }
180
242
  /** True when the text names a compliance marker anywhere, valid or not. */
181
243
  export function containsComplianceMarker(text) {
182
- return MARKER_FRAGMENT_RES.compliance.test(text);
244
+ return containsAxisMarker('compliance', text);
183
245
  }
184
- /** True when the text names a marker of either axis, or the retired spelling, valid or not. */
246
+ /** True when the text names a tdd marker anywhere, valid or not. */
247
+ export function containsTddMarker(text) {
248
+ return containsAxisMarker('tdd', text);
249
+ }
250
+ /** True when the text names a marker of any axis, or the retired spelling, valid or not. */
185
251
  export function containsVariantMarker(text) {
186
- return containsCurationMarker(text) || containsComplianceMarker(text);
252
+ return VARIANT_AXES.some(axis => containsAxisMarker(axis, text));
187
253
  }
188
254
  function markerOf(line) {
189
255
  // A CRLF body carries the carriage return on the line; it is not part of the marker.
@@ -199,34 +265,65 @@ function attemptedAxisOf(line) {
199
265
  }
200
266
  return null;
201
267
  }
202
- /** The four variants of a body, named by the axes they turn off. */
203
- export const VARIANT_KEYS = ['on', 'curation-off', 'compliance-off', 'both-off'];
268
+ /**
269
+ * The variants of a body, one per combination of axes turned off, named by the axes
270
+ * they turn off. The four that keep tdd on come first and keep the names and trees
271
+ * they had before the tdd axis; the four that turn tdd off follow.
272
+ */
273
+ export const VARIANT_KEYS = [
274
+ 'on',
275
+ 'curation-off',
276
+ 'compliance-off',
277
+ 'both-off',
278
+ 'tdd-off',
279
+ 'curation-tdd-off',
280
+ 'compliance-tdd-off',
281
+ 'curation-compliance-tdd-off',
282
+ ];
204
283
  /** Every variant that turns an axis off, in VARIANT_KEYS order. */
205
284
  export const OFF_VARIANT_KEYS = VARIANT_KEYS.filter((key) => key !== 'on');
206
- /** The arm each axis keeps in each variant. */
285
+ /** The arm each axis keeps in each variant. Total over VARIANT_KEYS, with one row per combination of axes. */
207
286
  const VARIANT_STATES = {
208
- on: { curation: 'on', compliance: 'on' },
209
- 'curation-off': { curation: 'off', compliance: 'on' },
210
- 'compliance-off': { curation: 'on', compliance: 'off' },
211
- 'both-off': { curation: 'off', compliance: 'off' },
287
+ on: { curation: 'on', compliance: 'on', tdd: 'on' },
288
+ 'curation-off': { curation: 'off', compliance: 'on', tdd: 'on' },
289
+ 'compliance-off': { curation: 'on', compliance: 'off', tdd: 'on' },
290
+ 'both-off': { curation: 'off', compliance: 'off', tdd: 'on' },
291
+ 'tdd-off': { curation: 'on', compliance: 'on', tdd: 'off' },
292
+ 'curation-tdd-off': { curation: 'off', compliance: 'on', tdd: 'off' },
293
+ 'compliance-tdd-off': { curation: 'on', compliance: 'off', tdd: 'off' },
294
+ 'curation-compliance-tdd-off': { curation: 'off', compliance: 'off', tdd: 'off' },
212
295
  };
296
+ /** The arm an axis keeps in a variant. */
297
+ export function variantArm(key, axis) {
298
+ return VARIANT_STATES[key][axis];
299
+ }
300
+ /** The axes a variant turns off, in VARIANT_AXES order. Empty for `on`. */
301
+ export function offAxesOf(key) {
302
+ return VARIANT_AXES.filter(axis => VARIANT_STATES[key][axis] === 'off');
303
+ }
213
304
  /**
214
- * The variants the build writes for a body, `on` first.
305
+ * The off variants a machine with these switches can read, MOST-PREFERRED FIRST: every
306
+ * off key whose off axes are all off on the machine, the key with the most axes off
307
+ * first, and keys with as many in VARIANT_KEYS order. The first key a host has a file
308
+ * for is the file for the machine's off axes that the host carries an arm on. Empty
309
+ * when every switch is on.
310
+ */
311
+ export function consultedOffKeys(machine) {
312
+ return OFF_VARIANT_KEYS
313
+ .filter(key => offAxesOf(key).every(axis => !machine[axis]))
314
+ .sort((a, b) => offAxesOf(b).length - offAxesOf(a).length);
315
+ }
316
+ /**
317
+ * The variants the build writes for a body, in VARIANT_KEYS order, `on` first.
215
318
  *
216
319
  * A variant is written only for a host that has an arm on each axis the variant turns
217
320
  * off: `curation-off` when the body carries a curation arm, `compliance-off` when it
218
- * carries a compliance arm, and `both-off` when it carries both. A body with no arm
219
- * has the one variant, because every key would give the same bytes.
321
+ * carries a compliance arm, `both-off` when it carries both, and the same for the
322
+ * variants that also turn tdd off. A body with no arm has the one variant, because
323
+ * every key would give the same bytes.
220
324
  */
221
325
  export function emittedVariantKeys(hasArms) {
222
- const keys = ['on'];
223
- if (hasArms.curation)
224
- keys.push('curation-off');
225
- if (hasArms.compliance)
226
- keys.push('compliance-off');
227
- if (hasArms.curation && hasArms.compliance)
228
- keys.push('both-off');
229
- return keys;
326
+ return VARIANT_KEYS.filter(key => VARIANT_AXES.every(axis => VARIANT_STATES[key][axis] === 'on' || hasArms[axis]));
230
327
  }
231
328
  /** The longest stretch of a stray line a refusal quotes. */
232
329
  const STRAY_QUOTE_MAX = 80;
@@ -325,7 +422,7 @@ function applyMarker(marker, lineNo, open) {
325
422
  }
326
423
  }
327
424
  /**
328
- * Split a compiled body into its four variants.
425
+ * Split a compiled body into its variants (VARIANT_KEYS).
329
426
  *
330
427
  * Total and pure: the same input gives the same output, and a refusal is a
331
428
  * Result. The loop is one pass over the body's lines, so its bound is the body's
@@ -340,8 +437,8 @@ function applyMarker(marker, lineNo, open) {
340
437
  */
341
438
  export function splitVariants(body) {
342
439
  const lines = body.split('\n');
343
- const kept = { on: [], 'curation-off': [], 'compliance-off': [], 'both-off': [] };
344
- const seen = { curation: false, compliance: false };
440
+ const kept = recordOf(VARIANT_KEYS, () => []);
441
+ const seen = perAxis(() => false);
345
442
  const open = [];
346
443
  for (let index = 0; index < lines.length; index++) {
347
444
  const line = lines[index];
@@ -359,12 +456,7 @@ export function splitVariants(body) {
359
456
  return Err({ kind: 'unclosed-arm', axis: unclosed.axis, arm: unclosed.arm, openedAt: unclosed.openedAt });
360
457
  }
361
458
  return Ok({
362
- bodies: {
363
- on: kept.on.join('\n'),
364
- 'curation-off': kept['curation-off'].join('\n'),
365
- 'compliance-off': kept['compliance-off'].join('\n'),
366
- 'both-off': kept['both-off'].join('\n'),
367
- },
459
+ bodies: recordOf(VARIANT_KEYS, key => kept[key].join('\n')),
368
460
  hasArms: seen,
369
461
  });
370
462
  }
@@ -383,7 +475,7 @@ export function describeVariantSplitError(error) {
383
475
  case 'interleaved-arm':
384
476
  return (`line ${error.line}: a ${error.axis}:${error.marker} marker ends the ${error.axis} arm opened at line ` +
385
477
  `${error.openedAt} while the ${error.innerAxis} arm opened at line ${error.innerOpenedAt} is still ` +
386
- `open — arms of two axes may nest but not overlap; close the ${error.innerAxis} arm with ` +
478
+ `open — arms of different axes may nest but not overlap; close the ${error.innerAxis} arm with ` +
387
479
  `${error.innerAxis}:end first`);
388
480
  case 'repeated-off':
389
481
  return (`line ${error.line}: a second ${error.axis}:off marker inside the off arm opened at line ` +
@@ -413,8 +505,8 @@ export function describeVariantSplitError(error) {
413
505
  /**
414
506
  * Repo-relative path of the variant manifest, written beside the variant trees.
415
507
  *
416
- * The installer reads it to learn which compiled prompts carry a curation arm and
417
- * which carry a compliance arm, so it never has to guess from the files it finds.
508
+ * The installer reads it to learn which axes each compiled prompt carries an arm on
509
+ * (curation, compliance, tdd), so it never has to guess from the files it finds.
418
510
  */
419
511
  export const VARIANT_MANIFEST_REL_PATH = 'dist/variant-axes.json';
420
512
  /** The manifest format version. A change to the shape of the file raises it. */
@@ -444,7 +536,7 @@ export function renderVariantManifest(hosts) {
444
536
  const key = manifestKeyOf(host);
445
537
  if (entries.has(key))
446
538
  return Err({ kind: 'duplicate-host', host: key });
447
- entries.set(key, { curation: host.axes.curation, compliance: host.axes.compliance });
539
+ entries.set(key, perAxis(axis => host.axes[axis]));
448
540
  }
449
541
  const sorted = {};
450
542
  for (const key of [...entries.keys()].sort(byCodeUnit)) {
@@ -505,17 +597,38 @@ export function parseVariantManifest(text) {
505
597
  const match = MANIFEST_HOST_KEY_RE.exec(key);
506
598
  if (match === null)
507
599
  return Err({ kind: 'bad-host-key', key });
508
- if (!isPlainRecord(value) || typeof value.curation !== 'boolean' || typeof value.compliance !== 'boolean') {
600
+ if (!isPlainRecord(value))
509
601
  return Err({ kind: 'bad-host-axes', host: key });
510
- }
511
- hosts.push({
512
- kind: match[1],
513
- name: match[2],
514
- axes: { curation: value.curation, compliance: value.compliance },
515
- });
602
+ const axes = parseManifestAxes(value);
603
+ if (axes === null)
604
+ return Err({ kind: 'bad-host-axes', host: key });
605
+ hosts.push({ kind: match[1], name: match[2], axes });
516
606
  }
517
607
  return Ok(hosts);
518
608
  }
609
+ /**
610
+ * The axes that existed before any later axis was added. A variant manifest or an install
611
+ * record written before a later axis existed has no entry for it; these axes stay required
612
+ * in both. An absent later axis reads as no arm in the manifest and as on in the record.
613
+ */
614
+ export const ORIGINAL_AXES = ['curation', 'compliance'];
615
+ /**
616
+ * The axes of one host entry, or null when an axis is not a boolean. An axis that is not
617
+ * in ORIGINAL_AXES may be absent, and then it is false.
618
+ */
619
+ function parseManifestAxes(entry) {
620
+ const read = {};
621
+ for (const axis of VARIANT_AXES) {
622
+ const value = entry[axis];
623
+ if (typeof value === 'boolean')
624
+ read[axis] = value;
625
+ else if (value === undefined && !ORIGINAL_AXES.includes(axis))
626
+ read[axis] = false;
627
+ else
628
+ return null;
629
+ }
630
+ return perAxis(axis => read[axis] === true);
631
+ }
519
632
  /** Render a manifest parse refusal as one line a person can act on. */
520
633
  export function describeVariantManifestParseError(error) {
521
634
  switch (error.kind) {
@@ -532,7 +645,7 @@ export function describeVariantManifestParseError(error) {
532
645
  case 'bad-host-key':
533
646
  return `the host key "${error.key}" is not <commands|agents>/<name>`;
534
647
  case 'bad-host-axes':
535
- return `the host ${error.host} does not carry a boolean "curation" and a boolean "compliance"`;
648
+ return `the host ${error.host} does not carry a boolean "curation" and a boolean "compliance" (and, when present, a boolean "tdd")`;
536
649
  default: {
537
650
  const unhandled = error;
538
651
  return `unhandled variant manifest parse error ${JSON.stringify(unhandled)}`;
@@ -550,13 +663,15 @@ export function describeVariantManifestParseError(error) {
550
663
  * have and never the on file.
551
664
  */
552
665
  export function machineVariantKey(machine, hasArms) {
553
- const curationOff = !machine.curation && hasArms.curation;
554
- const complianceOff = !machine.compliance && hasArms.compliance;
555
- if (curationOff && complianceOff)
556
- return 'both-off';
557
- if (curationOff)
558
- return 'curation-off';
559
- return complianceOff ? 'compliance-off' : 'on';
666
+ const off = VARIANT_AXES.filter(axis => !machine[axis] && hasArms[axis]);
667
+ // VARIANT_STATES has one row per combination of axes, so exactly one off key matches a
668
+ // non-empty set (the table test pins that for every machine state and arm set), and
669
+ // `on` is the answer when nothing is off.
670
+ const found = OFF_VARIANT_KEYS.find(key => {
671
+ const turnedOff = offAxesOf(key);
672
+ return turnedOff.length === off.length && turnedOff.every(axis => off.includes(axis));
673
+ });
674
+ return found ?? 'on';
560
675
  }
561
676
  /**
562
677
  * The repo-relative off files a machine must find for the hosts that carry a compliance
@@ -18,6 +18,7 @@ const CURRENT_KEYS = {
18
18
  memory: ['memory'],
19
19
  curation: ['curation', 'learning'],
20
20
  knowledge: ['knowledge'],
21
+ tdd: ['tdd'],
21
22
  };
22
23
  /**
23
24
  * The pre-rename key each machine feature was stored under, where one exists:
@@ -179,8 +180,8 @@ export async function readOptInFeature(devflowDir, feature) {
179
180
  * D-NOOP-TOGGLE: a toggle to the value the manifest already records writes
180
181
  * nothing. The only bytes such a write could change are `updatedAt`, and that
181
182
  * field means "the manifest's content last changed", so a no-op must not move
182
- * it. This is the one write point of `devflow memory|curation|knowledge|behavior
183
- * --enable/--disable`, so the rule holds for all four at once.
183
+ * it. This is the one write point of `devflow memory|curation|knowledge|behavior|tdd
184
+ * --enable/--disable`, so the rule holds for all five at once.
184
185
  *
185
186
  * The read and the write run under the manifest lock (manifest-lock.ts), so the
186
187
  * heal in readManifest cannot write an older snapshot over this write. The wait is