@dtlabs/ai-toolkit 0.17.4 → 0.18.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 (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/lib/workflow-control.js +65 -13
  4. package/package.json +1 -1
  5. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  6. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  7. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  8. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  9. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  10. package/src/claude/agents/gaia-generate-work-breakdown.md +94 -100
  11. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  12. package/src/claude/commands/assemble-docs.md +16 -0
  13. package/src/claude/commands/feature-status.md +9 -4
  14. package/src/claude/scripts/ac-task-matrix.js +87 -0
  15. package/src/claude/scripts/assemble-docs.js +66 -0
  16. package/src/claude/scripts/req-assembler.js +127 -0
  17. package/src/claude/scripts/req-graph-checks.js +71 -0
  18. package/src/claude/scripts/req-skeleton-check.js +64 -0
  19. package/src/claude/scripts/tech-spec-assembler.js +188 -0
  20. package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
  21. package/src/claude/scripts/templates/gaia-component-template.md +64 -0
  22. package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
  23. package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
  24. package/src/claude/scripts/templates/gaia-task-template.md +92 -0
  25. package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
  26. package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
  27. package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
  28. package/src/claude/scripts/wb-graph-checks.js +754 -0
  29. package/src/claude/scripts/wb-render.js +6 -11
  30. package/src/claude/scripts/wb-skeleton-check.js +99 -0
  31. package/src/claude/scripts/wb-validate.js +49 -655
  32. package/src/claude/scripts/wbs-assembler.js +368 -0
  33. package/src/claude/skills/implement-feature/SKILL.md +12 -6
  34. package/src/claude/workflows/pm-phase1.js +342 -16
  35. package/src/claude/workflows/pm-phase2.js +277 -14
@@ -1,7 +1,8 @@
1
1
  'use strict'
2
2
 
3
- const fs = require('fs')
4
3
  const path = require('path')
4
+ const graphChecks = require('./wb-graph-checks')
5
+ const { loadWorkBreakdown } = require('./wbs-assembler')
5
6
 
6
7
  // Schema v2 constants — used by validation checks below
7
8
 
@@ -15,228 +16,17 @@ const REQUIRED_PHASE_FIELDS = [
15
16
  'tasks',
16
17
  ]
17
18
 
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
19
+ // Duration policy thresholds (agentMinutes) — used by check 15, which stays inline here
20
+ // (it needs per-task estimate detail, not just the skeleton shape wb-graph-checks.js works with)
29
21
  const TARGET_MAX = 15 // tasks ≤15 min agent estimate: target band
30
22
  const ABOVE_MAX = 20 // tasks 16–20 min: above target (no flag)
31
23
  const WARNING_MAX = 30 // tasks 21–30 min: warning band (non-blocking); >30 = splitRequired (blocking)
32
24
 
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
- // ── Markdown table row splitter ─────────────────────────────────────────────
63
- // Splits a Markdown table row into cells on unescaped pipe separators only.
64
- // A backslash-escaped pipe (\|) is treated as a literal pipe *inside* the cell
65
- // rather than a column separator and is unescaped in the returned cell, so a
66
- // criterion such as `ai-toolkit ledger open\|close\|fail\|skip` stays in a
67
- // single cell and does not shift the "Related UC" column.
68
- // Returns the raw split including the leading/trailing empty cells produced by
69
- // the row's outer pipes, matching the previous `line.split('|')` contract so
70
- // callers can keep using `.slice(1, -1)`.
71
- function splitTableRow(line) {
72
- const cells = []
73
- let current = ''
74
- for (let i = 0; i < line.length; i++) {
75
- const ch = line[i]
76
- if (ch === '\\' && line[i + 1] === '|') {
77
- current += '|' // unescape: keep the literal pipe inside this cell
78
- i++ // consume the escaped '|'
79
- continue
80
- }
81
- if (ch === '|') {
82
- cells.push(current)
83
- current = ''
84
- continue
85
- }
86
- current += ch
87
- }
88
- cells.push(current)
89
- return cells
90
- }
91
-
92
- // ── AC table parser (prerequisite for checks 19–21) ─────────────────────────
93
-
94
- function parseAcTable(requirementsPath) {
95
- if (!requirementsPath) return null
96
-
97
- let text
98
- try {
99
- text = fs.readFileSync(path.resolve(requirementsPath), 'utf8')
100
- } catch (err) {
101
- process.stderr.write(`Error: cannot read requirements file "${requirementsPath}": ${err.message}\n`)
102
- process.exit(2)
103
- }
104
-
105
- const lines = text.split(/\r?\n/)
106
-
107
- // ── Parse UC priorities ────────────────────────────────────────────────────
108
- // Scan for ### UC-NN: headings, then look for | Priority | <value> | in the
109
- // following metadata table (stop at the next same-or-higher-level heading).
110
- const ucPriorityMap = new Map()
111
- const UC_HEADING_RE = /^###\s+(UC-\d+):/
112
-
113
- for (let i = 0; i < lines.length; i++) {
114
- const headingMatch = lines[i].match(UC_HEADING_RE)
115
- if (!headingMatch) continue
116
- const ucId = headingMatch[1]
117
- for (let j = i + 1; j < lines.length; j++) {
118
- if (/^#{1,3}\s/.test(lines[j])) break
119
- const priorityMatch = lines[j].match(/^\|\s*Priority\s*\|\s*(\S+)\s*\|/)
120
- if (priorityMatch) {
121
- ucPriorityMap.set(ucId, priorityMatch[1])
122
- break
123
- }
124
- }
125
- }
126
-
127
- // ── Locate ## 7. Acceptance Criteria section ──────────────────────────────
128
- let acSectionIdx = -1
129
- for (let i = 0; i < lines.length; i++) {
130
- if (/^## 7\. Acceptance Criteria\s*$/.test(lines[i])) {
131
- acSectionIdx = i
132
- break
133
- }
134
- }
135
- if (acSectionIdx === -1) {
136
- process.stderr.write('Error: requirements file is missing the "## 7. Acceptance Criteria" section\n')
137
- process.exit(2)
138
- }
139
-
140
- // ── Find the first Markdown table (header row containing | ID |) ──────────
141
- let tableHeaderIdx = -1
142
- for (let i = acSectionIdx + 1; i < lines.length; i++) {
143
- if (/^##\s/.test(lines[i])) break
144
- if (/\|\s*ID\s*\|/.test(lines[i])) {
145
- tableHeaderIdx = i
146
- break
147
- }
148
- }
149
- if (tableHeaderIdx === -1) {
150
- process.stderr.write('Error: no AC table found after "## 7. Acceptance Criteria" (expected a header row containing "| ID |")\n')
151
- process.exit(2)
152
- }
153
-
154
- // ── Validate column headers ────────────────────────────────────────────────
155
- const headerCells = splitTableRow(lines[tableHeaderIdx]).slice(1, -1).map(c => c.trim())
156
- if (headerCells.length < 3) {
157
- process.stderr.write('Error: AC table header has fewer than 3 columns\n')
158
- process.exit(2)
159
- }
160
- if (headerCells[0] !== 'ID' || headerCells[1] !== 'Criterion' || headerCells[2] !== 'Related UC') {
161
- process.stderr.write(`Error: AC table columns must be "ID", "Criterion", "Related UC" in order; got "${headerCells[0]}", "${headerCells[1]}", "${headerCells[2]}"\n`)
162
- process.exit(2)
163
- }
164
-
165
- // ── Parse data rows ────────────────────────────────────────────────────────
166
- const PRIORITY_ORDER = ['Must', 'Should', 'Could']
167
- const UC_REF_RE = /^UC-\d+$/
168
- const acMap = new Map()
169
-
170
- for (let i = tableHeaderIdx + 1; i < lines.length; i++) {
171
- const line = lines[i]
172
- if (/^##\s/.test(line)) break
173
- if (!line.trim().startsWith('|')) break
174
-
175
- // Skip separator rows: every cell is only dashes, colons, or spaces
176
- const rawCells = splitTableRow(line).slice(1, -1)
177
- if (rawCells.every(c => /^[\s\-:]+$/.test(c))) continue
178
-
179
- const cells = rawCells.map(c => c.trim())
180
- if (cells.length < 3) continue
181
-
182
- const acId = cells[0]
183
- const relatedUcRaw = cells[2]
184
-
185
- if (!acId) continue
186
-
187
- if (acMap.has(acId)) {
188
- process.stderr.write(`Error: duplicate AC ID "${acId}" in AC table\n`)
189
- process.exit(2)
190
- }
191
-
192
- let unscoped = false
193
- let allowedUserStories = []
194
- let acPriority
195
-
196
- if (relatedUcRaw.trim().toLowerCase() === 'all ucs') {
197
- unscoped = true
198
- allowedUserStories = []
199
- // Derive strongest priority from all known UCs
200
- acPriority = [...ucPriorityMap.values()].reduce((best, p) => {
201
- const bestIdx = PRIORITY_ORDER.indexOf(best)
202
- const pIdx = PRIORITY_ORDER.indexOf(p)
203
- return pIdx !== -1 && pIdx < bestIdx ? p : best
204
- }, 'Could')
205
- } else {
206
- const ucTokens = relatedUcRaw.split(',').map(t => t.trim())
207
-
208
- for (const token of ucTokens) {
209
- if (!UC_REF_RE.test(token)) {
210
- process.stderr.write(`Error: malformed UC reference "${token}" in AC "${acId}" Related UC field\n`)
211
- process.exit(2)
212
- }
213
- }
214
-
215
- for (const ucRef of ucTokens) {
216
- if (!ucPriorityMap.has(ucRef)) {
217
- process.stderr.write(`Error: AC "${acId}" references UC "${ucRef}" which does not exist in the requirements\n`)
218
- process.exit(2)
219
- }
220
- if (!ucPriorityMap.get(ucRef)) {
221
- process.stderr.write(`Error: ${ucRef} has no declared priority\n`)
222
- process.exit(2)
223
- }
224
- allowedUserStories.push(ucRef.replace('UC-', 'US-'))
225
- }
226
-
227
- acPriority = ucTokens.reduce((best, ucRef) => {
228
- const p = ucPriorityMap.get(ucRef)
229
- const bestIdx = PRIORITY_ORDER.indexOf(best)
230
- const pIdx = PRIORITY_ORDER.indexOf(p)
231
- return pIdx !== -1 && pIdx < bestIdx ? p : best
232
- }, 'Could')
233
- }
234
-
235
- acMap.set(acId, { id: acId, priority: acPriority, allowedUserStories, unscoped })
236
- }
237
-
238
- return acMap
239
- }
25
+ // Checks 3, 5-14, 19-21 are extracted into wb-graph-checks.js (FTR-020 OQ-02) and reused
26
+ // here via the graphChecks import above; this module keeps only checks 4, 15-18, 22, 23,
27
+ // which need per-task detail (required field presence including verification.commands,
28
+ // duration bands, content-quality checks) that the skeleton-only shape doesn't carry.
29
+ const { ERRORS } = graphChecks
240
30
 
241
31
  // ── Entry point ──────────────────────────────────────────────────────────────
242
32
 
@@ -248,20 +38,14 @@ if (!jsonPath) {
248
38
  process.exit(2)
249
39
  }
250
40
 
251
- // Read and parse the work breakdown JSON
252
- let raw
253
- try {
254
- raw = fs.readFileSync(path.resolve(jsonPath), 'utf8')
255
- } catch (err) {
256
- process.stderr.write(`Error: cannot read file "${jsonPath}": ${err.message}\n`)
257
- process.exit(2)
258
- }
259
-
41
+ // Read and assemble the work breakdown — accepts either a single legacy JSON file
42
+ // (schema v2) or a {PREFIX}-wbs/ split directory (schema v3), returning the same
43
+ // in-memory shape either way (FTR-020).
260
44
  let wb
261
45
  try {
262
- wb = JSON.parse(raw)
46
+ wb = loadWorkBreakdown(path.resolve(jsonPath))
263
47
  } catch (err) {
264
- process.stderr.write(`Error: "${jsonPath}" is not valid JSON: ${err.message}\n`)
48
+ process.stderr.write(`Error: ${err.message}\n`)
265
49
  process.exit(2)
266
50
  }
267
51
 
@@ -291,36 +75,16 @@ if (wb.schemaVersion !== SCHEMA_VERSION) {
291
75
  })
292
76
  }
293
77
 
294
- // ── Check 3: Unique task IDs ─────────────────────────────────────────────────
78
+ // ── Check 3: Unique task IDs (extracted — wb-graph-checks.js) ───────────────
295
79
 
296
80
  const phases = Array.isArray(wb.phases) ? wb.phases : []
297
- const seenTaskIds = new Map() // taskId → first-seen phaseId
298
- const duplicateTaskIds = new Set()
299
-
300
81
  for (const phase of phases) {
301
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
302
- report.taskCount += tasks.length
303
- for (const task of tasks) {
304
- if (task.id != null) {
305
- if (seenTaskIds.has(task.id)) {
306
- duplicateTaskIds.add(task.id)
307
- } else {
308
- seenTaskIds.set(task.id, phase.id)
309
- }
310
- }
311
- }
82
+ report.taskCount += Array.isArray(phase.tasks) ? phase.tasks.length : 0
312
83
  }
313
84
 
314
- for (const taskId of duplicateTaskIds) {
315
- report.errors.push({
316
- category: ERRORS.UNIQUE_ID_VIOLATION,
317
- severity: 'error',
318
- taskId,
319
- field: 'id',
320
- message: `Duplicate task ID "${taskId}" appears in multiple phases`,
321
- details: { taskId },
322
- })
323
- }
85
+ const check3 = graphChecks.checkUniqueTaskIds({ phases })
86
+ report.errors.push(...check3.errors)
87
+ const seenTaskIds = check3.seenTaskIds // taskId → first-seen phaseId
324
88
 
325
89
  // ── Check 4: Required phase fields ───────────────────────────────────────────
326
90
 
@@ -447,344 +211,42 @@ for (const phase of phases) {
447
211
  }
448
212
  }
449
213
 
450
- // ── Check 5: Task ID format ──────────────────────────────────────────────────
451
-
452
- const INFRA_TASK_ID_RE = /^INFRA-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
453
- const US_TASK_ID_RE = /^[A-Z0-9-]+-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
454
-
455
- for (const phase of phases) {
456
- const isInfraPhase = phase.id === 'INFRA' || phase.type === 'infra'
457
- const re = isInfraPhase ? INFRA_TASK_ID_RE : US_TASK_ID_RE
458
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
459
- for (const task of tasks) {
460
- if (task.id == null) continue
461
- if (!re.test(task.id)) {
462
- report.errors.push({
463
- category: ERRORS.INVALID_ID_FORMAT,
464
- severity: 'error',
465
- taskId: task.id,
466
- phaseId: phase.id,
467
- field: 'id',
468
- message: `Task "${task.id}" in phase "${phase.id}" has an invalid ID format`,
469
- details: { taskId: task.id, phaseId: phase.id, expectedPattern: re.toString() },
470
- })
471
- }
472
- }
473
- }
474
-
475
- // ── Check 6: Domain whitelist ────────────────────────────────────────────────
214
+ // ── Check 5: Task ID format (extracted — wb-graph-checks.js) ────────────────
215
+ report.errors.push(...graphChecks.checkTaskIdFormat({ phases }).errors)
476
216
 
477
- for (const phase of phases) {
478
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
479
- for (const task of tasks) {
480
- if (task.domain == null) continue
481
- if (!VALID_DOMAINS.includes(task.domain)) {
482
- report.errors.push({
483
- category: ERRORS.INVALID_DOMAIN,
484
- severity: 'error',
485
- taskId: task.id,
486
- field: 'domain',
487
- message: `Task "${task.id}" has invalid domain "${task.domain}"; must be one of ${VALID_DOMAINS.join(', ')}`,
488
- details: { taskId: task.id, domain: task.domain, validDomains: VALID_DOMAINS },
489
- })
490
- }
491
- }
492
- }
217
+ // ── Check 6: Domain whitelist (extracted — wb-graph-checks.js) ──────────────
218
+ report.errors.push(...graphChecks.checkDomainWhitelist({ phases }).errors)
493
219
 
494
- // ── Check 7: AgentType whitelist ─────────────────────────────────────────────
495
-
496
- for (const phase of phases) {
497
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
498
- for (const task of tasks) {
499
- if (task.agentType == null) continue
500
- if (!VALID_AGENT_TYPES.includes(task.agentType)) {
501
- report.errors.push({
502
- category: ERRORS.INVALID_AGENT_TYPE,
503
- severity: 'error',
504
- taskId: task.id,
505
- field: 'agentType',
506
- message: `Task "${task.id}" has invalid agentType "${task.agentType}"; must be one of ${VALID_AGENT_TYPES.join(', ')}`,
507
- details: { taskId: task.id, agentType: task.agentType, validAgentTypes: VALID_AGENT_TYPES },
508
- })
509
- }
510
- }
511
- }
512
-
513
- // ── Check 8: Dependency reference existence ──────────────────────────────────
220
+ // ── Check 7: AgentType whitelist (extracted — wb-graph-checks.js) ───────────
221
+ report.errors.push(...graphChecks.checkAgentTypeWhitelist({ phases }).errors)
514
222
 
223
+ // ── Check 8: Dependency reference existence (extracted) ─────────────────────
515
224
  const allTaskIds = new Set(seenTaskIds.keys())
225
+ report.errors.push(...graphChecks.checkDependencyExistence({ phases }, allTaskIds).errors)
516
226
 
517
- for (const phase of phases) {
518
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
519
- for (const task of tasks) {
520
- if (!Array.isArray(task.dependsOn)) continue
521
- for (const refId of task.dependsOn) {
522
- if (!allTaskIds.has(refId)) {
523
- report.errors.push({
524
- category: ERRORS.DEPENDENCY_NOT_FOUND,
525
- severity: 'error',
526
- taskId: task.id,
527
- field: 'dependsOn',
528
- message: `Task "${task.id}" references non-existent task ID "${refId}" in dependsOn`,
529
- details: { taskId: task.id, missingRef: refId },
530
- })
531
- }
532
- }
533
- }
534
- }
535
-
536
- // ── Check 9: Self-dependency ─────────────────────────────────────────────────
537
-
538
- for (const phase of phases) {
539
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
540
- for (const task of tasks) {
541
- if (!Array.isArray(task.dependsOn)) continue
542
- if (task.dependsOn.includes(task.id)) {
543
- report.errors.push({
544
- category: ERRORS.SELF_DEPENDENCY,
545
- severity: 'error',
546
- taskId: task.id,
547
- field: 'dependsOn',
548
- message: `Task "${task.id}" lists itself in dependsOn (self-dependency)`,
549
- details: { taskId: task.id },
550
- })
551
- }
552
- }
553
- }
554
-
555
- // ── Check 10: Phase ID consistency ───────────────────────────────────────────
556
-
557
- const INFRA_PREFIX = 'INFRA-TASK-'
558
- const TASK_SEP = '-TASK-'
559
-
560
- for (const phase of phases) {
561
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
562
- for (const task of tasks) {
563
- if (task.id == null) continue
564
- if (task.id.startsWith(INFRA_PREFIX)) {
565
- if (phase.id !== 'INFRA') {
566
- report.errors.push({
567
- category: ERRORS.PHASE_ID_MISMATCH,
568
- severity: 'error',
569
- taskId: task.id,
570
- field: 'id',
571
- message: `Task "${task.id}" has INFRA prefix but is in phase "${phase.id}"; INFRA tasks must be in the INFRA phase`,
572
- details: { taskId: task.id, phaseId: phase.id, expectedPhase: 'INFRA' },
573
- })
574
- }
575
- } else {
576
- const sepIdx = task.id.indexOf(TASK_SEP)
577
- if (sepIdx !== -1) {
578
- const extractedPrefix = task.id.substring(0, sepIdx)
579
- if (extractedPrefix !== phase.id) {
580
- report.errors.push({
581
- category: ERRORS.PHASE_ID_MISMATCH,
582
- severity: 'error',
583
- taskId: task.id,
584
- field: 'id',
585
- message: `Task "${task.id}" has prefix "${extractedPrefix}" but is in phase "${phase.id}"`,
586
- details: { taskId: task.id, phaseId: phase.id, extractedPrefix },
587
- })
588
- }
589
- }
590
- }
591
- }
592
- }
593
-
594
- // ── Check 11: Task cycle detection (DFS gray/black coloring) ─────────────────
595
-
596
- // Build adjacency map; skip tasks with null IDs; skip refs not in graph and self-deps
597
- const adjMap = new Map() // taskId → valid dependsOn[]
598
- for (const phase of phases) {
599
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
600
- for (const task of tasks) {
601
- if (task.id == null) continue
602
- const validDeps = Array.isArray(task.dependsOn)
603
- ? task.dependsOn.filter(dep => allTaskIds.has(dep) && dep !== task.id)
604
- : []
605
- adjMap.set(task.id, validDeps)
606
- }
607
- }
608
-
609
- // Three-color DFS: WHITE = unvisited, GRAY = in progress (on current path), BLACK = done
610
- const WHITE = 0
611
- const GRAY = 1
612
- const BLACK = 2
613
-
614
- const colors = new Map()
615
- for (const taskId of adjMap.keys()) {
616
- colors.set(taskId, WHITE)
617
- }
618
-
619
- const detectedCycles = []
620
-
621
- function dfsCycle(nodeId, pathStack) {
622
- colors.set(nodeId, GRAY)
623
- pathStack.push(nodeId)
624
-
625
- for (const dep of adjMap.get(nodeId)) {
626
- const color = colors.get(dep)
627
- if (color === GRAY) {
628
- // Back edge — collect all members of this cycle from the current path stack
629
- const cycleStart = pathStack.indexOf(dep)
630
- detectedCycles.push(pathStack.slice(cycleStart))
631
- } else if (color === WHITE) {
632
- dfsCycle(dep, pathStack)
633
- }
634
- // BLACK: node fully processed — no cycle through it
635
- }
636
-
637
- pathStack.pop()
638
- colors.set(nodeId, BLACK)
639
- }
640
-
641
- for (const taskId of adjMap.keys()) {
642
- if (colors.get(taskId) === WHITE) {
643
- dfsCycle(taskId, [])
644
- }
645
- }
646
-
647
- // Populate dependencies with task-level graph data
648
- report.dependencies.taskGraph = {}
649
- for (const [taskId, deps] of adjMap) {
650
- report.dependencies.taskGraph[taskId] = deps
651
- }
652
- report.dependencies.taskCycles = detectedCycles
653
-
654
- // Report each detected cycle as one error entry
655
- for (const cycleMembers of detectedCycles) {
656
- report.errors.push({
657
- category: ERRORS.TASK_CYCLE_DETECTED,
658
- severity: 'error',
659
- taskId: null,
660
- field: null,
661
- message: `Dependency cycle detected among tasks: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
662
- details: { cycleMembers },
663
- })
664
- }
227
+ // ── Check 9: Self-dependency (extracted — wb-graph-checks.js) ───────────────
228
+ report.errors.push(...graphChecks.checkSelfDependency({ phases }).errors)
665
229
 
666
- // ── Check 12 & 13: Phase dependency projection and cycle detection ────────────
230
+ // ── Check 10: Phase ID consistency (extracted — wb-graph-checks.js) ─────────
231
+ report.errors.push(...graphChecks.checkPhaseIdConsistency({ phases }).errors)
667
232
 
668
- // seenTaskIds already maps taskId → ownerPhaseId; alias for clarity
669
- const taskToPhase = seenTaskIds // Map<taskId, phaseId>
233
+ // ── Check 11: Task cycle detection (extracted — wb-graph-checks.js) ─────────
234
+ const check11 = graphChecks.checkTaskCycle({ phases }, allTaskIds)
235
+ report.errors.push(...check11.errors)
236
+ report.dependencies.taskGraph = check11.taskGraph
237
+ report.dependencies.taskCycles = check11.taskCycles
670
238
 
671
- // All known phase IDs for existence checks (Check 12)
672
- const phaseIdSet = new Set(phases.map(p => p.id).filter(id => id != null))
239
+ // ── Check 12 & 13: Phase dependency projection and cycle (extracted) ─────────
240
+ const check1213 = graphChecks.checkPhaseDependencyAndCycle({ phases }, seenTaskIds)
241
+ report.errors.push(...check1213.errors)
242
+ report.dependencies.phaseGraph = check1213.phaseGraph
243
+ report.dependencies.phaseCycles = check1213.phaseCycles
673
244
 
674
- // Step 2: Compute phase-level dependsOn projection (same algorithm as wb-render.js)
675
- // For each phase: union all task dependsOn IDs → map to owner phase → remove self → deduplicate
676
- const phaseDeps = new Map() // Map<phaseId, Set<phaseId>>
677
-
678
- for (const phase of phases) {
679
- if (phase.id == null) continue
680
- const deps = new Set()
681
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
682
- for (const task of tasks) {
683
- if (!Array.isArray(task.dependsOn)) continue
684
- for (const refId of task.dependsOn) {
685
- const ownerPhase = taskToPhase.get(refId)
686
- if (ownerPhase == null) continue // unresolved task dep — already reported in check 8
687
- if (ownerPhase === phase.id) continue // remove self-loops (intra-phase)
688
- deps.add(ownerPhase)
689
- }
690
- }
691
- phaseDeps.set(phase.id, deps)
692
- }
693
-
694
- // Check 12: Phase dependency existence (defensive — should not fire if check 8 passed)
695
- for (const [phaseId, deps] of phaseDeps) {
696
- for (const depPhaseId of deps) {
697
- if (!phaseIdSet.has(depPhaseId)) {
698
- report.errors.push({
699
- category: ERRORS.PHASE_DEPENDENCY_NOT_FOUND,
700
- severity: 'error',
701
- taskId: null,
702
- field: 'dependsOn',
703
- message: `Phase "${phaseId}" has a projected dependency on phase "${depPhaseId}" which does not exist`,
704
- details: { phaseId, missingPhase: depPhaseId },
705
- })
706
- }
707
- }
708
- }
709
-
710
- // Check 13: Phase cycle detection (same DFS gray/black algorithm as check 11)
711
- const phaseColors = new Map()
712
- for (const phaseId of phaseDeps.keys()) {
713
- phaseColors.set(phaseId, WHITE)
714
- }
715
-
716
- const detectedPhaseCycles = []
717
-
718
- function dfsPhaseCycle(nodeId, pathStack) {
719
- phaseColors.set(nodeId, GRAY)
720
- pathStack.push(nodeId)
721
-
722
- for (const dep of phaseDeps.get(nodeId)) {
723
- const color = phaseColors.get(dep)
724
- if (color === GRAY) {
725
- const cycleStart = pathStack.indexOf(dep)
726
- detectedPhaseCycles.push(pathStack.slice(cycleStart))
727
- } else if (color === WHITE) {
728
- dfsPhaseCycle(dep, pathStack)
729
- }
730
- }
731
-
732
- pathStack.pop()
733
- phaseColors.set(nodeId, BLACK)
734
- }
735
-
736
- for (const phaseId of phaseDeps.keys()) {
737
- if (phaseColors.get(phaseId) === WHITE) {
738
- dfsPhaseCycle(phaseId, [])
739
- }
740
- }
741
-
742
- // Store phase graph in report as plain object for JSON serialization
743
- report.dependencies.phaseGraph = {}
744
- for (const [phaseId, deps] of phaseDeps) {
745
- report.dependencies.phaseGraph[phaseId] = [...deps].sort()
746
- }
747
- report.dependencies.phaseCycles = detectedPhaseCycles
748
-
749
- // Report each detected phase cycle as one error entry
750
- for (const cycleMembers of detectedPhaseCycles) {
751
- report.errors.push({
752
- category: ERRORS.PHASE_CYCLE_DETECTED,
753
- severity: 'error',
754
- taskId: null,
755
- field: null,
756
- message: `Phase dependency cycle detected: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
757
- details: { cycleMembers },
758
- })
759
- }
760
-
761
- // ── Check 14: Phase schedulability (buildWaves) ──────────────────────────────
762
-
763
- const buildWavesDone = new Set()
764
- const phaseWaves = []
765
- let phaseRemaining = phases.map(p => p.id).filter(id => id != null)
766
- while (phaseRemaining.length > 0) {
767
- const ready = phaseRemaining.filter(id => [...(phaseDeps.get(id) || [])].every(d => buildWavesDone.has(d)))
768
- if (ready.length === 0) break // deadlock — unschedulable phases remain
769
- ready.forEach(id => buildWavesDone.add(id))
770
- phaseWaves.push(ready)
771
- phaseRemaining = phaseRemaining.filter(id => !buildWavesDone.has(id))
772
- }
773
-
774
- report.dependencies.phaseWaves = phaseWaves
775
- report.dependencies.phaseUnschedulable = phaseRemaining
776
-
777
- for (const phaseId of phaseRemaining) {
778
- report.errors.push({
779
- category: ERRORS.PHASE_UNSCHEDULABLE,
780
- severity: 'error',
781
- taskId: null,
782
- phaseId,
783
- field: null,
784
- message: `Phase "${phaseId}" cannot be scheduled: all predecessor phases are unschedulable or form a deadlock`,
785
- details: { phaseId },
786
- })
787
- }
245
+ // ── Check 14: Phase schedulability (extracted — wb-graph-checks.js) ─────────
246
+ const check14 = graphChecks.checkPhaseSchedulability({ phases }, check1213.phaseDepsMap)
247
+ report.errors.push(...check14.errors)
248
+ report.dependencies.phaseWaves = check14.phaseWaves
249
+ report.dependencies.phaseUnschedulable = check14.phaseUnschedulable
788
250
 
789
251
  // ── Check 15: Duration policy ────────────────────────────────────────────────
790
252
 
@@ -902,78 +364,10 @@ for (const phase of phases) {
902
364
  }
903
365
  report.domainDistribution = domainDist
904
366
 
905
- // ── AC table parsing (prerequisite for checks 19–21) ─────────────────────────
906
-
907
- const acMap = parseAcTable(requirementsPath)
908
-
909
- // ── Checks 19 & 20: AC existence and scope validation ────────────────────────
367
+ // ── Checks 19, 20 & 21: AC existence, scope, Must coverage (extracted) ──────
910
368
 
911
- if (acMap !== null) {
912
- for (const phase of phases) {
913
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
914
- for (const task of tasks) {
915
- if (!Array.isArray(task.acceptanceCriteria)) continue
916
- for (const acId of task.acceptanceCriteria) {
917
- const acEntry = acMap.get(acId)
918
-
919
- // ── Check 19: AC existence ───────────────────────────────────────────
920
- if (acEntry === undefined) {
921
- report.errors.push({
922
- category: ERRORS.AC_NOT_FOUND,
923
- severity: 'error',
924
- taskId: task.id,
925
- field: 'acceptanceCriteria',
926
- message: `Task "${task.id}" references AC "${acId}" which does not exist in the Requirements AC table`,
927
- details: { taskId: task.id, acId },
928
- })
929
- continue
930
- }
931
-
932
- // ── Check 20: AC scope validation ────────────────────────────────────
933
- if (acEntry.unscoped === true) continue
934
- const phaseId = seenTaskIds.get(task.id)
935
- if (!acEntry.allowedUserStories.includes(phaseId)) {
936
- report.errors.push({
937
- category: ERRORS.AC_WRONG_US,
938
- severity: 'error',
939
- taskId: task.id,
940
- field: 'acceptanceCriteria',
941
- message: `Task "${task.id}" (in phase "${phaseId}") references AC "${acId}" which is scoped to ${JSON.stringify(acEntry.allowedUserStories)}, not this phase`,
942
- details: { taskId: task.id, phaseId, acId, allowedUserStories: acEntry.allowedUserStories },
943
- })
944
- }
945
- }
946
- }
947
- }
948
- }
949
-
950
- // ── Check 21: Must AC coverage ───────────────────────────────────────────────
951
-
952
- if (acMap !== null) {
953
- const coveredAcIds = new Set()
954
- for (const phase of phases) {
955
- const tasks = Array.isArray(phase.tasks) ? phase.tasks : []
956
- for (const task of tasks) {
957
- if (!Array.isArray(task.acceptanceCriteria)) continue
958
- for (const acId of task.acceptanceCriteria) {
959
- coveredAcIds.add(acId)
960
- }
961
- }
962
- }
963
-
964
- for (const [acId, acEntry] of acMap) {
965
- if (acEntry.priority === 'Must' && !coveredAcIds.has(acId)) {
966
- report.errors.push({
967
- category: ERRORS.MUST_AC_UNCOVERED,
968
- severity: 'error',
969
- taskId: null,
970
- field: null,
971
- message: `AC "${acId}" has Must priority but is not covered by any task's acceptanceCriteria`,
972
- details: { acId, priority: 'Must' },
973
- })
974
- }
975
- }
976
- }
369
+ const acMap = graphChecks.parseAcTable(requirementsPath)
370
+ report.errors.push(...graphChecks.checkAcCoverage({ phases }, acMap, seenTaskIds).errors)
977
371
 
978
372
  // ── Check 22: Text field character validation ─────────────────────────────────
979
373