@dtlabs/ai-toolkit 0.8.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.
- package/.claude/agents/generate-work-breakdown.md +212 -192
- package/.claude/agents/install-toolkit.md +6 -1
- package/.claude/agents/validate-work-breakdown-semantic.md +96 -0
- package/.claude/scripts/.gitkeep +0 -0
- package/.claude/scripts/tests/fixtures/ac-table-format.md +48 -0
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +234 -0
- package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +4 -0
- package/.claude/scripts/tests/fixtures/test-feature/feature.md +45 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +39 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +61 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +72 -0
- package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +38 -0
- package/.claude/scripts/tests/fixtures/wb-valid.json +189 -0
- package/.claude/scripts/tests/helpers/buildWaves.js +21 -0
- package/.claude/scripts/tests/helpers/pm-phase2-flow.js +29 -0
- package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +55 -0
- package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +108 -0
- package/.claude/scripts/tests/installer.scripts-distribution.test.js +131 -0
- package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +353 -0
- package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +346 -0
- package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +276 -0
- package/.claude/scripts/tests/verify-us-01-t03.js +288 -0
- package/.claude/scripts/tests/verify-us-03-t04.js +262 -0
- package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +337 -0
- package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +352 -0
- package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +219 -0
- package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +330 -0
- package/.claude/scripts/tests/wb-validate.checks-11.test.js +166 -0
- package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +222 -0
- package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +359 -0
- package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +362 -0
- package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +297 -0
- package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +243 -0
- package/.claude/scripts/wb-render.js +309 -0
- package/.claude/scripts/wb-validate.js +1007 -0
- package/.claude/workflows/pm-phase1.js +22 -13
- package/.claude/workflows/pm-phase2.js +304 -32
- package/.claude/workflows/pm-phase3.js +57 -21
- package/CHANGELOG.md +82 -0
- package/bin/cli.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const VALIDATE = path.resolve(__dirname, '../wb-validate.js');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run wb-validate.js as a subprocess and return exit code, parsed JSON report,
|
|
12
|
+
* and stderr. No requirements path is needed for checks 8–10.
|
|
13
|
+
*/
|
|
14
|
+
function runValidator(wbPath) {
|
|
15
|
+
const result = spawnSync('node', [VALIDATE, wbPath], { encoding: 'utf8' });
|
|
16
|
+
let report = null;
|
|
17
|
+
try { report = JSON.parse(result.stdout); } catch (_) {}
|
|
18
|
+
return { exitCode: result.status, report, stderr: result.stderr };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Shared temp-file lifecycle ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
let tmpFile;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpFile = path.join(os.tmpdir(), `wb-t16-${Date.now()}.json`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function writeFixture(obj) {
|
|
34
|
+
fs.writeFileSync(tmpFile, JSON.stringify(obj, null, 2));
|
|
35
|
+
return tmpFile;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Domain builders ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function makeTask(id, dependsOn = []) {
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
title: `Task ${id}`,
|
|
44
|
+
outcome: 'Something done',
|
|
45
|
+
domain: 'BE',
|
|
46
|
+
agentType: 'developer-backend',
|
|
47
|
+
dependsOn,
|
|
48
|
+
acceptanceCriteria: [],
|
|
49
|
+
verification: { commands: ['node --version'] },
|
|
50
|
+
estimate: { agentMinutes: 10, tokens: 5000 },
|
|
51
|
+
outputCount: 1,
|
|
52
|
+
groupingRationale: 'Single responsibility',
|
|
53
|
+
commit: { subject: `feat: ${id}` },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makePhase(id, tasks, type = 'user-story') {
|
|
58
|
+
return { id, type, title: `Phase ${id}`, commit: `feat(${id}): done`, tasks };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeWB(phases) {
|
|
62
|
+
return { schemaVersion: 2, phases };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('wb-validate.js — checks 8–10', () => {
|
|
68
|
+
|
|
69
|
+
// ── Check 8: dependency reference existence ────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('check 8 — dependency reference existence', () => {
|
|
72
|
+
|
|
73
|
+
test('exits 1 and reports dependency_not_found for the referencing task when dependsOn contains a non-existent task ID', () => {
|
|
74
|
+
// Arrange: task depends on US-01-TASK-BE-99 which is not declared anywhere
|
|
75
|
+
const wb = makeWB([
|
|
76
|
+
makePhase('US-01', [
|
|
77
|
+
makeTask('US-01-TASK-BE-01', ['US-01-TASK-BE-99']),
|
|
78
|
+
]),
|
|
79
|
+
]);
|
|
80
|
+
writeFixture(wb);
|
|
81
|
+
|
|
82
|
+
// Act
|
|
83
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
expect(exitCode).toBe(1);
|
|
87
|
+
expect(report.valid).toBe(false);
|
|
88
|
+
const err = report.errors.find(e => e.category === 'dependency_not_found');
|
|
89
|
+
expect(err).toBeDefined();
|
|
90
|
+
expect(err.taskId).toBe('US-01-TASK-BE-01');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('exits 0 and reports no dependency_not_found errors when all dependsOn IDs resolve to declared tasks', () => {
|
|
94
|
+
// Arrange: two tasks in the same phase; second depends on first
|
|
95
|
+
const wb = makeWB([
|
|
96
|
+
makePhase('US-01', [
|
|
97
|
+
makeTask('US-01-TASK-BE-01'),
|
|
98
|
+
makeTask('US-01-TASK-BE-02', ['US-01-TASK-BE-01']),
|
|
99
|
+
]),
|
|
100
|
+
]);
|
|
101
|
+
writeFixture(wb);
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(exitCode).toBe(0);
|
|
108
|
+
expect(report.valid).toBe(true);
|
|
109
|
+
expect(report.errors.filter(e => e.category === 'dependency_not_found')).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('exits 0 and reports no dependency_not_found errors when a US task depends on an INFRA task from a preceding phase', () => {
|
|
113
|
+
// Arrange: INFRA phase declared first; US-01 task resolves its dep cross-phase
|
|
114
|
+
const wb = makeWB([
|
|
115
|
+
makePhase('INFRA', [makeTask('INFRA-TASK-BE-01')], 'infra'),
|
|
116
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01', ['INFRA-TASK-BE-01'])]),
|
|
117
|
+
]);
|
|
118
|
+
writeFixture(wb);
|
|
119
|
+
|
|
120
|
+
// Act
|
|
121
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
expect(exitCode).toBe(0);
|
|
125
|
+
expect(report.valid).toBe(true);
|
|
126
|
+
expect(report.errors.filter(e => e.category === 'dependency_not_found')).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Check 9: self-dependency ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('check 9 — self-dependency', () => {
|
|
133
|
+
|
|
134
|
+
test('exits 1 and reports self_dependency for the task that lists its own ID in dependsOn', () => {
|
|
135
|
+
// Arrange: task references itself as a dependency
|
|
136
|
+
const wb = makeWB([
|
|
137
|
+
makePhase('US-01', [
|
|
138
|
+
makeTask('US-01-TASK-BE-01', ['US-01-TASK-BE-01']),
|
|
139
|
+
]),
|
|
140
|
+
]);
|
|
141
|
+
writeFixture(wb);
|
|
142
|
+
|
|
143
|
+
// Act
|
|
144
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
147
|
+
expect(exitCode).toBe(1);
|
|
148
|
+
expect(report.valid).toBe(false);
|
|
149
|
+
const err = report.errors.find(e => e.category === 'self_dependency');
|
|
150
|
+
expect(err).toBeDefined();
|
|
151
|
+
expect(err.taskId).toBe('US-01-TASK-BE-01');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('exits 0 and reports no self_dependency errors when no task lists its own ID in dependsOn', () => {
|
|
155
|
+
// Arrange: simple valid WB with no self-references
|
|
156
|
+
const wb = makeWB([
|
|
157
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
158
|
+
]);
|
|
159
|
+
writeFixture(wb);
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(exitCode).toBe(0);
|
|
166
|
+
expect(report.valid).toBe(true);
|
|
167
|
+
expect(report.errors.filter(e => e.category === 'self_dependency')).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── Check 10: phase ID / task ID prefix consistency ────────────────────────
|
|
172
|
+
|
|
173
|
+
describe('check 10 — phase ID / task ID prefix consistency', () => {
|
|
174
|
+
|
|
175
|
+
test('exits 1 and reports phase_id_mismatch when an INFRA-prefixed task ID appears in a non-INFRA phase', () => {
|
|
176
|
+
// Arrange: task ID starts with INFRA-TASK- but is placed in US-01
|
|
177
|
+
const wb = makeWB([
|
|
178
|
+
makePhase('US-01', [
|
|
179
|
+
makeTask('INFRA-TASK-BE-01'),
|
|
180
|
+
]),
|
|
181
|
+
]);
|
|
182
|
+
writeFixture(wb);
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
186
|
+
|
|
187
|
+
// Assert
|
|
188
|
+
expect(exitCode).toBe(1);
|
|
189
|
+
expect(report.valid).toBe(false);
|
|
190
|
+
expect(report.errors.some(e => e.category === 'phase_id_mismatch')).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('exits 1 and reports phase_id_mismatch when a task ID carries a US-prefix that does not match its containing phase', () => {
|
|
194
|
+
// Arrange: task prefixed US-02 but placed in phase US-01
|
|
195
|
+
const wb = makeWB([
|
|
196
|
+
makePhase('US-01', [
|
|
197
|
+
makeTask('US-02-TASK-BE-01'),
|
|
198
|
+
]),
|
|
199
|
+
]);
|
|
200
|
+
writeFixture(wb);
|
|
201
|
+
|
|
202
|
+
// Act
|
|
203
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
204
|
+
|
|
205
|
+
// Assert
|
|
206
|
+
expect(exitCode).toBe(1);
|
|
207
|
+
expect(report.valid).toBe(false);
|
|
208
|
+
expect(report.errors.some(e => e.category === 'phase_id_mismatch')).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('exits 0 and reports no phase_id_mismatch errors when a US task ID prefix exactly matches its phase ID', () => {
|
|
212
|
+
// Arrange: prefix US-01 matches phase US-01
|
|
213
|
+
const wb = makeWB([
|
|
214
|
+
makePhase('US-01', [makeTask('US-01-TASK-BE-01')]),
|
|
215
|
+
]);
|
|
216
|
+
writeFixture(wb);
|
|
217
|
+
|
|
218
|
+
// Act
|
|
219
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
220
|
+
|
|
221
|
+
// Assert
|
|
222
|
+
expect(exitCode).toBe(0);
|
|
223
|
+
expect(report.valid).toBe(true);
|
|
224
|
+
expect(report.errors.filter(e => e.category === 'phase_id_mismatch')).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('exits 0 and reports no phase_id_mismatch errors when an INFRA-prefixed task is placed in the INFRA phase', () => {
|
|
228
|
+
// Arrange: INFRA-TASK-BE-01 correctly placed in the INFRA phase
|
|
229
|
+
const wb = makeWB([
|
|
230
|
+
makePhase('INFRA', [makeTask('INFRA-TASK-BE-01')], 'infra'),
|
|
231
|
+
]);
|
|
232
|
+
writeFixture(wb);
|
|
233
|
+
|
|
234
|
+
// Act
|
|
235
|
+
const { exitCode, report } = runValidator(tmpFile);
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
expect(exitCode).toBe(0);
|
|
239
|
+
expect(report.valid).toBe(true);
|
|
240
|
+
expect(report.errors.filter(e => e.category === 'phase_id_mismatch')).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const jsonPath = process.argv[2]
|
|
9
|
+
const prefix = process.argv[3]
|
|
10
|
+
|
|
11
|
+
if (!jsonPath || !prefix) {
|
|
12
|
+
process.stderr.write('Usage: node wb-render.js <path-to-work-breakdown.json> <feature-prefix> [destination-dir]\n')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const destDir = process.argv[4]
|
|
17
|
+
? process.argv[4]
|
|
18
|
+
: path.dirname(path.resolve(jsonPath))
|
|
19
|
+
|
|
20
|
+
// ── Read and parse JSON ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
let raw
|
|
23
|
+
try {
|
|
24
|
+
raw = fs.readFileSync(jsonPath, 'utf8')
|
|
25
|
+
} catch (err) {
|
|
26
|
+
process.stderr.write(`Error: cannot read file "${jsonPath}": ${err.message}\n`)
|
|
27
|
+
process.exit(1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let wb
|
|
31
|
+
try {
|
|
32
|
+
wb = JSON.parse(raw)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
process.stderr.write(`Error: "${jsonPath}" is not valid JSON: ${err.message}\n`)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Basic structure check ─────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
if (wb.schemaVersion !== 2) {
|
|
41
|
+
process.stderr.write(`Error: schemaVersion must be 2, got ${JSON.stringify(wb.schemaVersion)}\n`)
|
|
42
|
+
process.exit(2)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!Array.isArray(wb.phases)) {
|
|
46
|
+
process.stderr.write('Error: "phases" must be an array\n')
|
|
47
|
+
process.exit(2)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Output file paths ─────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const mdPath = path.join(destDir, `${prefix}-Work-Breakdown.md`)
|
|
53
|
+
const csvPath = path.join(destDir, `${prefix}-Work-Breakdown.csv`)
|
|
54
|
+
|
|
55
|
+
// ── Phase-level depends_on aggregation ───────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function computePhaseDependsOn(wb) {
|
|
58
|
+
// Build taskId → phaseId index
|
|
59
|
+
const taskToPhase = new Map()
|
|
60
|
+
for (const phase of wb.phases) {
|
|
61
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
62
|
+
for (const task of tasks) {
|
|
63
|
+
if (task.id != null) taskToPhase.set(task.id, phase.id)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// For each phase, compute external phase dependencies
|
|
68
|
+
const result = new Map()
|
|
69
|
+
for (const phase of wb.phases) {
|
|
70
|
+
if (phase.id == null) continue
|
|
71
|
+
const extPhases = new Set()
|
|
72
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
73
|
+
for (const task of tasks) {
|
|
74
|
+
if (!Array.isArray(task.dependsOn)) continue
|
|
75
|
+
for (const depId of task.dependsOn) {
|
|
76
|
+
const ownerPhase = taskToPhase.get(depId)
|
|
77
|
+
if (ownerPhase == null) continue // unresolved — skip
|
|
78
|
+
if (ownerPhase === phase.id) continue // intra-phase — remove
|
|
79
|
+
extPhases.add(ownerPhase)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
result.set(phase.id, [...extPhases].sort().join(' '))
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Compute phase dependencies ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
let phaseDepsMap
|
|
90
|
+
try {
|
|
91
|
+
phaseDepsMap = computePhaseDependsOn(wb)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
process.stderr.write(`Error: phase dependency aggregation failed: ${err.message}\n`)
|
|
94
|
+
process.exit(3)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Field helpers ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function sanitizeField(str) {
|
|
100
|
+
const s = typeof str === 'string' ? str : String(str ?? '')
|
|
101
|
+
return s
|
|
102
|
+
.replace(/\|/g, ' ')
|
|
103
|
+
.replace(/\r/g, '')
|
|
104
|
+
.replace(/\n/g, ' ')
|
|
105
|
+
.trim()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildCommitSubject(task) {
|
|
109
|
+
if (!task.commit || typeof task.commit !== 'object') {
|
|
110
|
+
return 'chore: implement task'
|
|
111
|
+
}
|
|
112
|
+
const type = sanitizeField(task.commit.type ?? '')
|
|
113
|
+
const scope = sanitizeField(task.commit.scope ?? '')
|
|
114
|
+
const subject = sanitizeField(task.commit.subject ?? '')
|
|
115
|
+
const full = `${type}(${scope}): ${subject}`
|
|
116
|
+
return full.length > 72 ? full.slice(0, 72) + '…' : full
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Render stubs (implemented in T03–T05) ─────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function renderMarkdown(wb, prefix, phaseDepsMap) {
|
|
122
|
+
const DOMAINS = ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST']
|
|
123
|
+
|
|
124
|
+
function getAgentMinutes(task) {
|
|
125
|
+
const m = task.agentMinutes ?? task.estimate?.agentMinutes
|
|
126
|
+
return (m !== undefined && m !== null) ? Number(m) : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getDurationBand(mins) {
|
|
130
|
+
if (mins === null) return null
|
|
131
|
+
if (mins <= 15) return 'target'
|
|
132
|
+
if (mins <= 20) return 'above'
|
|
133
|
+
if (mins <= 30) return 'warning'
|
|
134
|
+
return 'split'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const allTasks = wb.phases.flatMap(p => Array.isArray(p.tasks) ? p.tasks : [])
|
|
138
|
+
|
|
139
|
+
// Summary duration totals — across all tasks
|
|
140
|
+
let summaryTarget = 0, summaryAbove = 0, summaryWarning = 0, summarySplit = 0
|
|
141
|
+
for (const task of allTasks) {
|
|
142
|
+
const band = getDurationBand(getAgentMinutes(task))
|
|
143
|
+
if (band === 'target') summaryTarget++
|
|
144
|
+
else if (band === 'above') summaryAbove++
|
|
145
|
+
else if (band === 'warning') summaryWarning++
|
|
146
|
+
else if (band === 'split') summarySplit++
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Per-domain stats — for Statistics table and domain distribution string
|
|
150
|
+
const domainStats = {}
|
|
151
|
+
for (const d of DOMAINS) domainStats[d] = { count: 0, target: 0, above: 0, warning: 0, split: 0 }
|
|
152
|
+
for (const task of allTasks) {
|
|
153
|
+
const d = task.domain
|
|
154
|
+
if (!d || !DOMAINS.includes(d)) continue
|
|
155
|
+
const band = getDurationBand(getAgentMinutes(task))
|
|
156
|
+
domainStats[d].count++
|
|
157
|
+
if (band) domainStats[d][band]++
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const domainDistStr = DOMAINS.map(d => `${d}: ${domainStats[d].count}`).join(', ')
|
|
161
|
+
|
|
162
|
+
const statTotals = DOMAINS.reduce(
|
|
163
|
+
(acc, d) => {
|
|
164
|
+
acc.count += domainStats[d].count
|
|
165
|
+
acc.target += domainStats[d].target
|
|
166
|
+
acc.above += domainStats[d].above
|
|
167
|
+
acc.warning += domainStats[d].warning
|
|
168
|
+
acc.split += domainStats[d].split
|
|
169
|
+
return acc
|
|
170
|
+
},
|
|
171
|
+
{ count: 0, target: 0, above: 0, warning: 0, split: 0 }
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
function phaseCommit(phase) {
|
|
175
|
+
if (!phase.commit || typeof phase.commit !== 'object') {
|
|
176
|
+
return `chore(${sanitizeField(prefix)}): implement phase`
|
|
177
|
+
}
|
|
178
|
+
const type = sanitizeField(phase.commit.type ?? 'chore')
|
|
179
|
+
const subject = sanitizeField(phase.commit.subject ?? '')
|
|
180
|
+
return `${type}(${sanitizeField(prefix)}): ${subject}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function taskRow(task) {
|
|
184
|
+
const id = sanitizeField(task.id ?? '—')
|
|
185
|
+
const title = sanitizeField(task.title ?? '')
|
|
186
|
+
const outcome = sanitizeField(task.outcome ?? '')
|
|
187
|
+
const domain = sanitizeField(task.domain ?? '—')
|
|
188
|
+
const mins = getAgentMinutes(task)
|
|
189
|
+
const est = mins !== null ? String(mins) : '—'
|
|
190
|
+
const depArr = Array.isArray(task.dependsOn) && task.dependsOn.length > 0 ? task.dependsOn : null
|
|
191
|
+
const deps = depArr ? depArr.join(', ') : '—'
|
|
192
|
+
const verifArr = task.verificationCommands ?? task.verification?.commands ?? []
|
|
193
|
+
const verif = verifArr.length > 0 ? sanitizeField(verifArr.join('; ')) : '—'
|
|
194
|
+
return `| ${id} | ${title} | ${outcome} | ${domain} | ${est} | ${deps} | ${verif} |`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const title = wb.title ?? wb.feature ?? prefix
|
|
198
|
+
const generated = new Date().toISOString()
|
|
199
|
+
const L = []
|
|
200
|
+
|
|
201
|
+
// ── Title ──
|
|
202
|
+
L.push(`# Work Breakdown — ${title}`)
|
|
203
|
+
L.push('')
|
|
204
|
+
|
|
205
|
+
// ── Document Info ──
|
|
206
|
+
L.push('## Document Info')
|
|
207
|
+
L.push('| Field | Value |')
|
|
208
|
+
L.push('|-------|-------|')
|
|
209
|
+
L.push(`| Feature | ${sanitizeField(prefix)} |`)
|
|
210
|
+
L.push('| Schema | v2 |')
|
|
211
|
+
L.push(`| Generated | ${generated} |`)
|
|
212
|
+
L.push('')
|
|
213
|
+
|
|
214
|
+
// ── Summary ──
|
|
215
|
+
L.push('## Summary')
|
|
216
|
+
L.push('| Metric | Value |')
|
|
217
|
+
L.push('|--------|-------|')
|
|
218
|
+
L.push(`| Total tasks | ${allTasks.length} |`)
|
|
219
|
+
L.push(`| Total phases | ${wb.phases.length} |`)
|
|
220
|
+
L.push(`| Within target (≤15 min) | ${summaryTarget} |`)
|
|
221
|
+
L.push(`| Above target (16–20 min) | ${summaryAbove} |`)
|
|
222
|
+
L.push(`| Warning (21–30 min) | ${summaryWarning} |`)
|
|
223
|
+
L.push(`| Split required (>30 min) | ${summarySplit} |`)
|
|
224
|
+
L.push(`| Domain distribution | ${domainDistStr} |`)
|
|
225
|
+
L.push('')
|
|
226
|
+
|
|
227
|
+
// ── Infrastructure Phase ──
|
|
228
|
+
const infraPhases = wb.phases.filter(p => p.id === 'INFRA')
|
|
229
|
+
const userStoryPhases = wb.phases.filter(p => p.id !== 'INFRA')
|
|
230
|
+
|
|
231
|
+
for (const phase of infraPhases) {
|
|
232
|
+
L.push('## Infrastructure Phase (INFRA)')
|
|
233
|
+
L.push('')
|
|
234
|
+
L.push('### Commit')
|
|
235
|
+
L.push(phaseCommit(phase))
|
|
236
|
+
L.push('')
|
|
237
|
+
L.push('### Tasks')
|
|
238
|
+
L.push('| ID | Title | Outcome | Domain | Est. (min) | Dependencies | Verification |')
|
|
239
|
+
L.push('|---|---|---|---|---|---|---|')
|
|
240
|
+
for (const task of (Array.isArray(phase.tasks) ? phase.tasks : [])) {
|
|
241
|
+
L.push(taskRow(task))
|
|
242
|
+
}
|
|
243
|
+
L.push('')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── User Story Phases ──
|
|
247
|
+
if (userStoryPhases.length > 0) {
|
|
248
|
+
L.push('## User Story Phases')
|
|
249
|
+
L.push('')
|
|
250
|
+
for (const phase of userStoryPhases) {
|
|
251
|
+
L.push(`### ${sanitizeField(phase.id ?? '')}: ${sanitizeField(phase.title ?? '')}`)
|
|
252
|
+
L.push('')
|
|
253
|
+
L.push('### Commit')
|
|
254
|
+
L.push(phaseCommit(phase))
|
|
255
|
+
L.push('')
|
|
256
|
+
L.push('### Tasks')
|
|
257
|
+
L.push('| ID | Title | Outcome | Domain | Est. (min) | Dependencies | Verification |')
|
|
258
|
+
L.push('|---|---|---|---|---|---|---|')
|
|
259
|
+
for (const task of (Array.isArray(phase.tasks) ? phase.tasks : [])) {
|
|
260
|
+
L.push(taskRow(task))
|
|
261
|
+
}
|
|
262
|
+
L.push('')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Statistics ──
|
|
267
|
+
L.push('## Statistics')
|
|
268
|
+
L.push('')
|
|
269
|
+
L.push('| Domain | Count | Target | Above | Warning | Split |')
|
|
270
|
+
L.push('|--------|-------|--------|-------|---------|-------|')
|
|
271
|
+
for (const d of DOMAINS) {
|
|
272
|
+
const s = domainStats[d]
|
|
273
|
+
L.push(`| ${d} | ${s.count} | ${s.target} | ${s.above} | ${s.warning} | ${s.split} |`)
|
|
274
|
+
}
|
|
275
|
+
L.push(`| **Total** | **${statTotals.count}** | **${statTotals.target}** | **${statTotals.above}** | **${statTotals.warning}** | **${statTotals.split}** |`)
|
|
276
|
+
L.push('')
|
|
277
|
+
|
|
278
|
+
return L.join('\n')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderCsv(wb, prefix, phaseDepsMap) {
|
|
282
|
+
const rows = ['phase_id|phase_title|commit_message|depends_on|task_id|task_title|domain|agent_type']
|
|
283
|
+
for (const phase of wb.phases) {
|
|
284
|
+
const phaseId = sanitizeField(phase.id ?? '')
|
|
285
|
+
const phaseTitle = sanitizeField(phase.title ?? '')
|
|
286
|
+
const commitMsg = phase.commit && typeof phase.commit === 'object'
|
|
287
|
+
? sanitizeField(phase.commit.type ?? 'chore') + '(' + sanitizeField(prefix) + '): ' + sanitizeField(phase.commit.subject ?? '')
|
|
288
|
+
: `chore(${sanitizeField(prefix)}): implement phase`
|
|
289
|
+
const dependsOn = sanitizeField(phaseDepsMap.get(phase.id) ?? '')
|
|
290
|
+
const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
|
|
291
|
+
for (const task of tasks) {
|
|
292
|
+
const taskId = sanitizeField(task.id ?? '')
|
|
293
|
+
const taskTitle = sanitizeField(task.title ?? '')
|
|
294
|
+
const domain = sanitizeField(task.domain ?? '')
|
|
295
|
+
const agentType = sanitizeField(task.agentType ?? '')
|
|
296
|
+
rows.push(`${phaseId}|${phaseTitle}|${commitMsg}|${dependsOn}|${taskId}|${taskTitle}|${domain}|${agentType}`)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return rows.join('\n') + '\n'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Write files and exit ──────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
const md = renderMarkdown(wb, prefix, phaseDepsMap)
|
|
305
|
+
const csv = renderCsv(wb, prefix, phaseDepsMap)
|
|
306
|
+
fs.writeFileSync(mdPath, md, 'utf8')
|
|
307
|
+
fs.writeFileSync(csvPath, csv, 'utf8')
|
|
308
|
+
process.stdout.write(JSON.stringify({ exitCode: 0, markdownPath: mdPath, csvPath }) + '\n')
|
|
309
|
+
process.exit(0)
|