@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.
- package/CHANGELOG.md +29 -0
- package/lib/agent-registry.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
- package/src/claude/agents/gaia-generate-requirements.md +22 -27
- package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
- package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
- package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
- package/src/claude/agents/gaia-generate-work-breakdown.md +124 -105
- package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
- package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
- package/src/claude/commands/assemble-docs.md +16 -0
- package/src/claude/commands/feature-status.md +9 -4
- package/src/claude/scripts/ac-task-matrix.js +121 -0
- package/src/claude/scripts/assemble-docs.js +66 -0
- package/src/claude/scripts/req-assembler.js +127 -0
- package/src/claude/scripts/req-graph-checks.js +71 -0
- package/src/claude/scripts/req-skeleton-check.js +64 -0
- package/src/claude/scripts/tech-spec-assembler.js +188 -0
- package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
- package/src/claude/scripts/templates/gaia-component-template.md +64 -0
- package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
- package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
- package/src/claude/scripts/templates/gaia-task-template.md +92 -0
- package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
- package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
- package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
- package/src/claude/scripts/wb-graph-checks.js +754 -0
- package/src/claude/scripts/wb-render.js +6 -11
- package/src/claude/scripts/wb-skeleton-check.js +99 -0
- package/src/claude/scripts/wb-validate.js +49 -655
- package/src/claude/scripts/wbs-assembler.js +368 -0
- package/src/claude/skills/implement-feature/SKILL.md +12 -6
- package/src/claude/workflows/pm-phase1.js +324 -18
- package/src/claude/workflows/pm-phase2.js +358 -15
|
@@ -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
|
-
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
46
|
+
wb = loadWorkBreakdown(path.resolve(jsonPath))
|
|
263
47
|
} catch (err) {
|
|
264
|
-
process.stderr.write(`Error:
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
|
230
|
+
// ── Check 10: Phase ID consistency (extracted — wb-graph-checks.js) ─────────
|
|
231
|
+
report.errors.push(...graphChecks.checkPhaseIdConsistency({ phases }).errors)
|
|
667
232
|
|
|
668
|
-
//
|
|
669
|
-
const
|
|
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
|
-
//
|
|
672
|
-
const
|
|
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
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
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
|
-
|
|
912
|
-
|
|
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
|
|