@dtlabs/ai-toolkit 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude/agents/generate-work-breakdown.md +212 -192
  2. package/.claude/agents/install-toolkit.md +6 -1
  3. package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
  4. package/.claude/scripts/.gitkeep +0 -0
  5. package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
  6. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
  7. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
  8. package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
  9. package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
  10. package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
  11. package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
  12. package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
  13. package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
  14. package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
  15. package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
  16. package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
  17. package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
  18. package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
  19. package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
  20. package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
  21. package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
  22. package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
  23. package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
  24. package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
  25. package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
  26. package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
  27. package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
  28. package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
  29. package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
  30. package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
  31. package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
  32. package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
  33. package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
  34. package/.claude/scripts/wb-render.js +309 -0
  35. package/.claude/scripts/wb-validate.js +1007 -0
  36. package/.claude/workflows/pm-phase2.js +289 -28
  37. package/CHANGELOG.md +67 -0
  38. package/bin/cli.js +1 -0
  39. package/package.json +1 -1
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const VALIDATE = path.resolve(__dirname, '../wb-validate.js');
9
+ const FIXTURES = path.resolve(__dirname, 'fixtures');
10
+
11
+ function runValidator(wbPath) {
12
+ const result = spawnSync('node', [VALIDATE, wbPath], { encoding: 'utf8' });
13
+ let report = null;
14
+ try { report = JSON.parse(result.stdout); } catch (_) {}
15
+ return { exitCode: result.status, report, stderr: result.stderr };
16
+ }
17
+
18
+ // ── Shared temp-file lifecycle ────────────────────────────────────────────────
19
+
20
+ let tmpFile;
21
+
22
+ beforeEach(() => { tmpFile = path.join(os.tmpdir(), `wb-t18-${Date.now()}.json`); });
23
+ afterEach(() => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
24
+
25
+ function writeFixture(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile; }
26
+
27
+ // ── Domain builders ───────────────────────────────────────────────────────────
28
+
29
+ function makeTask(id, dependsOn = []) {
30
+ return {
31
+ id,
32
+ title: `Task ${id}`,
33
+ outcome: 'Done',
34
+ domain: 'BE',
35
+ agentType: 'developer-backend',
36
+ dependsOn,
37
+ acceptanceCriteria: [],
38
+ verification: { commands: ['node --version'] },
39
+ estimate: { agentMinutes: 10, tokens: 5000 },
40
+ outputCount: 1,
41
+ groupingRationale: 'Single',
42
+ commit: { subject: `feat: ${id}` },
43
+ };
44
+ }
45
+
46
+ function makePhase(id, tasks, type = 'user-story') {
47
+ return { id, type, title: `Phase ${id}`, commit: `feat(${id}): done`, tasks };
48
+ }
49
+
50
+ function makeWB(phases) { return { schemaVersion: 2, phases }; }
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ describe('wb-validate.js — check 12 — phase dependency projection (phase_dependency_not_found)', () => {
55
+
56
+ // ── Test A: cross-phase dep projects into phaseGraph ────────────────────────
57
+
58
+ test('exits 0, phaseGraph[US-02] contains US-01, and phaseGraph[US-01] is empty when US-02 task depends on a US-01 task', () => {
59
+ // Arrange: one cross-phase dependency — US-02-TASK-BE-01 depends on US-01-TASK-BE-01
60
+ const wb = makeWB([
61
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
62
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
63
+ ]);
64
+ writeFixture(wb);
65
+
66
+ // Act
67
+ const { exitCode, report } = runValidator(tmpFile);
68
+
69
+ // Assert
70
+ expect(exitCode).toBe(0);
71
+ expect(report.dependencies.phaseGraph['US-02']).toContain('US-01');
72
+ expect(report.dependencies.phaseGraph['US-01']).toEqual([]);
73
+ });
74
+
75
+ // ── Test B: intra-phase dep does NOT appear in phaseGraph ───────────────────
76
+
77
+ test('exits 0 and phaseGraph[US-01] is empty when a task depends on another task within the same phase (intra-phase dep is removed)', () => {
78
+ // Arrange: both tasks are in US-01; the dep is intra-phase and must not project
79
+ const wb = makeWB([
80
+ makePhase('US-01', [
81
+ makeTask('US-01-TASK-BE-01'),
82
+ makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-01']),
83
+ ]),
84
+ ]);
85
+ writeFixture(wb);
86
+
87
+ // Act
88
+ const { exitCode, report } = runValidator(tmpFile);
89
+
90
+ // Assert
91
+ expect(exitCode).toBe(0);
92
+ expect(report.dependencies.phaseGraph['US-01']).toEqual([]);
93
+ });
94
+ });
95
+
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ describe('wb-validate.js — check 13 — phase cycle detection (phase_cycle_detected)', () => {
99
+
100
+ // ── Test A: two-phase mutual cycle ───────────────────────────────────────────
101
+
102
+ test('exits 1, reports phase_cycle_detected, and details.cycleMembers contains both phase IDs for a two-phase mutual cycle (US-01 ↔ US-02)', () => {
103
+ // Arrange: US-01 task depends on US-02 task AND US-02 task depends on US-01 task
104
+ const wb = makeWB([
105
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['US-02-TASK-BE-01'])]),
106
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
107
+ ]);
108
+ writeFixture(wb);
109
+
110
+ // Act
111
+ const { exitCode, report } = runValidator(tmpFile);
112
+
113
+ // Assert
114
+ expect(exitCode).toBe(1);
115
+ const cycleErr = report.errors.find(e => e.category === 'phase_cycle_detected');
116
+ expect(cycleErr).toBeDefined();
117
+ expect(Array.isArray(cycleErr.details.cycleMembers)).toBe(true);
118
+ expect(cycleErr.details.cycleMembers).toContain('US-01');
119
+ expect(cycleErr.details.cycleMembers).toContain('US-02');
120
+ });
121
+
122
+ // ── Test B: acyclic WB — phaseCycles is an empty array ──────────────────────
123
+
124
+ test('exits 0 and report.dependencies.phaseCycles is an empty array when there are no phase cycles', () => {
125
+ // Arrange: linear chain US-01 → US-02 — no cycle
126
+ const wb = makeWB([
127
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
128
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
129
+ ]);
130
+ writeFixture(wb);
131
+
132
+ // Act
133
+ const { exitCode, report } = runValidator(tmpFile);
134
+
135
+ // Assert
136
+ expect(exitCode).toBe(0);
137
+ expect(report.dependencies.phaseCycles).toEqual([]);
138
+ });
139
+ });
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+
143
+ describe('wb-validate.js — check 14 — phase schedulability / buildWaves (phase_unschedulable)', () => {
144
+
145
+ // ── Test A: linear chain → three sequential waves ────────────────────────────
146
+
147
+ test('exits 0 and phaseWaves is [[US-01], [US-02], [US-03]] for a linear chain US-01 → US-02 → US-03', () => {
148
+ // Arrange: each phase depends on the previous one's task
149
+ const wb = makeWB([
150
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
151
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
152
+ makePhase('US-03', [makeTask('US-03-TASK-BE-01', ['US-02-TASK-BE-01'])]),
153
+ ]);
154
+ writeFixture(wb);
155
+
156
+ // Act
157
+ const { exitCode, report } = runValidator(tmpFile);
158
+
159
+ // Assert
160
+ expect(exitCode).toBe(0);
161
+ expect(report.dependencies.phaseWaves).toHaveLength(3);
162
+ expect(report.dependencies.phaseWaves[0]).toEqual(['US-01']);
163
+ expect(report.dependencies.phaseWaves[1]).toEqual(['US-02']);
164
+ expect(report.dependencies.phaseWaves[2]).toEqual(['US-03']);
165
+ expect(report.dependencies.phaseUnschedulable).toEqual([]);
166
+ });
167
+
168
+ // ── Test B: parallel phases → wave 0 has US-01 + US-02, wave 1 has US-03 ────
169
+
170
+ test('exits 0, wave 0 contains both US-01 and US-02, and wave 1 is [US-03] when US-01 and US-02 are independent and US-03 depends on both', () => {
171
+ // Arrange: US-01 and US-02 have no cross-phase deps; US-03 depends on both
172
+ const wb = makeWB([
173
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
174
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01')]),
175
+ makePhase('US-03', [makeTask('US-03-TASK-BE-01', ['US-01-TASK-BE-01', 'US-02-TASK-BE-01'])]),
176
+ ]);
177
+ writeFixture(wb);
178
+
179
+ // Act
180
+ const { exitCode, report } = runValidator(tmpFile);
181
+
182
+ // Assert
183
+ expect(exitCode).toBe(0);
184
+ expect(report.dependencies.phaseWaves).toHaveLength(2);
185
+ expect(report.dependencies.phaseWaves[0]).toContain('US-01');
186
+ expect(report.dependencies.phaseWaves[0]).toContain('US-02');
187
+ expect(report.dependencies.phaseWaves[1]).toEqual(['US-03']);
188
+ });
189
+
190
+ // ── Test C: phase cycle creates deadlock → phase_unschedulable ───────────────
191
+
192
+ test('exits 1, reports phase_unschedulable error, and phaseUnschedulable contains at least one phase ID when a mutual cycle creates a scheduling deadlock', () => {
193
+ // Arrange: same two-phase mutual cycle as check 13 test A — neither phase can be scheduled
194
+ const wb = makeWB([
195
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['US-02-TASK-BE-01'])]),
196
+ makePhase('US-02', [makeTask('US-02-TASK-BE-01', ['US-01-TASK-BE-01'])]),
197
+ ]);
198
+ writeFixture(wb);
199
+
200
+ // Act
201
+ const { exitCode, report } = runValidator(tmpFile);
202
+
203
+ // Assert
204
+ expect(exitCode).toBe(1);
205
+ expect(report.dependencies.phaseUnschedulable.length).toBeGreaterThanOrEqual(1);
206
+ expect(report.errors.some(e => e.category === 'phase_unschedulable')).toBe(true);
207
+ });
208
+
209
+ // ── Test D: phaseWaves is always present in the report ───────────────────────
210
+
211
+ test('report.dependencies.phaseWaves is an array even when the WB is valid (wb-valid.json exits 0)', () => {
212
+ // Arrange: use the pre-existing valid fixture — no cycles, fully schedulable
213
+ const wbPath = path.join(FIXTURES, 'wb-valid.json');
214
+
215
+ // Act
216
+ const { exitCode, report } = runValidator(wbPath);
217
+
218
+ // Assert
219
+ expect(exitCode).toBe(0);
220
+ expect(Array.isArray(report.dependencies.phaseWaves)).toBe(true);
221
+ });
222
+ });
@@ -0,0 +1,359 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const VALIDATE = path.resolve(__dirname, '../wb-validate.js');
9
+
10
+ function runValidator(wbPath) {
11
+ const result = spawnSync('node', [VALIDATE, wbPath], { encoding: 'utf8' });
12
+ let report = null;
13
+ try { report = JSON.parse(result.stdout); } catch (_) {}
14
+ return { exitCode: result.status, report, stderr: result.stderr };
15
+ }
16
+
17
+ // ── Shared temp-file lifecycle ────────────────────────────────────────────────
18
+
19
+ let tmpFile;
20
+
21
+ beforeEach(() => { tmpFile = path.join(os.tmpdir(), `wb-t19-${Date.now()}.json`); });
22
+ afterEach(() => { try { fs.unlinkSync(tmpFile); } catch (_) {} });
23
+
24
+ function writeFixture(obj) { fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2)); return tmpFile; }
25
+
26
+ // ── Domain builders ───────────────────────────────────────────────────────────
27
+
28
+ function makeTask(id, overrides = {}) {
29
+ return {
30
+ id,
31
+ title: `Task ${id}`,
32
+ outcome: 'Done',
33
+ domain: 'BE',
34
+ agentType: 'developer-backend',
35
+ dependsOn: [],
36
+ acceptanceCriteria: [],
37
+ verification: { commands: ['node --version'] },
38
+ estimate: { agentMinutes: 10, tokens: 5000 },
39
+ outputCount: 1,
40
+ groupingRationale: 'Single',
41
+ commit: { subject: `feat: ${id}` },
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function makePhase(id, tasks) {
47
+ return { id, type: 'user-story', title: `Phase ${id}`, commit: `feat(${id}): done`, tasks };
48
+ }
49
+
50
+ function makeWB(phases) { return { schemaVersion: 2, phases }; }
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ describe('wb-validate.js — check 15 — duration policy (target / above_target / warning / split_required)', () => {
55
+
56
+ // ── Test A: target band (≤15 min) ────────────────────────────────────────────
57
+
58
+ test('exits 0, no errors or warnings, and durationBands.target >= 1 when agentMinutes is 10 (≤15, target band)', () => {
59
+ // Arrange
60
+ const wb = makeWB([
61
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 10, tokens: 5000 } })]),
62
+ ]);
63
+ writeFixture(wb);
64
+
65
+ // Act
66
+ const { exitCode, report } = runValidator(tmpFile);
67
+
68
+ // Assert
69
+ expect(exitCode).toBe(0);
70
+ expect(report.errors).toHaveLength(0);
71
+ expect(report.warnings).toHaveLength(0);
72
+ expect(report.durationBands.target).toBeGreaterThanOrEqual(1);
73
+ });
74
+
75
+ // ── Test B: above_target band (16–20 min) ────────────────────────────────────
76
+
77
+ test('exits 0, no errors or warnings, and durationBands.above_target >= 1 when agentMinutes is 18 (16–20, above_target band)', () => {
78
+ // Arrange
79
+ const wb = makeWB([
80
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 18, tokens: 5000 } })]),
81
+ ]);
82
+ writeFixture(wb);
83
+
84
+ // Act
85
+ const { exitCode, report } = runValidator(tmpFile);
86
+
87
+ // Assert
88
+ expect(exitCode).toBe(0);
89
+ expect(report.errors).toHaveLength(0);
90
+ expect(report.warnings).toHaveLength(0);
91
+ expect(report.durationBands.above_target).toBeGreaterThanOrEqual(1);
92
+ });
93
+
94
+ // ── Test C: warning band (21–30 min) — non-blocking ─────────────────────────
95
+
96
+ test('exits 0, report.warnings contains a duration_warning entry, and durationBands.warning >= 1 when agentMinutes is 25 (21–30, warning band)', () => {
97
+ // Arrange
98
+ const wb = makeWB([
99
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 25, tokens: 5000 } })]),
100
+ ]);
101
+ writeFixture(wb);
102
+
103
+ // Act
104
+ const { exitCode, report } = runValidator(tmpFile);
105
+
106
+ // Assert
107
+ expect(exitCode).toBe(0);
108
+ const warningEntry = report.warnings.find(w => w.category === 'duration_warning');
109
+ expect(warningEntry).toBeDefined();
110
+ expect(report.durationBands.warning).toBeGreaterThanOrEqual(1);
111
+ });
112
+
113
+ // ── Test D: split_required band (>30 min) — blocking error ───────────────────
114
+
115
+ test('exits 1, report.errors contains a split_required entry, and durationBands.split_required >= 1 when agentMinutes is 35 (>30, split_required band)', () => {
116
+ // Arrange
117
+ const wb = makeWB([
118
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 35, tokens: 5000 } })]),
119
+ ]);
120
+ writeFixture(wb);
121
+
122
+ // Act
123
+ const { exitCode, report } = runValidator(tmpFile);
124
+
125
+ // Assert
126
+ expect(exitCode).toBe(1);
127
+ const errorEntry = report.errors.find(e => e.category === 'split_required');
128
+ expect(errorEntry).toBeDefined();
129
+ expect(report.durationBands.split_required).toBeGreaterThanOrEqual(1);
130
+ });
131
+
132
+ // ── Test E: all four bands present in one WB ─────────────────────────────────
133
+
134
+ test('exits 1 and durationBands has all four keys with correct counts when tasks span all four duration bands (10 / 18 / 25 / 35 min)', () => {
135
+ // Arrange: four tasks — one in each duration band
136
+ const wb = makeWB([
137
+ makePhase('US-01', [
138
+ makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 10, tokens: 5000 } }),
139
+ makeTask('US-01-TASK-BE-02', { estimate: { agentMinutes: 18, tokens: 5000 } }),
140
+ makeTask('US-01-TASK-BE-03', { estimate: { agentMinutes: 25, tokens: 5000 } }),
141
+ makeTask('US-01-TASK-BE-04', { estimate: { agentMinutes: 35, tokens: 5000 } }),
142
+ ]),
143
+ ]);
144
+ writeFixture(wb);
145
+
146
+ // Act
147
+ const { exitCode, report } = runValidator(tmpFile);
148
+
149
+ // Assert: split_required causes a blocking error → exit 1
150
+ expect(exitCode).toBe(1);
151
+ expect(report.durationBands.target).toBe(1);
152
+ expect(report.durationBands.above_target).toBe(1);
153
+ expect(report.durationBands.warning).toBe(1);
154
+ expect(report.durationBands.split_required).toBe(1);
155
+ });
156
+ });
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+
160
+ describe('wb-validate.js — check 16 — empty verification commands (empty_verification_commands)', () => {
161
+
162
+ // ── Test A: empty commands array → blocking error ────────────────────────────
163
+
164
+ test('exits 1 and report.errors contains empty_verification_commands when verification.commands is an empty array', () => {
165
+ // Arrange
166
+ const wb = makeWB([
167
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { verification: { commands: [] } })]),
168
+ ]);
169
+ writeFixture(wb);
170
+
171
+ // Act
172
+ const { exitCode, report } = runValidator(tmpFile);
173
+
174
+ // Assert
175
+ expect(exitCode).toBe(1);
176
+ const errorEntry = report.errors.find(e => e.category === 'empty_verification_commands');
177
+ expect(errorEntry).toBeDefined();
178
+ });
179
+
180
+ // ── Test B: non-empty commands array → no error ──────────────────────────────
181
+
182
+ test('exits 0 and no empty_verification_commands error is reported when verification.commands has at least one entry', () => {
183
+ // Arrange
184
+ const wb = makeWB([
185
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { verification: { commands: ['node --version'] } })]),
186
+ ]);
187
+ writeFixture(wb);
188
+
189
+ // Act
190
+ const { exitCode, report } = runValidator(tmpFile);
191
+
192
+ // Assert
193
+ expect(exitCode).toBe(0);
194
+ expect(report.errors.find(e => e.category === 'empty_verification_commands')).toBeUndefined();
195
+ });
196
+ });
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+
200
+ describe('wb-validate.js — check 17 — empty commit subject (empty_commit_subject)', () => {
201
+
202
+ // ── Test A: empty string subject → blocking error ────────────────────────────
203
+
204
+ test('exits 1 and report.errors contains empty_commit_subject when commit.subject is an empty string', () => {
205
+ // Arrange
206
+ const wb = makeWB([
207
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: '' } })]),
208
+ ]);
209
+ writeFixture(wb);
210
+
211
+ // Act
212
+ const { exitCode, report } = runValidator(tmpFile);
213
+
214
+ // Assert
215
+ expect(exitCode).toBe(1);
216
+ const errorEntry = report.errors.find(e => e.category === 'empty_commit_subject');
217
+ expect(errorEntry).toBeDefined();
218
+ });
219
+
220
+ // ── Test B: whitespace-only subject → blocking error ─────────────────────────
221
+
222
+ test('exits 1 and report.errors contains empty_commit_subject when commit.subject is whitespace only', () => {
223
+ // Arrange
224
+ const wb = makeWB([
225
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: ' ' } })]),
226
+ ]);
227
+ writeFixture(wb);
228
+
229
+ // Act
230
+ const { exitCode, report } = runValidator(tmpFile);
231
+
232
+ // Assert
233
+ expect(exitCode).toBe(1);
234
+ const errorEntry = report.errors.find(e => e.category === 'empty_commit_subject');
235
+ expect(errorEntry).toBeDefined();
236
+ });
237
+
238
+ // ── Test C: valid non-empty subject → no error ───────────────────────────────
239
+
240
+ test('exits 0 and no empty_commit_subject error is reported when commit.subject is a non-empty string', () => {
241
+ // Arrange
242
+ const wb = makeWB([
243
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { commit: { subject: 'feat: do something' } })]),
244
+ ]);
245
+ writeFixture(wb);
246
+
247
+ // Act
248
+ const { exitCode, report } = runValidator(tmpFile);
249
+
250
+ // Assert
251
+ expect(exitCode).toBe(0);
252
+ expect(report.errors.find(e => e.category === 'empty_commit_subject')).toBeUndefined();
253
+ });
254
+ });
255
+
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+
258
+ describe('wb-validate.js — check 18 — missing grouping rationale (missing_grouping_rationale)', () => {
259
+
260
+ // ── Test A: empty string rationale → blocking error ──────────────────────────
261
+
262
+ test('exits 1 and report.errors contains missing_grouping_rationale when groupingRationale is an empty string', () => {
263
+ // Arrange
264
+ const wb = makeWB([
265
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: '' })]),
266
+ ]);
267
+ writeFixture(wb);
268
+
269
+ // Act
270
+ const { exitCode, report } = runValidator(tmpFile);
271
+
272
+ // Assert
273
+ expect(exitCode).toBe(1);
274
+ const errorEntry = report.errors.find(e => e.category === 'missing_grouping_rationale');
275
+ expect(errorEntry).toBeDefined();
276
+ });
277
+
278
+ // ── Test B: whitespace-only rationale → blocking error ───────────────────────
279
+
280
+ test('exits 1 and report.errors contains missing_grouping_rationale when groupingRationale is whitespace only', () => {
281
+ // Arrange
282
+ const wb = makeWB([
283
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: ' ' })]),
284
+ ]);
285
+ writeFixture(wb);
286
+
287
+ // Act
288
+ const { exitCode, report } = runValidator(tmpFile);
289
+
290
+ // Assert
291
+ expect(exitCode).toBe(1);
292
+ const errorEntry = report.errors.find(e => e.category === 'missing_grouping_rationale');
293
+ expect(errorEntry).toBeDefined();
294
+ });
295
+
296
+ // ── Test C: valid non-empty rationale → no error ─────────────────────────────
297
+
298
+ test('exits 0 and no missing_grouping_rationale error is reported when groupingRationale is a non-empty string', () => {
299
+ // Arrange
300
+ const wb = makeWB([
301
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { groupingRationale: 'Single responsibility' })]),
302
+ ]);
303
+ writeFixture(wb);
304
+
305
+ // Act
306
+ const { exitCode, report } = runValidator(tmpFile);
307
+
308
+ // Assert
309
+ expect(exitCode).toBe(0);
310
+ expect(report.errors.find(e => e.category === 'missing_grouping_rationale')).toBeUndefined();
311
+ });
312
+ });
313
+
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+ // W2 contract-pinning — check 15 details field name must be agentMinutes
316
+ //
317
+ // Pins the field name contract between wb-validate.js (producer) and pm-phase2.js
318
+ // (consumer). If wb-validate.js ever renames the field, this test breaks before
319
+ // the consumer silently returns undefined at runtime.
320
+ // ─────────────────────────────────────────────────────────────────────────────
321
+
322
+ describe('wb-validate.js — check 15 — details field contract: agentMinutes (not estimateMinutes)', () => {
323
+
324
+ test('duration_warning entry details contains agentMinutes (not estimateMinutes) when task exceeds target band', () => {
325
+ // Arrange: task with 25 min estimate → warning band (21-30 min)
326
+ const wb = makeWB([
327
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 25, tokens: 5000 } })]),
328
+ ]);
329
+ writeFixture(wb);
330
+
331
+ // Act
332
+ const { exitCode, report } = runValidator(tmpFile);
333
+
334
+ // Assert
335
+ expect(exitCode).toBe(0); // warning band is non-blocking
336
+ const warning = (report.warnings || []).find(w => w.category === 'duration_warning');
337
+ expect(warning).toBeDefined();
338
+ expect(warning.details).toHaveProperty('agentMinutes', 25);
339
+ expect(warning.details).not.toHaveProperty('estimateMinutes');
340
+ });
341
+
342
+ test('split_required entry details contains agentMinutes (not estimateMinutes) when task exceeds split band', () => {
343
+ // Arrange: task with 35 min estimate → split_required band (>30 min)
344
+ const wb = makeWB([
345
+ makePhase('US-01', [makeTask('US-01-TASK-BE-01', { estimate: { agentMinutes: 35, tokens: 5000 } })]),
346
+ ]);
347
+ writeFixture(wb);
348
+
349
+ // Act
350
+ const { exitCode, report } = runValidator(tmpFile);
351
+
352
+ // Assert
353
+ expect(exitCode).toBe(1); // split_required is a blocking error
354
+ const error = report.errors.find(e => e.category === 'split_required');
355
+ expect(error).toBeDefined();
356
+ expect(error.details).toHaveProperty('agentMinutes', 35);
357
+ expect(error.details).not.toHaveProperty('estimateMinutes');
358
+ });
359
+ });