@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.
Files changed (41) 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-phase1.js +22 -13
  37. package/.claude/workflows/pm-phase2.js +304 -32
  38. package/.claude/workflows/pm-phase3.js +57 -21
  39. package/CHANGELOG.md +82 -0
  40. package/bin/cli.js +1 -0
  41. package/package.json +1 -1
@@ -0,0 +1,1007 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // Schema v2 constants — used by validation checks below
7
+
8
+ const SCHEMA_VERSION = 2
9
+
10
+ const REQUIRED_PHASE_FIELDS = [
11
+ 'id',
12
+ 'type',
13
+ 'title',
14
+ 'commit',
15
+ 'tasks',
16
+ ]
17
+
18
+ const VALID_DOMAINS = ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST']
19
+
20
+ const VALID_AGENT_TYPES = [
21
+ 'developer-backend',
22
+ 'developer-frontend',
23
+ 'developer-testing',
24
+ 'developer-database',
25
+ 'review-solution',
26
+ ]
27
+
28
+ // Duration policy thresholds (agentMinutes) — used by check 15
29
+ const TARGET_MAX = 15 // tasks ≤15 min agent estimate: target band
30
+ const ABOVE_MAX = 20 // tasks 16–20 min: above target (no flag)
31
+ const WARNING_MAX = 30 // tasks 21–30 min: warning band (non-blocking); >30 = splitRequired (blocking)
32
+
33
+ // Error catalog — eliminates magic strings across validation checks
34
+ const ERRORS = {
35
+ SCHEMA_VERSION_INVALID: 'schema_version_invalid', // check 2
36
+ UNIQUE_ID_VIOLATION: 'unique_id_violation', // check 3
37
+ MISSING_FIELD: 'missing_field', // check 4
38
+ INVALID_ID_FORMAT: 'invalid_id_format', // check 5
39
+ INVALID_DOMAIN: 'invalid_domain', // check 6
40
+ INVALID_AGENT_TYPE: 'invalid_agent_type', // check 7
41
+ DEPENDENCY_NOT_FOUND: 'dependency_not_found', // check 8
42
+ SELF_DEPENDENCY: 'self_dependency', // check 9
43
+ PHASE_ID_MISMATCH: 'phase_id_mismatch', // check 10
44
+ TASK_CYCLE_DETECTED: 'task_cycle_detected', // check 11
45
+ PHASE_DEPENDENCY_NOT_FOUND: 'phase_dependency_not_found', // check 12
46
+ PHASE_CYCLE_DETECTED: 'phase_cycle_detected', // check 13
47
+ PHASE_UNSCHEDULABLE: 'phase_unschedulable', // check 14
48
+ DURATION_WARNING: 'duration_warning', // check 15 — non-blocking warning
49
+ SPLIT_REQUIRED: 'split_required', // check 15 — blocking error (>30 min)
50
+ EMPTY_VERIFICATION_COMMANDS: 'empty_verification_commands', // check 16
51
+ EMPTY_COMMIT_SUBJECT: 'empty_commit_subject', // check 17
52
+ MISSING_GROUPING_RATIONALE: 'missing_grouping_rationale', // check 18
53
+ AC_NOT_FOUND: 'ac_not_found', // check 19
54
+ AC_WRONG_US: 'ac_wrong_us', // check 20
55
+ AC_INVALID_UC_REF: 'ac_invalid_uc_ref', // check 20
56
+ UC_MISSING_PRIORITY: 'uc_missing_priority', // check 20
57
+ MUST_AC_UNCOVERED: 'must_ac_uncovered', // check 21
58
+ INVALID_TEXT_FIELD_CHARS: 'invalid_text_field_chars', // check 22
59
+ EMPTY_PHASE: 'empty_phase', // check 23
60
+ }
61
+
62
+ // ── AC table parser (prerequisite for checks 19–21) ─────────────────────────
63
+
64
+ function parseAcTable(requirementsPath) {
65
+ if (!requirementsPath) return null
66
+
67
+ let text
68
+ try {
69
+ text = fs.readFileSync(path.resolve(requirementsPath), 'utf8')
70
+ } catch (err) {
71
+ process.stderr.write(`Error: cannot read requirements file "${requirementsPath}": ${err.message}\n`)
72
+ process.exit(2)
73
+ }
74
+
75
+ const lines = text.split(/\r?\n/)
76
+
77
+ // ── Parse UC priorities ────────────────────────────────────────────────────
78
+ // Scan for ### UC-NN: headings, then look for | Priority | <value> | in the
79
+ // following metadata table (stop at the next same-or-higher-level heading).
80
+ const ucPriorityMap = new Map()
81
+ const UC_HEADING_RE = /^###\s+(UC-\d+):/
82
+
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const headingMatch = lines[i].match(UC_HEADING_RE)
85
+ if (!headingMatch) continue
86
+ const ucId = headingMatch[1]
87
+ for (let j = i + 1; j < lines.length; j++) {
88
+ if (/^#{1,3}\s/.test(lines[j])) break
89
+ const priorityMatch = lines[j].match(/^\|\s*Priority\s*\|\s*(\S+)\s*\|/)
90
+ if (priorityMatch) {
91
+ ucPriorityMap.set(ucId, priorityMatch[1])
92
+ break
93
+ }
94
+ }
95
+ }
96
+
97
+ // ── Locate ## 7. Acceptance Criteria section ──────────────────────────────
98
+ let acSectionIdx = -1
99
+ for (let i = 0; i < lines.length; i++) {
100
+ if (/^## 7\. Acceptance Criteria\s*$/.test(lines[i])) {
101
+ acSectionIdx = i
102
+ break
103
+ }
104
+ }
105
+ if (acSectionIdx === -1) {
106
+ process.stderr.write('Error: requirements file is missing the "## 7. Acceptance Criteria" section\n')
107
+ process.exit(2)
108
+ }
109
+
110
+ // ── Find the first Markdown table (header row containing | ID |) ──────────
111
+ let tableHeaderIdx = -1
112
+ for (let i = acSectionIdx + 1; i < lines.length; i++) {
113
+ if (/^##\s/.test(lines[i])) break
114
+ if (/\|\s*ID\s*\|/.test(lines[i])) {
115
+ tableHeaderIdx = i
116
+ break
117
+ }
118
+ }
119
+ if (tableHeaderIdx === -1) {
120
+ process.stderr.write('Error: no AC table found after "## 7. Acceptance Criteria" (expected a header row containing "| ID |")\n')
121
+ process.exit(2)
122
+ }
123
+
124
+ // ── Validate column headers ────────────────────────────────────────────────
125
+ const headerCells = lines[tableHeaderIdx].split('|').slice(1, -1).map(c => c.trim())
126
+ if (headerCells.length < 3) {
127
+ process.stderr.write('Error: AC table header has fewer than 3 columns\n')
128
+ process.exit(2)
129
+ }
130
+ if (headerCells[0] !== 'ID' || headerCells[1] !== 'Criterion' || headerCells[2] !== 'Related UC') {
131
+ process.stderr.write(`Error: AC table columns must be "ID", "Criterion", "Related UC" in order; got "${headerCells[0]}", "${headerCells[1]}", "${headerCells[2]}"\n`)
132
+ process.exit(2)
133
+ }
134
+
135
+ // ── Parse data rows ────────────────────────────────────────────────────────
136
+ const PRIORITY_ORDER = ['Must', 'Should', 'Could']
137
+ const UC_REF_RE = /^UC-\d+$/
138
+ const acMap = new Map()
139
+
140
+ for (let i = tableHeaderIdx + 1; i < lines.length; i++) {
141
+ const line = lines[i]
142
+ if (/^##\s/.test(line)) break
143
+ if (!line.trim().startsWith('|')) break
144
+
145
+ // Skip separator rows: every cell is only dashes, colons, or spaces
146
+ const rawCells = line.split('|').slice(1, -1)
147
+ if (rawCells.every(c => /^[\s\-:]+$/.test(c))) continue
148
+
149
+ const cells = rawCells.map(c => c.trim())
150
+ if (cells.length < 3) continue
151
+
152
+ const acId = cells[0]
153
+ const relatedUcRaw = cells[2]
154
+
155
+ if (!acId) continue
156
+
157
+ if (acMap.has(acId)) {
158
+ process.stderr.write(`Error: duplicate AC ID "${acId}" in AC table\n`)
159
+ process.exit(2)
160
+ }
161
+
162
+ let unscoped = false
163
+ let allowedUserStories = []
164
+ let acPriority
165
+
166
+ if (relatedUcRaw.trim().toLowerCase() === 'all ucs') {
167
+ unscoped = true
168
+ allowedUserStories = []
169
+ // Derive strongest priority from all known UCs
170
+ acPriority = [...ucPriorityMap.values()].reduce((best, p) => {
171
+ const bestIdx = PRIORITY_ORDER.indexOf(best)
172
+ const pIdx = PRIORITY_ORDER.indexOf(p)
173
+ return pIdx !== -1 && pIdx < bestIdx ? p : best
174
+ }, 'Could')
175
+ } else {
176
+ const ucTokens = relatedUcRaw.split(',').map(t => t.trim())
177
+
178
+ for (const token of ucTokens) {
179
+ if (!UC_REF_RE.test(token)) {
180
+ process.stderr.write(`Error: malformed UC reference "${token}" in AC "${acId}" Related UC field\n`)
181
+ process.exit(2)
182
+ }
183
+ }
184
+
185
+ for (const ucRef of ucTokens) {
186
+ if (!ucPriorityMap.has(ucRef)) {
187
+ process.stderr.write(`Error: AC "${acId}" references UC "${ucRef}" which does not exist in the requirements\n`)
188
+ process.exit(2)
189
+ }
190
+ if (!ucPriorityMap.get(ucRef)) {
191
+ process.stderr.write(`Error: ${ucRef} has no declared priority\n`)
192
+ process.exit(2)
193
+ }
194
+ allowedUserStories.push(ucRef.replace('UC-', 'US-'))
195
+ }
196
+
197
+ acPriority = ucTokens.reduce((best, ucRef) => {
198
+ const p = ucPriorityMap.get(ucRef)
199
+ const bestIdx = PRIORITY_ORDER.indexOf(best)
200
+ const pIdx = PRIORITY_ORDER.indexOf(p)
201
+ return pIdx !== -1 && pIdx < bestIdx ? p : best
202
+ }, 'Could')
203
+ }
204
+
205
+ acMap.set(acId, { id: acId, priority: acPriority, allowedUserStories, unscoped })
206
+ }
207
+
208
+ return acMap
209
+ }
210
+
211
+ // ── Entry point ──────────────────────────────────────────────────────────────
212
+
213
+ const jsonPath = process.argv[2]
214
+ const requirementsPath = process.argv[3] // optional; used by AC checks (US-02-T10..T12)
215
+
216
+ if (!jsonPath) {
217
+ process.stderr.write('Error: missing required argument <path-to-work-breakdown.json>\n')
218
+ process.exit(2)
219
+ }
220
+
221
+ // Read and parse the work breakdown JSON
222
+ let raw
223
+ try {
224
+ raw = fs.readFileSync(path.resolve(jsonPath), 'utf8')
225
+ } catch (err) {
226
+ process.stderr.write(`Error: cannot read file "${jsonPath}": ${err.message}\n`)
227
+ process.exit(2)
228
+ }
229
+
230
+ let wb
231
+ try {
232
+ wb = JSON.parse(raw)
233
+ } catch (err) {
234
+ process.stderr.write(`Error: "${jsonPath}" is not valid JSON: ${err.message}\n`)
235
+ process.exit(2)
236
+ }
237
+
238
+ // ── Report structure ─────────────────────────────────────────────────────────
239
+
240
+ const report = {
241
+ valid: true,
242
+ schemaVersion: wb.schemaVersion,
243
+ taskCount: 0,
244
+ errors: [],
245
+ warnings: [],
246
+ durationBands: {},
247
+ domainDistribution: {},
248
+ dependencies: {},
249
+ }
250
+
251
+ // ── Check 2: Schema version ──────────────────────────────────────────────────
252
+
253
+ if (wb.schemaVersion !== SCHEMA_VERSION) {
254
+ report.errors.push({
255
+ category: ERRORS.SCHEMA_VERSION_INVALID,
256
+ severity: 'error',
257
+ taskId: null,
258
+ field: 'schemaVersion',
259
+ message: `schemaVersion must be ${SCHEMA_VERSION}, got ${JSON.stringify(wb.schemaVersion)}`,
260
+ details: { expected: SCHEMA_VERSION, actual: wb.schemaVersion },
261
+ })
262
+ }
263
+
264
+ // ── Check 3: Unique task IDs ─────────────────────────────────────────────────
265
+
266
+ const phases = Array.isArray(wb.phases) ? wb.phases : []
267
+ const seenTaskIds = new Map() // taskId → first-seen phaseId
268
+ const duplicateTaskIds = new Set()
269
+
270
+ for (const phase of phases) {
271
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
272
+ report.taskCount += tasks.length
273
+ for (const task of tasks) {
274
+ if (task.id != null) {
275
+ if (seenTaskIds.has(task.id)) {
276
+ duplicateTaskIds.add(task.id)
277
+ } else {
278
+ seenTaskIds.set(task.id, phase.id)
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ for (const taskId of duplicateTaskIds) {
285
+ report.errors.push({
286
+ category: ERRORS.UNIQUE_ID_VIOLATION,
287
+ severity: 'error',
288
+ taskId,
289
+ field: 'id',
290
+ message: `Duplicate task ID "${taskId}" appears in multiple phases`,
291
+ details: { taskId },
292
+ })
293
+ }
294
+
295
+ // ── Check 4: Required phase fields ───────────────────────────────────────────
296
+
297
+ for (const phase of phases) {
298
+ const phaseId = phase.id || '(unknown)'
299
+
300
+ for (const field of REQUIRED_PHASE_FIELDS) {
301
+ if (phase[field] == null) {
302
+ report.errors.push({
303
+ category: ERRORS.MISSING_FIELD,
304
+ severity: 'error',
305
+ phaseId,
306
+ taskId: null,
307
+ field,
308
+ message: `Phase "${phaseId}" is missing required field "${field}"`,
309
+ details: { phaseId, field },
310
+ })
311
+ } else if (field === 'tasks' && !Array.isArray(phase[field])) {
312
+ report.errors.push({
313
+ category: ERRORS.MISSING_FIELD,
314
+ severity: 'error',
315
+ phaseId,
316
+ taskId: null,
317
+ field,
318
+ message: `Phase "${phaseId}" field "tasks" must be an array`,
319
+ details: { phaseId, field },
320
+ })
321
+ }
322
+ }
323
+
324
+ // ── Check 4: Required task fields ─────────────────────────────────────────
325
+
326
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
327
+ for (const task of tasks) {
328
+ const taskId = task.id || '(unknown)'
329
+
330
+ const topLevelFields = [
331
+ 'id', 'title', 'outcome', 'domain', 'agentType', 'dependsOn',
332
+ 'acceptanceCriteria', 'outputCount', 'groupingRationale', 'commit',
333
+ ]
334
+ for (const field of topLevelFields) {
335
+ if (task[field] == null) {
336
+ report.errors.push({
337
+ category: ERRORS.MISSING_FIELD,
338
+ severity: 'error',
339
+ taskId,
340
+ field,
341
+ message: `Task "${taskId}" is missing required field "${field}"`,
342
+ details: { taskId, field },
343
+ })
344
+ }
345
+ }
346
+
347
+ // Nested: verification.commands — existence and array type only; emptiness checked later
348
+ if (task.verification == null || task.verification.commands == null) {
349
+ report.errors.push({
350
+ category: ERRORS.MISSING_FIELD,
351
+ severity: 'error',
352
+ taskId,
353
+ field: 'verification.commands',
354
+ message: `Task "${taskId}" is missing required field "verification.commands"`,
355
+ details: { taskId, field: 'verification.commands' },
356
+ })
357
+ } else if (!Array.isArray(task.verification.commands)) {
358
+ report.errors.push({
359
+ category: ERRORS.MISSING_FIELD,
360
+ severity: 'error',
361
+ taskId,
362
+ field: 'verification.commands',
363
+ message: `Task "${taskId}" field "verification.commands" must be an array`,
364
+ details: { taskId, field: 'verification.commands' },
365
+ })
366
+ }
367
+
368
+ // Nested: estimate.agentMinutes
369
+ if (task.estimate == null || task.estimate.agentMinutes == null) {
370
+ report.errors.push({
371
+ category: ERRORS.MISSING_FIELD,
372
+ severity: 'error',
373
+ taskId,
374
+ field: 'estimate.agentMinutes',
375
+ message: `Task "${taskId}" is missing required field "estimate.agentMinutes"`,
376
+ details: { taskId, field: 'estimate.agentMinutes' },
377
+ })
378
+ }
379
+
380
+ // Nested: estimate.tokens
381
+ if (task.estimate == null || task.estimate.tokens == null) {
382
+ report.errors.push({
383
+ category: ERRORS.MISSING_FIELD,
384
+ severity: 'error',
385
+ taskId,
386
+ field: 'estimate.tokens',
387
+ message: `Task "${taskId}" is missing required field "estimate.tokens"`,
388
+ details: { taskId, field: 'estimate.tokens' },
389
+ })
390
+ }
391
+
392
+ // commit.subject — commit itself is checked above as a top-level field
393
+ if (task.commit != null && task.commit.subject == null) {
394
+ report.errors.push({
395
+ category: ERRORS.MISSING_FIELD,
396
+ severity: 'error',
397
+ taskId,
398
+ field: 'commit.subject',
399
+ message: `Task "${taskId}" is missing required field "commit.subject"`,
400
+ details: { taskId, field: 'commit.subject' },
401
+ })
402
+ }
403
+ }
404
+ }
405
+
406
+ // ── Check 5: Task ID format ──────────────────────────────────────────────────
407
+
408
+ const INFRA_TASK_ID_RE = /^INFRA-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
409
+ const US_TASK_ID_RE = /^[A-Z0-9-]+-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
410
+
411
+ for (const phase of phases) {
412
+ const isInfraPhase = phase.id === 'INFRA' || phase.type === 'infra'
413
+ const re = isInfraPhase ? INFRA_TASK_ID_RE : US_TASK_ID_RE
414
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
415
+ for (const task of tasks) {
416
+ if (task.id == null) continue
417
+ if (!re.test(task.id)) {
418
+ report.errors.push({
419
+ category: ERRORS.INVALID_ID_FORMAT,
420
+ severity: 'error',
421
+ taskId: task.id,
422
+ phaseId: phase.id,
423
+ field: 'id',
424
+ message: `Task "${task.id}" in phase "${phase.id}" has an invalid ID format`,
425
+ details: { taskId: task.id, phaseId: phase.id, expectedPattern: re.toString() },
426
+ })
427
+ }
428
+ }
429
+ }
430
+
431
+ // ── Check 6: Domain whitelist ────────────────────────────────────────────────
432
+
433
+ for (const phase of phases) {
434
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
435
+ for (const task of tasks) {
436
+ if (task.domain == null) continue
437
+ if (!VALID_DOMAINS.includes(task.domain)) {
438
+ report.errors.push({
439
+ category: ERRORS.INVALID_DOMAIN,
440
+ severity: 'error',
441
+ taskId: task.id,
442
+ field: 'domain',
443
+ message: `Task "${task.id}" has invalid domain "${task.domain}"; must be one of ${VALID_DOMAINS.join(', ')}`,
444
+ details: { taskId: task.id, domain: task.domain, validDomains: VALID_DOMAINS },
445
+ })
446
+ }
447
+ }
448
+ }
449
+
450
+ // ── Check 7: AgentType whitelist ─────────────────────────────────────────────
451
+
452
+ for (const phase of phases) {
453
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
454
+ for (const task of tasks) {
455
+ if (task.agentType == null) continue
456
+ if (!VALID_AGENT_TYPES.includes(task.agentType)) {
457
+ report.errors.push({
458
+ category: ERRORS.INVALID_AGENT_TYPE,
459
+ severity: 'error',
460
+ taskId: task.id,
461
+ field: 'agentType',
462
+ message: `Task "${task.id}" has invalid agentType "${task.agentType}"; must be one of ${VALID_AGENT_TYPES.join(', ')}`,
463
+ details: { taskId: task.id, agentType: task.agentType, validAgentTypes: VALID_AGENT_TYPES },
464
+ })
465
+ }
466
+ }
467
+ }
468
+
469
+ // ── Check 8: Dependency reference existence ──────────────────────────────────
470
+
471
+ const allTaskIds = new Set(seenTaskIds.keys())
472
+
473
+ for (const phase of phases) {
474
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
475
+ for (const task of tasks) {
476
+ if (!Array.isArray(task.dependsOn)) continue
477
+ for (const refId of task.dependsOn) {
478
+ if (!allTaskIds.has(refId)) {
479
+ report.errors.push({
480
+ category: ERRORS.DEPENDENCY_NOT_FOUND,
481
+ severity: 'error',
482
+ taskId: task.id,
483
+ field: 'dependsOn',
484
+ message: `Task "${task.id}" references non-existent task ID "${refId}" in dependsOn`,
485
+ details: { taskId: task.id, missingRef: refId },
486
+ })
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ // ── Check 9: Self-dependency ─────────────────────────────────────────────────
493
+
494
+ for (const phase of phases) {
495
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
496
+ for (const task of tasks) {
497
+ if (!Array.isArray(task.dependsOn)) continue
498
+ if (task.dependsOn.includes(task.id)) {
499
+ report.errors.push({
500
+ category: ERRORS.SELF_DEPENDENCY,
501
+ severity: 'error',
502
+ taskId: task.id,
503
+ field: 'dependsOn',
504
+ message: `Task "${task.id}" lists itself in dependsOn (self-dependency)`,
505
+ details: { taskId: task.id },
506
+ })
507
+ }
508
+ }
509
+ }
510
+
511
+ // ── Check 10: Phase ID consistency ───────────────────────────────────────────
512
+
513
+ const INFRA_PREFIX = 'INFRA-TASK-'
514
+ const TASK_SEP = '-TASK-'
515
+
516
+ for (const phase of phases) {
517
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
518
+ for (const task of tasks) {
519
+ if (task.id == null) continue
520
+ if (task.id.startsWith(INFRA_PREFIX)) {
521
+ if (phase.id !== 'INFRA') {
522
+ report.errors.push({
523
+ category: ERRORS.PHASE_ID_MISMATCH,
524
+ severity: 'error',
525
+ taskId: task.id,
526
+ field: 'id',
527
+ message: `Task "${task.id}" has INFRA prefix but is in phase "${phase.id}"; INFRA tasks must be in the INFRA phase`,
528
+ details: { taskId: task.id, phaseId: phase.id, expectedPhase: 'INFRA' },
529
+ })
530
+ }
531
+ } else {
532
+ const sepIdx = task.id.indexOf(TASK_SEP)
533
+ if (sepIdx !== -1) {
534
+ const extractedPrefix = task.id.substring(0, sepIdx)
535
+ if (extractedPrefix !== phase.id) {
536
+ report.errors.push({
537
+ category: ERRORS.PHASE_ID_MISMATCH,
538
+ severity: 'error',
539
+ taskId: task.id,
540
+ field: 'id',
541
+ message: `Task "${task.id}" has prefix "${extractedPrefix}" but is in phase "${phase.id}"`,
542
+ details: { taskId: task.id, phaseId: phase.id, extractedPrefix },
543
+ })
544
+ }
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ // ── Check 11: Task cycle detection (DFS gray/black coloring) ─────────────────
551
+
552
+ // Build adjacency map; skip tasks with null IDs; skip refs not in graph and self-deps
553
+ const adjMap = new Map() // taskId → valid dependsOn[]
554
+ for (const phase of phases) {
555
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
556
+ for (const task of tasks) {
557
+ if (task.id == null) continue
558
+ const validDeps = Array.isArray(task.dependsOn)
559
+ ? task.dependsOn.filter(dep => allTaskIds.has(dep) && dep !== task.id)
560
+ : []
561
+ adjMap.set(task.id, validDeps)
562
+ }
563
+ }
564
+
565
+ // Three-color DFS: WHITE = unvisited, GRAY = in progress (on current path), BLACK = done
566
+ const WHITE = 0
567
+ const GRAY = 1
568
+ const BLACK = 2
569
+
570
+ const colors = new Map()
571
+ for (const taskId of adjMap.keys()) {
572
+ colors.set(taskId, WHITE)
573
+ }
574
+
575
+ const detectedCycles = []
576
+
577
+ function dfsCycle(nodeId, pathStack) {
578
+ colors.set(nodeId, GRAY)
579
+ pathStack.push(nodeId)
580
+
581
+ for (const dep of adjMap.get(nodeId)) {
582
+ const color = colors.get(dep)
583
+ if (color === GRAY) {
584
+ // Back edge — collect all members of this cycle from the current path stack
585
+ const cycleStart = pathStack.indexOf(dep)
586
+ detectedCycles.push(pathStack.slice(cycleStart))
587
+ } else if (color === WHITE) {
588
+ dfsCycle(dep, pathStack)
589
+ }
590
+ // BLACK: node fully processed — no cycle through it
591
+ }
592
+
593
+ pathStack.pop()
594
+ colors.set(nodeId, BLACK)
595
+ }
596
+
597
+ for (const taskId of adjMap.keys()) {
598
+ if (colors.get(taskId) === WHITE) {
599
+ dfsCycle(taskId, [])
600
+ }
601
+ }
602
+
603
+ // Populate dependencies with task-level graph data
604
+ report.dependencies.taskGraph = {}
605
+ for (const [taskId, deps] of adjMap) {
606
+ report.dependencies.taskGraph[taskId] = deps
607
+ }
608
+ report.dependencies.taskCycles = detectedCycles
609
+
610
+ // Report each detected cycle as one error entry
611
+ for (const cycleMembers of detectedCycles) {
612
+ report.errors.push({
613
+ category: ERRORS.TASK_CYCLE_DETECTED,
614
+ severity: 'error',
615
+ taskId: null,
616
+ field: null,
617
+ message: `Dependency cycle detected among tasks: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
618
+ details: { cycleMembers },
619
+ })
620
+ }
621
+
622
+ // ── Check 12 & 13: Phase dependency projection and cycle detection ────────────
623
+
624
+ // seenTaskIds already maps taskId → ownerPhaseId; alias for clarity
625
+ const taskToPhase = seenTaskIds // Map<taskId, phaseId>
626
+
627
+ // All known phase IDs for existence checks (Check 12)
628
+ const phaseIdSet = new Set(phases.map(p => p.id).filter(id => id != null))
629
+
630
+ // Step 2: Compute phase-level dependsOn projection (same algorithm as wb-render.js)
631
+ // For each phase: union all task dependsOn IDs → map to owner phase → remove self → deduplicate
632
+ const phaseDeps = new Map() // Map<phaseId, Set<phaseId>>
633
+
634
+ for (const phase of phases) {
635
+ if (phase.id == null) continue
636
+ const deps = new Set()
637
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
638
+ for (const task of tasks) {
639
+ if (!Array.isArray(task.dependsOn)) continue
640
+ for (const refId of task.dependsOn) {
641
+ const ownerPhase = taskToPhase.get(refId)
642
+ if (ownerPhase == null) continue // unresolved task dep — already reported in check 8
643
+ if (ownerPhase === phase.id) continue // remove self-loops (intra-phase)
644
+ deps.add(ownerPhase)
645
+ }
646
+ }
647
+ phaseDeps.set(phase.id, deps)
648
+ }
649
+
650
+ // Check 12: Phase dependency existence (defensive — should not fire if check 8 passed)
651
+ for (const [phaseId, deps] of phaseDeps) {
652
+ for (const depPhaseId of deps) {
653
+ if (!phaseIdSet.has(depPhaseId)) {
654
+ report.errors.push({
655
+ category: ERRORS.PHASE_DEPENDENCY_NOT_FOUND,
656
+ severity: 'error',
657
+ taskId: null,
658
+ field: 'dependsOn',
659
+ message: `Phase "${phaseId}" has a projected dependency on phase "${depPhaseId}" which does not exist`,
660
+ details: { phaseId, missingPhase: depPhaseId },
661
+ })
662
+ }
663
+ }
664
+ }
665
+
666
+ // Check 13: Phase cycle detection (same DFS gray/black algorithm as check 11)
667
+ const phaseColors = new Map()
668
+ for (const phaseId of phaseDeps.keys()) {
669
+ phaseColors.set(phaseId, WHITE)
670
+ }
671
+
672
+ const detectedPhaseCycles = []
673
+
674
+ function dfsPhaseCycle(nodeId, pathStack) {
675
+ phaseColors.set(nodeId, GRAY)
676
+ pathStack.push(nodeId)
677
+
678
+ for (const dep of phaseDeps.get(nodeId)) {
679
+ const color = phaseColors.get(dep)
680
+ if (color === GRAY) {
681
+ const cycleStart = pathStack.indexOf(dep)
682
+ detectedPhaseCycles.push(pathStack.slice(cycleStart))
683
+ } else if (color === WHITE) {
684
+ dfsPhaseCycle(dep, pathStack)
685
+ }
686
+ }
687
+
688
+ pathStack.pop()
689
+ phaseColors.set(nodeId, BLACK)
690
+ }
691
+
692
+ for (const phaseId of phaseDeps.keys()) {
693
+ if (phaseColors.get(phaseId) === WHITE) {
694
+ dfsPhaseCycle(phaseId, [])
695
+ }
696
+ }
697
+
698
+ // Store phase graph in report as plain object for JSON serialization
699
+ report.dependencies.phaseGraph = {}
700
+ for (const [phaseId, deps] of phaseDeps) {
701
+ report.dependencies.phaseGraph[phaseId] = [...deps].sort()
702
+ }
703
+ report.dependencies.phaseCycles = detectedPhaseCycles
704
+
705
+ // Report each detected phase cycle as one error entry
706
+ for (const cycleMembers of detectedPhaseCycles) {
707
+ report.errors.push({
708
+ category: ERRORS.PHASE_CYCLE_DETECTED,
709
+ severity: 'error',
710
+ taskId: null,
711
+ field: null,
712
+ message: `Phase dependency cycle detected: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
713
+ details: { cycleMembers },
714
+ })
715
+ }
716
+
717
+ // ── Check 14: Phase schedulability (buildWaves) ──────────────────────────────
718
+
719
+ const buildWavesDone = new Set()
720
+ const phaseWaves = []
721
+ let phaseRemaining = phases.map(p => p.id).filter(id => id != null)
722
+ while (phaseRemaining.length > 0) {
723
+ const ready = phaseRemaining.filter(id => [...(phaseDeps.get(id) || [])].every(d => buildWavesDone.has(d)))
724
+ if (ready.length === 0) break // deadlock — unschedulable phases remain
725
+ ready.forEach(id => buildWavesDone.add(id))
726
+ phaseWaves.push(ready)
727
+ phaseRemaining = phaseRemaining.filter(id => !buildWavesDone.has(id))
728
+ }
729
+
730
+ report.dependencies.phaseWaves = phaseWaves
731
+ report.dependencies.phaseUnschedulable = phaseRemaining
732
+
733
+ for (const phaseId of phaseRemaining) {
734
+ report.errors.push({
735
+ category: ERRORS.PHASE_UNSCHEDULABLE,
736
+ severity: 'error',
737
+ taskId: null,
738
+ phaseId,
739
+ field: null,
740
+ message: `Phase "${phaseId}" cannot be scheduled: all predecessor phases are unschedulable or form a deadlock`,
741
+ details: { phaseId },
742
+ })
743
+ }
744
+
745
+ // ── Check 15: Duration policy ────────────────────────────────────────────────
746
+
747
+ const durationBands = { target: 0, above_target: 0, warning: 0, split_required: 0 }
748
+
749
+ for (const phase of phases) {
750
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
751
+ for (const task of tasks) {
752
+ const minutes = task.estimate?.agentMinutes
753
+ if (minutes == null) continue // already caught by check 4; skip here
754
+
755
+ let band
756
+ if (minutes <= TARGET_MAX) {
757
+ band = 'target'
758
+ } else if (minutes <= ABOVE_MAX) {
759
+ band = 'above_target'
760
+ } else if (minutes <= WARNING_MAX) {
761
+ band = 'warning'
762
+ report.warnings.push({
763
+ category: ERRORS.DURATION_WARNING,
764
+ severity: 'warning',
765
+ taskId: task.id,
766
+ field: 'estimate.agentMinutes',
767
+ message: `Task "${task.id}" estimate ${minutes}min exceeds target (${TARGET_MAX}min); consider splitting`,
768
+ details: { taskId: task.id, agentMinutes: minutes, targetMax: TARGET_MAX, warningMax: WARNING_MAX },
769
+ })
770
+ } else {
771
+ band = 'split_required'
772
+ report.errors.push({
773
+ category: ERRORS.SPLIT_REQUIRED,
774
+ severity: 'error',
775
+ taskId: task.id,
776
+ field: 'estimate.agentMinutes',
777
+ message: `Task "${task.id}" estimate ${minutes}min exceeds maximum (${WARNING_MAX}min); must be split`,
778
+ details: { taskId: task.id, agentMinutes: minutes, warningMax: WARNING_MAX },
779
+ })
780
+ }
781
+
782
+ durationBands[band]++
783
+ }
784
+ }
785
+
786
+ report.durationBands = durationBands
787
+
788
+ // ── Check 16 / 17 / 18: Content-quality checks ───────────────────────────────
789
+ // Check 16: Empty verification.commands array
790
+ // Check 17: Empty or whitespace-only commit.subject
791
+ // Check 18: Empty or whitespace-only groupingRationale
792
+
793
+ for (const phase of phases) {
794
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
795
+ for (const task of tasks) {
796
+ // Check 16: Empty verification commands
797
+ if (
798
+ task.verification != null &&
799
+ task.verification.commands != null &&
800
+ Array.isArray(task.verification.commands) &&
801
+ task.verification.commands.length === 0
802
+ ) {
803
+ report.errors.push({
804
+ category: ERRORS.EMPTY_VERIFICATION_COMMANDS,
805
+ severity: 'error',
806
+ taskId: task.id,
807
+ field: 'verification.commands',
808
+ message: `Task "${task.id}" has an empty verification.commands array; at least one command is required`,
809
+ details: { taskId: task.id },
810
+ })
811
+ }
812
+
813
+ // Check 17: Empty commit subject
814
+ if (
815
+ task.commit != null &&
816
+ task.commit.subject != null &&
817
+ typeof task.commit.subject === 'string' &&
818
+ task.commit.subject.trim() === ''
819
+ ) {
820
+ report.errors.push({
821
+ category: ERRORS.EMPTY_COMMIT_SUBJECT,
822
+ severity: 'error',
823
+ taskId: task.id,
824
+ field: 'commit.subject',
825
+ message: `Task "${task.id}" has an empty commit.subject; a non-empty commit subject is required`,
826
+ details: { taskId: task.id },
827
+ })
828
+ }
829
+
830
+ // Check 18: Missing grouping rationale
831
+ if (
832
+ task.groupingRationale != null &&
833
+ typeof task.groupingRationale === 'string' &&
834
+ task.groupingRationale.trim() === ''
835
+ ) {
836
+ report.errors.push({
837
+ category: ERRORS.MISSING_GROUPING_RATIONALE,
838
+ severity: 'error',
839
+ taskId: task.id,
840
+ field: 'groupingRationale',
841
+ message: `Task "${task.id}" has an empty groupingRationale; a non-empty rationale is required`,
842
+ details: { taskId: task.id },
843
+ })
844
+ }
845
+ }
846
+ }
847
+
848
+ // ── Domain distribution ───────────────────────────────────────────────────────
849
+
850
+ const domainDist = { BE: 0, FE: 0, DB: 0, DevOps: 0, INFRA: 0, TEST: 0 }
851
+ for (const phase of phases) {
852
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
853
+ for (const task of tasks) {
854
+ if (task.domain != null && domainDist[task.domain] !== undefined) {
855
+ domainDist[task.domain]++
856
+ }
857
+ }
858
+ }
859
+ report.domainDistribution = domainDist
860
+
861
+ // ── AC table parsing (prerequisite for checks 19–21) ─────────────────────────
862
+
863
+ const acMap = parseAcTable(requirementsPath)
864
+
865
+ // ── Checks 19 & 20: AC existence and scope validation ────────────────────────
866
+
867
+ if (acMap !== null) {
868
+ for (const phase of phases) {
869
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
870
+ for (const task of tasks) {
871
+ if (!Array.isArray(task.acceptanceCriteria)) continue
872
+ for (const acId of task.acceptanceCriteria) {
873
+ const acEntry = acMap.get(acId)
874
+
875
+ // ── Check 19: AC existence ───────────────────────────────────────────
876
+ if (acEntry === undefined) {
877
+ report.errors.push({
878
+ category: ERRORS.AC_NOT_FOUND,
879
+ severity: 'error',
880
+ taskId: task.id,
881
+ field: 'acceptanceCriteria',
882
+ message: `Task "${task.id}" references AC "${acId}" which does not exist in the Requirements AC table`,
883
+ details: { taskId: task.id, acId },
884
+ })
885
+ continue
886
+ }
887
+
888
+ // ── Check 20: AC scope validation ────────────────────────────────────
889
+ if (acEntry.unscoped === true) continue
890
+ const phaseId = seenTaskIds.get(task.id)
891
+ if (!acEntry.allowedUserStories.includes(phaseId)) {
892
+ report.errors.push({
893
+ category: ERRORS.AC_WRONG_US,
894
+ severity: 'error',
895
+ taskId: task.id,
896
+ field: 'acceptanceCriteria',
897
+ message: `Task "${task.id}" (in phase "${phaseId}") references AC "${acId}" which is scoped to ${JSON.stringify(acEntry.allowedUserStories)}, not this phase`,
898
+ details: { taskId: task.id, phaseId, acId, allowedUserStories: acEntry.allowedUserStories },
899
+ })
900
+ }
901
+ }
902
+ }
903
+ }
904
+ }
905
+
906
+ // ── Check 21: Must AC coverage ───────────────────────────────────────────────
907
+
908
+ if (acMap !== null) {
909
+ const coveredAcIds = new Set()
910
+ for (const phase of phases) {
911
+ const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
912
+ for (const task of tasks) {
913
+ if (!Array.isArray(task.acceptanceCriteria)) continue
914
+ for (const acId of task.acceptanceCriteria) {
915
+ coveredAcIds.add(acId)
916
+ }
917
+ }
918
+ }
919
+
920
+ for (const [acId, acEntry] of acMap) {
921
+ if (acEntry.priority === 'Must' && !coveredAcIds.has(acId)) {
922
+ report.errors.push({
923
+ category: ERRORS.MUST_AC_UNCOVERED,
924
+ severity: 'error',
925
+ taskId: null,
926
+ field: null,
927
+ message: `AC "${acId}" has Must priority but is not covered by any task's acceptanceCriteria`,
928
+ details: { acId, priority: 'Must' },
929
+ })
930
+ }
931
+ }
932
+ }
933
+
934
+ // ── Check 22: Text field character validation ─────────────────────────────────
935
+
936
+ {
937
+ const INVALID_CHARS_RE = /[|\r\n]/
938
+
939
+ for (const phase of phases) {
940
+ if (typeof phase.title === 'string' && INVALID_CHARS_RE.test(phase.title)) {
941
+ report.errors.push({
942
+ category: ERRORS.INVALID_TEXT_FIELD_CHARS,
943
+ severity: 'error',
944
+ taskId: null,
945
+ phaseId: phase.id,
946
+ field: 'title',
947
+ message: `Phase "${phase.id}" field "title" contains invalid characters (pipe, CR, or LF)`,
948
+ details: { phaseId: phase.id, field: 'title' },
949
+ })
950
+ }
951
+
952
+ if (!Array.isArray(phase.tasks)) continue
953
+
954
+ for (const task of phase.tasks) {
955
+ if (typeof task.title === 'string' && INVALID_CHARS_RE.test(task.title)) {
956
+ report.errors.push({
957
+ category: ERRORS.INVALID_TEXT_FIELD_CHARS,
958
+ severity: 'error',
959
+ taskId: task.id,
960
+ field: 'title',
961
+ message: `Task "${task.id}" field "title" contains invalid characters (pipe, CR, or LF)`,
962
+ details: { taskId: task.id, field: 'title' },
963
+ })
964
+ }
965
+
966
+ if (task.commit && typeof task.commit.subject === 'string' && INVALID_CHARS_RE.test(task.commit.subject)) {
967
+ report.errors.push({
968
+ category: ERRORS.INVALID_TEXT_FIELD_CHARS,
969
+ severity: 'error',
970
+ taskId: task.id,
971
+ field: 'commit.subject',
972
+ message: `Task "${task.id}" field "commit.subject" contains invalid characters (pipe, CR, or LF)`,
973
+ details: { taskId: task.id, field: 'commit.subject' },
974
+ })
975
+ }
976
+ }
977
+ }
978
+ }
979
+
980
+ // ── Check 23: Empty phase detection ──────────────────────────────────────────
981
+
982
+ {
983
+ for (const phase of phases) {
984
+ if (Array.isArray(phase.tasks) && phase.tasks.length === 0) {
985
+ report.errors.push({
986
+ category: ERRORS.EMPTY_PHASE,
987
+ severity: 'error',
988
+ taskId: null,
989
+ phaseId: phase.id,
990
+ field: 'tasks',
991
+ message: `Phase "${phase.id}" has no tasks; each phase must contain at least one task`,
992
+ details: { phaseId: phase.id },
993
+ })
994
+ }
995
+ }
996
+ }
997
+
998
+ // ── Exit code routing ────────────────────────────────────────────────────────
999
+
1000
+ if (report.errors.length > 0) {
1001
+ report.valid = false
1002
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
1003
+ process.exit(1)
1004
+ }
1005
+
1006
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
1007
+ process.exit(0)