@dtlabs/ai-toolkit 0.17.5 → 0.19.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 +29 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/package.json +1 -1
  4. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  5. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  6. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  7. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  8. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  9. package/src/claude/agents/gaia-generate-work-breakdown.md +124 -105
  10. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  11. package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
  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 +121 -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 +324 -18
  35. package/src/claude/workflows/pm-phase2.js +358 -15
@@ -0,0 +1,754 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ // Pure, dependency-graph structural checks extracted from wb-validate.js (FTR-020 OQ-02).
7
+ // Each exported check function takes a plain { phases } structure (phases[].tasks[] inline,
8
+ // the same shape wb-validate.js already builds from the parsed Work Breakdown) and returns
9
+ // { errors: [...], ...diagnostics } — no file I/O, no process.exit. Reused by wb-validate.js
10
+ // (checks 3, 5-14, 19-21) and by pm-phase2.js's skeleton structural pre-check (before any
11
+ // per-task token is spent — see Tech-Spec).
12
+
13
+ const VALID_DOMAINS = ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST']
14
+
15
+ const VALID_AGENT_TYPES = [
16
+ 'developer-backend',
17
+ 'developer-frontend',
18
+ 'developer-testing',
19
+ 'developer-database',
20
+ 'review-solution',
21
+ ]
22
+
23
+ const ERRORS = {
24
+ SCHEMA_VERSION_INVALID: 'schema_version_invalid', // check 2
25
+ UNIQUE_ID_VIOLATION: 'unique_id_violation', // check 3
26
+ MISSING_FIELD: 'missing_field', // check 4
27
+ INVALID_ID_FORMAT: 'invalid_id_format', // check 5
28
+ INVALID_DOMAIN: 'invalid_domain', // check 6
29
+ INVALID_AGENT_TYPE: 'invalid_agent_type', // check 7
30
+ DEPENDENCY_NOT_FOUND: 'dependency_not_found', // check 8
31
+ SELF_DEPENDENCY: 'self_dependency', // check 9
32
+ PHASE_ID_MISMATCH: 'phase_id_mismatch', // check 10
33
+ TASK_CYCLE_DETECTED: 'task_cycle_detected', // check 11
34
+ PHASE_DEPENDENCY_NOT_FOUND: 'phase_dependency_not_found', // check 12
35
+ PHASE_CYCLE_DETECTED: 'phase_cycle_detected', // check 13
36
+ PHASE_UNSCHEDULABLE: 'phase_unschedulable', // check 14
37
+ DURATION_WARNING: 'duration_warning', // check 15 — non-blocking warning
38
+ SPLIT_REQUIRED: 'split_required', // check 15 — blocking error (>30 min)
39
+ EMPTY_VERIFICATION_COMMANDS: 'empty_verification_commands', // check 16
40
+ EMPTY_COMMIT_SUBJECT: 'empty_commit_subject', // check 17
41
+ MISSING_GROUPING_RATIONALE: 'missing_grouping_rationale', // check 18
42
+ AC_NOT_FOUND: 'ac_not_found', // check 19
43
+ AC_WRONG_US: 'ac_wrong_us', // check 20
44
+ AC_INVALID_UC_REF: 'ac_invalid_uc_ref', // check 20
45
+ UC_MISSING_PRIORITY: 'uc_missing_priority', // check 20
46
+ MUST_AC_UNCOVERED: 'must_ac_uncovered', // check 21
47
+ INVALID_TEXT_FIELD_CHARS: 'invalid_text_field_chars', // check 22
48
+ EMPTY_PHASE: 'empty_phase', // check 23
49
+ }
50
+
51
+ function _tasksOf(phase) {
52
+ return Array.isArray(phase.tasks) ? phase.tasks : []
53
+ }
54
+
55
+ // ── Markdown table row splitter ─────────────────────────────────────────────
56
+ // Splits a Markdown table row into cells on unescaped pipe separators only.
57
+ // A backslash-escaped pipe (\|) is treated as a literal pipe *inside* the cell
58
+ // rather than a column separator and is unescaped in the returned cell, so a
59
+ // criterion such as `ai-toolkit ledger open\|close\|fail\|skip` stays in a
60
+ // single cell and does not shift the "Related UC" column.
61
+ // Returns the raw split including the leading/trailing empty cells produced by
62
+ // the row's outer pipes, matching the previous `line.split('|')` contract so
63
+ // callers can keep using `.slice(1, -1)`.
64
+ function splitTableRow(line) {
65
+ const cells = []
66
+ let current = ''
67
+ for (let i = 0; i < line.length; i++) {
68
+ const ch = line[i]
69
+ if (ch === '\\' && line[i + 1] === '|') {
70
+ current += '|' // unescape: keep the literal pipe inside this cell
71
+ i++ // consume the escaped '|'
72
+ continue
73
+ }
74
+ if (ch === '|') {
75
+ cells.push(current)
76
+ current = ''
77
+ continue
78
+ }
79
+ current += ch
80
+ }
81
+ cells.push(current)
82
+ return cells
83
+ }
84
+
85
+ // ── AC table parser (prerequisite for checks 19–21) ─────────────────────────
86
+
87
+ function parseAcTable(requirementsPath) {
88
+ if (!requirementsPath) return null
89
+
90
+ let text
91
+ try {
92
+ text = fs.readFileSync(path.resolve(requirementsPath), 'utf8')
93
+ } catch (err) {
94
+ process.stderr.write(`Error: cannot read requirements file "${requirementsPath}": ${err.message}\n`)
95
+ process.exit(2)
96
+ }
97
+
98
+ const lines = text.split(/\r?\n/)
99
+
100
+ // ── Parse UC priorities ────────────────────────────────────────────────────
101
+ // Scan for ### UC-NN: headings, then look for | Priority | <value> | in the
102
+ // following metadata table (stop at the next same-or-higher-level heading).
103
+ const ucPriorityMap = new Map()
104
+ const UC_HEADING_RE = /^###\s+(UC-\d+):/
105
+
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const headingMatch = lines[i].match(UC_HEADING_RE)
108
+ if (!headingMatch) continue
109
+ const ucId = headingMatch[1]
110
+ for (let j = i + 1; j < lines.length; j++) {
111
+ if (/^#{1,3}\s/.test(lines[j])) break
112
+ const priorityMatch = lines[j].match(/^\|\s*Priority\s*\|\s*(\S+)\s*\|/)
113
+ if (priorityMatch) {
114
+ ucPriorityMap.set(ucId, priorityMatch[1])
115
+ break
116
+ }
117
+ }
118
+ }
119
+
120
+ // Fallback: a split-format Requirements SKELETON (req/index.md, FTR-021) has no per-UC
121
+ // "### UC-NN:" heading at all — that detail lives in a separate req/use-cases/UC-NN.md file,
122
+ // outside the document this function is ever actually given when called against the skeleton
123
+ // (tech-spec-ac-check, FTR-025). It does still have a "## 2. Use Cases" INDEX table with its
124
+ // own Priority column, which is exactly the information this map needs — read it for any UC
125
+ // not already resolved by a real heading, so a monolithic document's (possibly more precise,
126
+ // definitely already-present) per-UC heading is never overridden by this fallback.
127
+ let ucSectionIdx = -1
128
+ for (let i = 0; i < lines.length; i++) {
129
+ if (/^## 2\. Use Cases\s*$/.test(lines[i])) { ucSectionIdx = i; break }
130
+ }
131
+ if (ucSectionIdx !== -1) {
132
+ for (let i = ucSectionIdx + 1; i < lines.length; i++) {
133
+ if (/^## \d+\.\s/.test(lines[i])) break
134
+ if (!lines[i].trim().startsWith('|')) continue
135
+ const cells = splitTableRow(lines[i]).slice(1, -1).map(c => c.trim())
136
+ if (cells.every(c => /^[\s\-:]+$/.test(c))) continue // separator row
137
+ const [ucId, , , priority] = cells
138
+ if (ucId && /^UC-\d+$/.test(ucId) && priority && !ucPriorityMap.has(ucId)) {
139
+ ucPriorityMap.set(ucId, priority)
140
+ }
141
+ }
142
+ }
143
+
144
+ // ── Locate ## 7. Acceptance Criteria section ──────────────────────────────
145
+ let acSectionIdx = -1
146
+ for (let i = 0; i < lines.length; i++) {
147
+ if (/^## 7\. Acceptance Criteria\s*$/.test(lines[i])) {
148
+ acSectionIdx = i
149
+ break
150
+ }
151
+ }
152
+ if (acSectionIdx === -1) {
153
+ process.stderr.write('Error: requirements file is missing the "## 7. Acceptance Criteria" section\n')
154
+ process.exit(2)
155
+ }
156
+
157
+ // ── Find the first Markdown table (header row containing | ID |) ──────────
158
+ let tableHeaderIdx = -1
159
+ for (let i = acSectionIdx + 1; i < lines.length; i++) {
160
+ if (/^##\s/.test(lines[i])) break
161
+ if (/\|\s*ID\s*\|/.test(lines[i])) {
162
+ tableHeaderIdx = i
163
+ break
164
+ }
165
+ }
166
+ if (tableHeaderIdx === -1) {
167
+ process.stderr.write('Error: no AC table found after "## 7. Acceptance Criteria" (expected a header row containing "| ID |")\n')
168
+ process.exit(2)
169
+ }
170
+
171
+ // ── Validate column headers ────────────────────────────────────────────────
172
+ const headerCells = splitTableRow(lines[tableHeaderIdx]).slice(1, -1).map(c => c.trim())
173
+ if (headerCells.length < 3) {
174
+ process.stderr.write('Error: AC table header has fewer than 3 columns\n')
175
+ process.exit(2)
176
+ }
177
+ if (headerCells[0] !== 'ID' || headerCells[1] !== 'Criterion' || headerCells[2] !== 'Related UC') {
178
+ process.stderr.write(`Error: AC table columns must be "ID", "Criterion", "Related UC" in order; got "${headerCells[0]}", "${headerCells[1]}", "${headerCells[2]}"\n`)
179
+ process.exit(2)
180
+ }
181
+
182
+ // ── Parse data rows ────────────────────────────────────────────────────────
183
+ const PRIORITY_ORDER = ['Must', 'Should', 'Could']
184
+ const UC_REF_RE = /^UC-\d+$/
185
+ const acMap = new Map()
186
+
187
+ for (let i = tableHeaderIdx + 1; i < lines.length; i++) {
188
+ const line = lines[i]
189
+ if (/^##\s/.test(line)) break
190
+ if (!line.trim().startsWith('|')) break
191
+
192
+ // Skip separator rows: every cell is only dashes, colons, or spaces
193
+ const rawCells = splitTableRow(line).slice(1, -1)
194
+ if (rawCells.every(c => /^[\s\-:]+$/.test(c))) continue
195
+
196
+ const cells = rawCells.map(c => c.trim())
197
+ if (cells.length < 3) continue
198
+
199
+ const acId = cells[0]
200
+ const relatedUcRaw = cells[2]
201
+
202
+ if (!acId) continue
203
+
204
+ if (acMap.has(acId)) {
205
+ process.stderr.write(`Error: duplicate AC ID "${acId}" in AC table\n`)
206
+ process.exit(2)
207
+ }
208
+
209
+ let unscoped = false
210
+ let allowedUserStories = []
211
+ let relatedUCs = []
212
+ let acPriority
213
+
214
+ if (relatedUcRaw.trim().toLowerCase() === 'all ucs') {
215
+ unscoped = true
216
+ allowedUserStories = []
217
+ relatedUCs = []
218
+ // Derive strongest priority from all known UCs
219
+ acPriority = [...ucPriorityMap.values()].reduce((best, p) => {
220
+ const bestIdx = PRIORITY_ORDER.indexOf(best)
221
+ const pIdx = PRIORITY_ORDER.indexOf(p)
222
+ return pIdx !== -1 && pIdx < bestIdx ? p : best
223
+ }, 'Could')
224
+ } else {
225
+ const ucTokens = relatedUcRaw.split(',').map(t => t.trim())
226
+
227
+ for (const token of ucTokens) {
228
+ if (!UC_REF_RE.test(token)) {
229
+ process.stderr.write(`Error: malformed UC reference "${token}" in AC "${acId}" Related UC field\n`)
230
+ process.exit(2)
231
+ }
232
+ }
233
+
234
+ for (const ucRef of ucTokens) {
235
+ if (!ucPriorityMap.has(ucRef)) {
236
+ process.stderr.write(`Error: AC "${acId}" references UC "${ucRef}" which does not exist in the requirements\n`)
237
+ process.exit(2)
238
+ }
239
+ if (!ucPriorityMap.get(ucRef)) {
240
+ process.stderr.write(`Error: ${ucRef} has no declared priority\n`)
241
+ process.exit(2)
242
+ }
243
+ allowedUserStories.push(ucRef.replace('UC-', 'US-'))
244
+ relatedUCs.push(ucRef)
245
+ }
246
+
247
+ acPriority = ucTokens.reduce((best, ucRef) => {
248
+ const p = ucPriorityMap.get(ucRef)
249
+ const bestIdx = PRIORITY_ORDER.indexOf(best)
250
+ const pIdx = PRIORITY_ORDER.indexOf(p)
251
+ return pIdx !== -1 && pIdx < bestIdx ? p : best
252
+ }, 'Could')
253
+ }
254
+
255
+ acMap.set(acId, { id: acId, criterion: cells[1], priority: acPriority, allowedUserStories, relatedUCs, unscoped })
256
+ }
257
+
258
+ return acMap
259
+ }
260
+
261
+ // ── Check 3: Unique task IDs ─────────────────────────────────────────────────
262
+
263
+ function checkUniqueTaskIds({ phases }) {
264
+ const errors = []
265
+ const seenTaskIds = new Map() // taskId → first-seen phaseId
266
+ const duplicateTaskIds = new Set()
267
+
268
+ for (const phase of phases) {
269
+ for (const task of _tasksOf(phase)) {
270
+ if (task.id != null) {
271
+ if (seenTaskIds.has(task.id)) {
272
+ duplicateTaskIds.add(task.id)
273
+ } else {
274
+ seenTaskIds.set(task.id, phase.id)
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ for (const taskId of duplicateTaskIds) {
281
+ errors.push({
282
+ category: ERRORS.UNIQUE_ID_VIOLATION,
283
+ severity: 'error',
284
+ taskId,
285
+ field: 'id',
286
+ message: `Duplicate task ID "${taskId}" appears in multiple phases`,
287
+ details: { taskId },
288
+ })
289
+ }
290
+
291
+ return { errors, seenTaskIds }
292
+ }
293
+
294
+ // ── Check 5: Task ID format ──────────────────────────────────────────────────
295
+
296
+ const INFRA_TASK_ID_RE = /^INFRA-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
297
+ const US_TASK_ID_RE = /^[A-Z0-9-]+-TASK-(BE|FE|DB|DevOps|INFRA|TEST)-\d+$/
298
+
299
+ function checkTaskIdFormat({ phases }) {
300
+ const errors = []
301
+ for (const phase of phases) {
302
+ const isInfraPhase = phase.id === 'INFRA' || phase.type === 'infra'
303
+ const re = isInfraPhase ? INFRA_TASK_ID_RE : US_TASK_ID_RE
304
+ for (const task of _tasksOf(phase)) {
305
+ if (task.id == null) continue
306
+ if (!re.test(task.id)) {
307
+ errors.push({
308
+ category: ERRORS.INVALID_ID_FORMAT,
309
+ severity: 'error',
310
+ taskId: task.id,
311
+ phaseId: phase.id,
312
+ field: 'id',
313
+ message: `Task "${task.id}" in phase "${phase.id}" has an invalid ID format`,
314
+ details: { taskId: task.id, phaseId: phase.id, expectedPattern: re.toString() },
315
+ })
316
+ }
317
+ }
318
+ }
319
+ return { errors }
320
+ }
321
+
322
+ // ── Check 6: Domain whitelist ────────────────────────────────────────────────
323
+
324
+ function checkDomainWhitelist({ phases }) {
325
+ const errors = []
326
+ for (const phase of phases) {
327
+ for (const task of _tasksOf(phase)) {
328
+ if (task.domain == null) continue
329
+ if (!VALID_DOMAINS.includes(task.domain)) {
330
+ errors.push({
331
+ category: ERRORS.INVALID_DOMAIN,
332
+ severity: 'error',
333
+ taskId: task.id,
334
+ field: 'domain',
335
+ message: `Task "${task.id}" has invalid domain "${task.domain}"; must be one of ${VALID_DOMAINS.join(', ')}`,
336
+ details: { taskId: task.id, domain: task.domain, validDomains: VALID_DOMAINS },
337
+ })
338
+ }
339
+ }
340
+ }
341
+ return { errors }
342
+ }
343
+
344
+ // ── Check 7: AgentType whitelist ─────────────────────────────────────────────
345
+
346
+ function checkAgentTypeWhitelist({ phases }) {
347
+ const errors = []
348
+ for (const phase of phases) {
349
+ for (const task of _tasksOf(phase)) {
350
+ if (task.agentType == null) continue
351
+ if (!VALID_AGENT_TYPES.includes(task.agentType)) {
352
+ errors.push({
353
+ category: ERRORS.INVALID_AGENT_TYPE,
354
+ severity: 'error',
355
+ taskId: task.id,
356
+ field: 'agentType',
357
+ message: `Task "${task.id}" has invalid agentType "${task.agentType}"; must be one of ${VALID_AGENT_TYPES.join(', ')}`,
358
+ details: { taskId: task.id, agentType: task.agentType, validAgentTypes: VALID_AGENT_TYPES },
359
+ })
360
+ }
361
+ }
362
+ }
363
+ return { errors }
364
+ }
365
+
366
+ // ── Check 8: Dependency reference existence ──────────────────────────────────
367
+
368
+ function checkDependencyExistence({ phases }, allTaskIds) {
369
+ const errors = []
370
+ for (const phase of phases) {
371
+ for (const task of _tasksOf(phase)) {
372
+ if (!Array.isArray(task.dependsOn)) continue
373
+ for (const refId of task.dependsOn) {
374
+ if (!allTaskIds.has(refId)) {
375
+ errors.push({
376
+ category: ERRORS.DEPENDENCY_NOT_FOUND,
377
+ severity: 'error',
378
+ taskId: task.id,
379
+ field: 'dependsOn',
380
+ message: `Task "${task.id}" references non-existent task ID "${refId}" in dependsOn`,
381
+ details: { taskId: task.id, missingRef: refId },
382
+ })
383
+ }
384
+ }
385
+ }
386
+ }
387
+ return { errors }
388
+ }
389
+
390
+ // ── Check 9: Self-dependency ─────────────────────────────────────────────────
391
+
392
+ function checkSelfDependency({ phases }) {
393
+ const errors = []
394
+ for (const phase of phases) {
395
+ for (const task of _tasksOf(phase)) {
396
+ if (!Array.isArray(task.dependsOn)) continue
397
+ if (task.dependsOn.includes(task.id)) {
398
+ errors.push({
399
+ category: ERRORS.SELF_DEPENDENCY,
400
+ severity: 'error',
401
+ taskId: task.id,
402
+ field: 'dependsOn',
403
+ message: `Task "${task.id}" lists itself in dependsOn (self-dependency)`,
404
+ details: { taskId: task.id },
405
+ })
406
+ }
407
+ }
408
+ }
409
+ return { errors }
410
+ }
411
+
412
+ // ── Check 10: Phase ID consistency ───────────────────────────────────────────
413
+
414
+ const INFRA_PREFIX = 'INFRA-TASK-'
415
+ const TASK_SEP = '-TASK-'
416
+
417
+ function checkPhaseIdConsistency({ phases }) {
418
+ const errors = []
419
+ for (const phase of phases) {
420
+ for (const task of _tasksOf(phase)) {
421
+ if (task.id == null) continue
422
+ if (task.id.startsWith(INFRA_PREFIX)) {
423
+ if (phase.id !== 'INFRA') {
424
+ errors.push({
425
+ category: ERRORS.PHASE_ID_MISMATCH,
426
+ severity: 'error',
427
+ taskId: task.id,
428
+ field: 'id',
429
+ message: `Task "${task.id}" has INFRA prefix but is in phase "${phase.id}"; INFRA tasks must be in the INFRA phase`,
430
+ details: { taskId: task.id, phaseId: phase.id, expectedPhase: 'INFRA' },
431
+ })
432
+ }
433
+ } else {
434
+ const sepIdx = task.id.indexOf(TASK_SEP)
435
+ if (sepIdx !== -1) {
436
+ const extractedPrefix = task.id.substring(0, sepIdx)
437
+ if (extractedPrefix !== phase.id) {
438
+ errors.push({
439
+ category: ERRORS.PHASE_ID_MISMATCH,
440
+ severity: 'error',
441
+ taskId: task.id,
442
+ field: 'id',
443
+ message: `Task "${task.id}" has prefix "${extractedPrefix}" but is in phase "${phase.id}"`,
444
+ details: { taskId: task.id, phaseId: phase.id, extractedPrefix },
445
+ })
446
+ }
447
+ }
448
+ }
449
+ }
450
+ }
451
+ return { errors }
452
+ }
453
+
454
+ // ── Check 11: Task cycle detection (DFS gray/black coloring) ─────────────────
455
+
456
+ function checkTaskCycle({ phases }, allTaskIds) {
457
+ const errors = []
458
+
459
+ // Build adjacency map; skip tasks with null IDs; skip refs not in graph and self-deps
460
+ const adjMap = new Map() // taskId → valid dependsOn[]
461
+ for (const phase of phases) {
462
+ for (const task of _tasksOf(phase)) {
463
+ if (task.id == null) continue
464
+ const validDeps = Array.isArray(task.dependsOn)
465
+ ? task.dependsOn.filter(dep => allTaskIds.has(dep) && dep !== task.id)
466
+ : []
467
+ adjMap.set(task.id, validDeps)
468
+ }
469
+ }
470
+
471
+ // Three-color DFS: WHITE = unvisited, GRAY = in progress (on current path), BLACK = done
472
+ const WHITE = 0
473
+ const GRAY = 1
474
+ const BLACK = 2
475
+
476
+ const colors = new Map()
477
+ for (const taskId of adjMap.keys()) {
478
+ colors.set(taskId, WHITE)
479
+ }
480
+
481
+ const detectedCycles = []
482
+
483
+ function dfsCycle(nodeId, pathStack) {
484
+ colors.set(nodeId, GRAY)
485
+ pathStack.push(nodeId)
486
+
487
+ for (const dep of adjMap.get(nodeId)) {
488
+ const color = colors.get(dep)
489
+ if (color === GRAY) {
490
+ // Back edge — collect all members of this cycle from the current path stack
491
+ const cycleStart = pathStack.indexOf(dep)
492
+ detectedCycles.push(pathStack.slice(cycleStart))
493
+ } else if (color === WHITE) {
494
+ dfsCycle(dep, pathStack)
495
+ }
496
+ // BLACK: node fully processed — no cycle through it
497
+ }
498
+
499
+ pathStack.pop()
500
+ colors.set(nodeId, BLACK)
501
+ }
502
+
503
+ for (const taskId of adjMap.keys()) {
504
+ if (colors.get(taskId) === WHITE) {
505
+ dfsCycle(taskId, [])
506
+ }
507
+ }
508
+
509
+ // Populate task-level graph data
510
+ const taskGraph = {}
511
+ for (const [taskId, deps] of adjMap) {
512
+ taskGraph[taskId] = deps
513
+ }
514
+
515
+ for (const cycleMembers of detectedCycles) {
516
+ errors.push({
517
+ category: ERRORS.TASK_CYCLE_DETECTED,
518
+ severity: 'error',
519
+ taskId: null,
520
+ field: null,
521
+ message: `Dependency cycle detected among tasks: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
522
+ details: { cycleMembers },
523
+ })
524
+ }
525
+
526
+ return { errors, taskGraph, taskCycles: detectedCycles }
527
+ }
528
+
529
+ // ── Check 12 & 13: Phase dependency projection and cycle detection ────────────
530
+
531
+ function checkPhaseDependencyAndCycle({ phases }, taskToPhase) {
532
+ const errors = []
533
+
534
+ // All known phase IDs for existence checks (Check 12)
535
+ const phaseIdSet = new Set(phases.map(p => p.id).filter(id => id != null))
536
+
537
+ // Step 2: Compute phase-level dependsOn projection (same algorithm as wb-render.js)
538
+ // For each phase: union all task dependsOn IDs → map to owner phase → remove self → deduplicate
539
+ const phaseDeps = new Map() // Map<phaseId, Set<phaseId>>
540
+
541
+ for (const phase of phases) {
542
+ if (phase.id == null) continue
543
+ const deps = new Set()
544
+ for (const task of _tasksOf(phase)) {
545
+ if (!Array.isArray(task.dependsOn)) continue
546
+ for (const refId of task.dependsOn) {
547
+ const ownerPhase = taskToPhase.get(refId)
548
+ if (ownerPhase == null) continue // unresolved task dep — already reported in check 8
549
+ if (ownerPhase === phase.id) continue // remove self-loops (intra-phase)
550
+ deps.add(ownerPhase)
551
+ }
552
+ }
553
+ phaseDeps.set(phase.id, deps)
554
+ }
555
+
556
+ // Check 12: Phase dependency existence (defensive — should not fire if check 8 passed)
557
+ for (const [phaseId, deps] of phaseDeps) {
558
+ for (const depPhaseId of deps) {
559
+ if (!phaseIdSet.has(depPhaseId)) {
560
+ errors.push({
561
+ category: ERRORS.PHASE_DEPENDENCY_NOT_FOUND,
562
+ severity: 'error',
563
+ taskId: null,
564
+ field: 'dependsOn',
565
+ message: `Phase "${phaseId}" has a projected dependency on phase "${depPhaseId}" which does not exist`,
566
+ details: { phaseId, missingPhase: depPhaseId },
567
+ })
568
+ }
569
+ }
570
+ }
571
+
572
+ // Check 13: Phase cycle detection (same DFS gray/black algorithm as check 11)
573
+ const WHITE = 0
574
+ const GRAY = 1
575
+ const BLACK = 2
576
+
577
+ const phaseColors = new Map()
578
+ for (const phaseId of phaseDeps.keys()) {
579
+ phaseColors.set(phaseId, WHITE)
580
+ }
581
+
582
+ const detectedPhaseCycles = []
583
+
584
+ function dfsPhaseCycle(nodeId, pathStack) {
585
+ phaseColors.set(nodeId, GRAY)
586
+ pathStack.push(nodeId)
587
+
588
+ for (const dep of phaseDeps.get(nodeId)) {
589
+ const color = phaseColors.get(dep)
590
+ if (color === GRAY) {
591
+ const cycleStart = pathStack.indexOf(dep)
592
+ detectedPhaseCycles.push(pathStack.slice(cycleStart))
593
+ } else if (color === WHITE) {
594
+ dfsPhaseCycle(dep, pathStack)
595
+ }
596
+ }
597
+
598
+ pathStack.pop()
599
+ phaseColors.set(nodeId, BLACK)
600
+ }
601
+
602
+ for (const phaseId of phaseDeps.keys()) {
603
+ if (phaseColors.get(phaseId) === WHITE) {
604
+ dfsPhaseCycle(phaseId, [])
605
+ }
606
+ }
607
+
608
+ // Store phase graph as plain object for JSON serialization
609
+ const phaseGraph = {}
610
+ for (const [phaseId, deps] of phaseDeps) {
611
+ phaseGraph[phaseId] = [...deps].sort()
612
+ }
613
+
614
+ for (const cycleMembers of detectedPhaseCycles) {
615
+ errors.push({
616
+ category: ERRORS.PHASE_CYCLE_DETECTED,
617
+ severity: 'error',
618
+ taskId: null,
619
+ field: null,
620
+ message: `Phase dependency cycle detected: ${cycleMembers.join(' → ')} → ${cycleMembers[0]}`,
621
+ details: { cycleMembers },
622
+ })
623
+ }
624
+
625
+ return { errors, phaseGraph, phaseCycles: detectedPhaseCycles, phaseDepsMap: phaseDeps }
626
+ }
627
+
628
+ // ── Check 14: Phase schedulability (buildWaves) ──────────────────────────────
629
+
630
+ function checkPhaseSchedulability({ phases }, phaseDepsMap) {
631
+ const errors = []
632
+ const buildWavesDone = new Set()
633
+ const phaseWaves = []
634
+ let phaseRemaining = phases.map(p => p.id).filter(id => id != null)
635
+ while (phaseRemaining.length > 0) {
636
+ const ready = phaseRemaining.filter(id => [...(phaseDepsMap.get(id) || [])].every(d => buildWavesDone.has(d)))
637
+ if (ready.length === 0) break // deadlock — unschedulable phases remain
638
+ ready.forEach(id => buildWavesDone.add(id))
639
+ phaseWaves.push(ready)
640
+ phaseRemaining = phaseRemaining.filter(id => !buildWavesDone.has(id))
641
+ }
642
+
643
+ for (const phaseId of phaseRemaining) {
644
+ errors.push({
645
+ category: ERRORS.PHASE_UNSCHEDULABLE,
646
+ severity: 'error',
647
+ taskId: null,
648
+ phaseId,
649
+ field: null,
650
+ message: `Phase "${phaseId}" cannot be scheduled: all predecessor phases are unschedulable or form a deadlock`,
651
+ details: { phaseId },
652
+ })
653
+ }
654
+
655
+ return { errors, phaseWaves, phaseUnschedulable: phaseRemaining }
656
+ }
657
+
658
+ // ── Checks 19, 20 & 21: AC existence, scope validation, Must coverage ────────
659
+
660
+ function checkAcCoverage({ phases }, acMap, seenTaskIds) {
661
+ const errors = []
662
+ if (acMap === null) return { errors }
663
+
664
+ const coveredAcIds = new Set()
665
+ const phaseById = new Map(phases.map(p => [p.id, p]))
666
+
667
+ for (const phase of phases) {
668
+ for (const task of _tasksOf(phase)) {
669
+ if (!Array.isArray(task.acceptanceCriteria)) continue
670
+ for (const acId of task.acceptanceCriteria) {
671
+ const acEntry = acMap.get(acId)
672
+
673
+ // ── Check 19: AC existence ───────────────────────────────────────────
674
+ if (acEntry === undefined) {
675
+ errors.push({
676
+ category: ERRORS.AC_NOT_FOUND,
677
+ severity: 'error',
678
+ taskId: task.id,
679
+ field: 'acceptanceCriteria',
680
+ message: `Task "${task.id}" references AC "${acId}" which does not exist in the Requirements AC table`,
681
+ details: { taskId: task.id, acId },
682
+ })
683
+ continue
684
+ }
685
+
686
+ coveredAcIds.add(acId)
687
+
688
+ // ── Check 20: AC scope validation ────────────────────────────────────
689
+ if (acEntry.unscoped === true) continue
690
+ const phaseId = seenTaskIds.get(task.id)
691
+ // sourceUC-aware scope check: a phase legitimately realizes a UC whenever that UC is
692
+ // in its own declared sourceUC, regardless of whether the phase ID numerically matches
693
+ // the UC number — one phase can span several UCs, one UC can be realized across several
694
+ // phases (see internal_docs/ideas/requirements-techspec-split-and-traceability-matrix.md's
695
+ // worked example, which caught exactly this kind of misassignment). Falls back to the
696
+ // old UC-NN<->US-NN id-matching rule only when the phase has no sourceUC field at all —
697
+ // true for schema-v2 (legacy, pre-sourceUC) Work Breakdowns, the one case where no
698
+ // better information exists.
699
+ const phase = phaseById.get(phaseId)
700
+ const sourceUC = phase && Array.isArray(phase.sourceUC) ? phase.sourceUC : null
701
+ const scopeOk = sourceUC
702
+ ? acEntry.relatedUCs.some(uc => sourceUC.includes(uc))
703
+ : acEntry.allowedUserStories.includes(phaseId)
704
+ if (!scopeOk) {
705
+ errors.push({
706
+ category: ERRORS.AC_WRONG_US,
707
+ severity: 'error',
708
+ taskId: task.id,
709
+ field: 'acceptanceCriteria',
710
+ message: sourceUC
711
+ ? `Task "${task.id}" (in phase "${phaseId}") references AC "${acId}" which is scoped to ${JSON.stringify(acEntry.relatedUCs)}, but phase "${phaseId}"'s sourceUC is ${JSON.stringify(sourceUC)}`
712
+ : `Task "${task.id}" (in phase "${phaseId}") references AC "${acId}" which is scoped to ${JSON.stringify(acEntry.allowedUserStories)}, not this phase`,
713
+ details: { taskId: task.id, phaseId, acId, allowedUserStories: acEntry.allowedUserStories, relatedUCs: acEntry.relatedUCs, sourceUC },
714
+ })
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ // ── Check 21: Must AC coverage ───────────────────────────────────────────────
721
+ for (const [acId, acEntry] of acMap) {
722
+ if (acEntry.priority === 'Must' && !coveredAcIds.has(acId)) {
723
+ errors.push({
724
+ category: ERRORS.MUST_AC_UNCOVERED,
725
+ severity: 'error',
726
+ taskId: null,
727
+ field: null,
728
+ message: `AC "${acId}" has Must priority but is not covered by any task's acceptanceCriteria`,
729
+ details: { acId, priority: 'Must' },
730
+ })
731
+ }
732
+ }
733
+
734
+ return { errors }
735
+ }
736
+
737
+ module.exports = {
738
+ ERRORS,
739
+ VALID_DOMAINS,
740
+ VALID_AGENT_TYPES,
741
+ splitTableRow,
742
+ parseAcTable,
743
+ checkUniqueTaskIds,
744
+ checkTaskIdFormat,
745
+ checkDomainWhitelist,
746
+ checkAgentTypeWhitelist,
747
+ checkDependencyExistence,
748
+ checkSelfDependency,
749
+ checkPhaseIdConsistency,
750
+ checkTaskCycle,
751
+ checkPhaseDependencyAndCycle,
752
+ checkPhaseSchedulability,
753
+ checkAcCoverage,
754
+ }