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,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
@@ -2,22 +2,36 @@
2
2
  * The install record of the prompt variants (#648, AC-29 and AC-31).
3
3
  *
4
4
  * After a converge that finished clean, the manifest records which variant of each axis the
5
- * installed prompts hold: `installedVariants: { curation, compliance }`. The record is the only
5
+ * installed prompts hold: `installedVariants`, one boolean per axis. The record is the only
6
6
  * way to tell what is installed without reading every prompt, and it answers one question the
7
7
  * switches alone cannot: does this machine still carry posting steps from before the switch moved?
8
8
  *
9
9
  * The manifest reader carries the field raw, so a hand-edited or newer-version value reaches this
10
- * module unchecked. {@link readInstalledVariants} is the one parse: anything but two booleans is
11
- * unknown, and an absent field is unknown. Nothing heals an unknown record. Pure, with no I/O.
10
+ * module unchecked. {@link readInstalledVariants} is the one parse: a value that is not a boolean
11
+ * on an axis is unknown, and an absent field is unknown. A record written before the tdd axis
12
+ * existed has no `tdd`; it reads as on, because the installed prompts of that time carried the
13
+ * tdd text. Nothing heals an unknown record. Pure, with no I/O.
14
+ */
15
+ import { ORIGINAL_AXES, VARIANT_AXES, perAxis } from './curation-variants.js';
16
+ /**
17
+ * The record as the manifest carries it, or null when it is absent or an axis is not a boolean (unknown).
18
+ * An axis added after the first record (not in ORIGINAL_AXES) may be absent, and then it is on.
12
19
  */
13
- /** The record as the manifest carries it, or null when it is absent or is not two booleans (unknown). */
14
20
  export function readInstalledVariants(raw) {
15
21
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
16
22
  return null;
17
- const { curation, compliance } = raw;
18
- if (typeof curation !== 'boolean' || typeof compliance !== 'boolean')
19
- return null;
20
- return { curation, compliance };
23
+ const record = raw;
24
+ const read = {};
25
+ for (const axis of VARIANT_AXES) {
26
+ const value = record[axis];
27
+ if (typeof value === 'boolean')
28
+ read[axis] = value;
29
+ else if (value === undefined && !ORIGINAL_AXES.includes(axis))
30
+ read[axis] = true;
31
+ else
32
+ return null;
33
+ }
34
+ return perAxis(axis => read[axis] === true);
21
35
  }
22
36
  /**
23
37
  * The record to write for a converge, or null when none is owed (AC-29). Only a converge that
@@ -26,7 +40,14 @@ export function readInstalledVariants(raw) {
26
40
  * notice stays due. Pure; the result is a fresh object.
27
41
  */
28
42
  export function installRecordFor(converged, switches) {
29
- return converged ? { curation: switches.curation, compliance: switches.compliance } : null;
43
+ return converged ? perAxis(axis => switches[axis]) : null;
44
+ }
45
+ export function variantRecordStatus(installed, axis, enabled) {
46
+ if (installed === null)
47
+ return 'unknown';
48
+ if (installed[axis] !== enabled)
49
+ return 'mismatch';
50
+ return enabled ? 'on' : 'off';
30
51
  }
31
52
  /**
32
53
  * Whether the posting steps are installed, from the record and the machine's compliance switch.
@@ -37,11 +58,21 @@ export function installRecordFor(converged, switches) {
37
58
  * both name the remedy.
38
59
  */
39
60
  export function postingStatus(installed, complianceEnabled) {
40
- if (installed === null)
41
- return 'unknown (run devflow init)';
42
- if (installed.compliance !== complianceEnabled)
43
- return 'mismatch (run devflow init)';
44
- return complianceEnabled ? 'installed' : 'not installed';
61
+ const status = variantRecordStatus(installed, 'compliance', complianceEnabled);
62
+ switch (status) {
63
+ case 'on':
64
+ return 'installed';
65
+ case 'off':
66
+ return 'not installed';
67
+ case 'mismatch':
68
+ return 'mismatch (run devflow init)';
69
+ case 'unknown':
70
+ return 'unknown (run devflow init)';
71
+ default: {
72
+ const unhandled = status;
73
+ return unhandled;
74
+ }
75
+ }
45
76
  }
46
77
  /** What `devflow init` prints, once, when the installed prompts move from posting steps to none. */
47
78
  export const POSTING_OFF_NOTICE = 'Compliance is off on this machine, so devflow installed no step that posts test-plan evidence, ' +
@@ -90,6 +90,7 @@ export function hasRequiredManifestFields(data) {
90
90
  * - features.proxy absent → false
91
91
  * - features.compliance absent/malformed → {enabled:false, frameworks:[]}
92
92
  * - features.behavior absent → false; malformed → false, healed on disk (D-BEHAVIOR-OPT-IN)
93
+ * - features.tdd absent or malformed → true; nothing is healed on disk
93
94
  * - features.tracker absent/malformed → {provider:'github'}
94
95
  * - features.tracker.servers absent → the default list; invalid → the default list, reported
95
96
  * through `opts.onWarning` when the caller supplies one (this reader has no output channel)
@@ -198,6 +199,8 @@ export async function readManifest(devflowDir, opts) {
198
199
  compliance: normalizeComplianceFeature(features.compliance),
199
200
  // D-BEHAVIOR-OPT-IN: absent/malformed → false (opt-in; the inverse of knowledge/curation).
200
201
  behavior: features.behavior === true,
202
+ // D-FEATURES-ABSENT-ON: absent/malformed → true. Not in needsHeal: no write is owed.
203
+ tdd: isMachineFeatureOn(data, 'tdd'),
201
204
  // Self-heal: absent/malformed/unknown tracker → {provider:'github'} (AC-3.21).
202
205
  // Deliberately NOT in the hard-null set above — see the field's doc comment.
203
206
  // Healing here is silent and emits no DEGRADED: that is the correct
@@ -40,7 +40,7 @@
40
40
  import { createHash } from 'crypto';
41
41
  import * as path from 'path';
42
42
  import { isContainedIn } from './paths.js';
43
- import { COMPLIANCE_OFF_OUTPUT_DIR, OFF_OUTPUT_DIRS, machineVariantKey, splitVariants, } from './curation-variants.js';
43
+ import { COMPLIANCE_OFF_OUTPUT_DIR, OFF_OUTPUT_DIRS, VARIANT_KEYS, emittedVariantKeys, machineVariantKey, splitVariants, variantArm, } from './curation-variants.js';
44
44
  function Ok(value) {
45
45
  return { ok: true, value };
46
46
  }
@@ -1311,21 +1311,28 @@ export function describeWorkflowFolderError(error) {
1311
1311
  }
1312
1312
  }
1313
1313
  }
1314
- /** A folder is complete for every key, so the key a machine reads is the one for its switches alone. */
1315
- const FOLDER_ARMS = { curation: true, compliance: true };
1314
+ /**
1315
+ * A folder is complete for every key it is built under, so the key a machine reads is the
1316
+ * one for its switches alone. A folder is built under the tdd-on keys only: the tdd axis
1317
+ * is outside the folders (no tdd arm), so a machine with tdd off reads the tdd-on folder.
1318
+ * A total record, so a new axis cannot go without an answer here.
1319
+ */
1320
+ export const FOLDER_ARMS = { curation: true, compliance: true, tdd: false };
1321
+ /** The variant keys a workflow folder is built, planned, pruned and shipped under: the keys FOLDER_ARMS emits. */
1322
+ export const FOLDER_VARIANT_KEYS = emittedVariantKeys(FOLDER_ARMS);
1316
1323
  /**
1317
1324
  * The variant key of the folder a machine with these switches reads.
1318
1325
  *
1319
1326
  * Unlike a prompt, a folder is complete under every key (a byte copy of the on text
1320
1327
  * where no arm applies), so the key follows the switches alone and never depends on
1321
- * which arms a file carries. The mapping is machineVariantKey's, with every arm assumed
1322
- * present, so the key table has one owner.
1328
+ * which arms a file carries. The mapping is machineVariantKey's, with the arms of
1329
+ * FOLDER_ARMS (every axis except tdd, which no folder carries), so the key table has one owner.
1323
1330
  */
1324
1331
  export function workflowFolderKey(switches) {
1325
1332
  return machineVariantKey(switches, FOLDER_ARMS);
1326
1333
  }
1327
1334
  /** The keys whose folder omits the compliance-gated files: the ones that turn compliance off. */
1328
- const COMPLIANCE_OFF_KEYS = new Set([true, false].map(curation => workflowFolderKey({ curation, compliance: false })));
1335
+ const COMPLIANCE_OFF_KEYS = new Set(VARIANT_KEYS.filter(key => variantArm(key, 'compliance') === 'off'));
1329
1336
  /**
1330
1337
  * The files one variant key's folder holds, in registry order, the core first.
1331
1338
  *