@dtlabs/ai-toolkit 0.17.5 → 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.
- package/CHANGELOG.md +21 -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 +94 -100
- package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
- 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 +87 -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 +277 -14
|
@@ -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
|
+
}
|