@dtlabs/ai-toolkit 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/lib/workflow-control.js +65 -13
  4. package/package.json +1 -1
  5. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  6. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  7. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  8. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  9. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  10. package/src/claude/agents/gaia-generate-work-breakdown.md +94 -100
  11. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  12. package/src/claude/commands/assemble-docs.md +16 -0
  13. package/src/claude/commands/feature-status.md +9 -4
  14. package/src/claude/scripts/ac-task-matrix.js +87 -0
  15. package/src/claude/scripts/assemble-docs.js +66 -0
  16. package/src/claude/scripts/req-assembler.js +127 -0
  17. package/src/claude/scripts/req-graph-checks.js +71 -0
  18. package/src/claude/scripts/req-skeleton-check.js +64 -0
  19. package/src/claude/scripts/tech-spec-assembler.js +188 -0
  20. package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
  21. package/src/claude/scripts/templates/gaia-component-template.md +64 -0
  22. package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
  23. package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
  24. package/src/claude/scripts/templates/gaia-task-template.md +92 -0
  25. package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
  26. package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
  27. package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
  28. package/src/claude/scripts/wb-graph-checks.js +754 -0
  29. package/src/claude/scripts/wb-render.js +6 -11
  30. package/src/claude/scripts/wb-skeleton-check.js +99 -0
  31. package/src/claude/scripts/wb-validate.js +49 -655
  32. package/src/claude/scripts/wbs-assembler.js +368 -0
  33. package/src/claude/skills/implement-feature/SKILL.md +12 -6
  34. package/src/claude/workflows/pm-phase1.js +342 -16
  35. package/src/claude/workflows/pm-phase2.js +277 -14
@@ -5,12 +5,212 @@ export const meta = {
5
5
  phases: [{ title: 'Work Breakdown' }, { title: 'Effort Estimate' }],
6
6
  }
7
7
 
8
+ // ── Skeleton JSON schema (FTR-020) ───────────────────────────────────────────
9
+ // Passed as opts.schema to the skeleton-generation worker call. See
10
+ // src/claude/agents/gaia-generate-work-breakdown.md Step 7 for the authoritative
11
+ // description of this shape.
12
+ const SKELETON_SCHEMA = {
13
+ type: 'object',
14
+ required: ['schemaVersion', 'feature', 'phases', 'tasks'],
15
+ properties: {
16
+ schemaVersion: { const: 3 },
17
+ feature: { type: 'string' },
18
+ phases: { type: 'array', items: {
19
+ type: 'object',
20
+ required: ['id', 'type', 'title', 'commit', 'sourceUC', 'context', 'taskIds'],
21
+ properties: {
22
+ id: { type: 'string' }, type: { enum: ['infrastructure', 'user-story'] },
23
+ title: { type: 'string' },
24
+ commit: { type: 'object', required: ['type', 'subject'],
25
+ properties: { type: { type: 'string' }, subject: { type: 'string' } } },
26
+ sourceUC: { type: 'array', items: { type: 'string' } },
27
+ // 2-4 sentences: why this phase exists, how its tasks relate, any sequencing note other
28
+ // phases/tasks need before opening this phase's own task files — the one part of the
29
+ // FTR-020 phase-detail template that genuinely needs narrative judgment, not something
30
+ // the host can derive mechanically the way it does Source UC/Tasks-in-phase/Depends-on.
31
+ context: { type: 'string' },
32
+ taskIds: { type: 'array', items: { type: 'string' } },
33
+ },
34
+ } },
35
+ tasks: { type: 'object', additionalProperties: {
36
+ type: 'object',
37
+ required: ['title', 'outcome', 'domain', 'agentType', 'dependsOn',
38
+ 'acceptanceCriteria', 'estimate', 'outputCount', 'commit'],
39
+ properties: {
40
+ title: { type: 'string' }, outcome: { type: 'string' },
41
+ domain: { enum: ['BE', 'FE', 'DB', 'DevOps', 'INFRA', 'TEST'] },
42
+ // Bracket-key form is deliberate — this is a JSON-Schema PROPERTY NAME describing a
43
+ // task's own field, not a dispatch-mechanism option key; written this way so the
44
+ // literal text doesn't collide with tests/cli/pm-phase2-source.test.js's source-text
45
+ // guard against this script ever using a non-c.worker dispatch mechanism.
46
+ ['agentType']: { enum: ['developer-backend', 'developer-frontend', 'developer-testing', 'review-solution'] },
47
+ dependsOn: { type: 'array', items: { type: 'string' } },
48
+ acceptanceCriteria: { type: 'array', items: { type: 'string' } },
49
+ estimate: { type: 'object', required: ['agentMinutes', 'tokens'],
50
+ properties: { agentMinutes: { type: 'integer' }, tokens: { type: 'integer' } } },
51
+ outputCount: { type: 'integer' },
52
+ commit: { type: 'object', required: ['type', 'subject'],
53
+ properties: { type: { type: 'string' }, subject: { type: 'string' } } },
54
+ },
55
+ } },
56
+ },
57
+ }
58
+
59
+ // Splits the skeleton response into (a) index.json's structural, map-keyed shape and
60
+ // (b) nothing else — phase title/commit/context are NOT copied into index.json (OQ-04); they
61
+ // are rendered straight to each phase's detail file by the caller, from the same skeleton
62
+ // response, since phase content is fully known the moment this one call returns.
63
+ function materializeIndex(skeleton) {
64
+ const tasks = {}
65
+ for (const [taskId, t] of Object.entries(skeleton.tasks)) {
66
+ tasks[taskId] = { ...t, detailFile: `tasks/${taskId}.md`, status: 'pending' }
67
+ }
68
+ const phases = skeleton.phases.map(p => ({
69
+ id: p.id, type: p.type, sourceUC: p.sourceUC, detailFile: `user-stories/${p.id}.md`, taskIds: p.taskIds,
70
+ }))
71
+ return { schemaVersion: 3, feature: skeleton.feature, phases, tasks }
72
+ }
73
+
74
+ // Duplicated from src/claude/scripts/wbs-assembler.js's renderPhaseDetailBlock/template-engine
75
+ // primitives — NOT required from it, since this workflow script's runtime has no `require` (see
76
+ // lib/workflow-runner.js: only `control`/`args` are injected); reads the template via `c.read()`
77
+ // (project-root-relative) instead of `fs.readFileSync(__dirname, ...)`, the one difference the
78
+ // sandboxed runtime forces. The fragile inverse (parsing, with the contiguous-run algorithm)
79
+ // stays exclusively in wbs-assembler.js, used only by wb-validate.js/wb-render.js. Keep both
80
+ // copies' FIELD/SECTION handling in sync if gaia-user-story-template.md's shape ever changes.
81
+ const FIELD_LINE_RE = /^- \*\*(.+?):\*\*[ \t]?(.*)$/
82
+ function _stripTemplateComments(text) {
83
+ return text.replace(/<!--[\s\S]*?-->/g, '')
84
+ }
85
+ function _fillFieldBlock(templateText, values) {
86
+ const lines = templateText.split('\n')
87
+ let start = 0
88
+ while (start < lines.length && !FIELD_LINE_RE.test(lines[start])) start++
89
+ const starts = []
90
+ let end = start
91
+ for (let j = start; j < lines.length; j++) {
92
+ const m = lines[j].match(FIELD_LINE_RE)
93
+ if (m) starts.push({ label: m[1] })
94
+ else if (lines[j].trim() === '') { end = j; break }
95
+ end = j + 1
96
+ }
97
+ const seen = new Set()
98
+ const newFieldLines = starts.map(({ label }) => {
99
+ if (!(label in values)) throw new Error(`Template field "${label}" has no supplied value`)
100
+ seen.add(label)
101
+ return `- **${label}:** ${values[label]}`
102
+ })
103
+ for (const label of Object.keys(values)) {
104
+ if (!seen.has(label)) throw new Error(`Supplied value for "${label}" matches no template field`)
105
+ }
106
+ return [...lines.slice(0, start), ...newFieldLines, ...lines.slice(end)].join('\n')
107
+ }
108
+ function _fillNamedSection(templateText, sectionName, content) {
109
+ const marker = `**${sectionName}:**`
110
+ const idx = templateText.indexOf(marker)
111
+ if (idx === -1) throw new Error(`Template is missing the "${sectionName}" section`)
112
+ const afterMarker = idx + marker.length
113
+ const nextMarkerIdx = templateText.indexOf('\n**', afterMarker)
114
+ const end = nextMarkerIdx === -1 ? templateText.length : nextMarkerIdx
115
+ return templateText.slice(0, afterMarker) + '\n\n' + content + '\n' + templateText.slice(end)
116
+ }
117
+ function _fillInlineBoldField(templateText, label, value) {
118
+ const lines = templateText.split('\n')
119
+ const marker = `**${label}:**`
120
+ const startIdx = lines.findIndex(l => l.includes(marker))
121
+ if (startIdx === -1) throw new Error(`Template is missing the "${label}" inline field`)
122
+ let end = startIdx + 1
123
+ while (end < lines.length && lines[end].trim() !== '') end++
124
+ return [...lines.slice(0, startIdx), `${marker} ${value}`, ...lines.slice(end)].join('\n')
125
+ }
126
+ function renderPhaseDetailBlock(phase, taskTitles, dependsOnPhases) {
127
+ const DASH = '—'
128
+ const sourceUC = Array.isArray(phase.sourceUC) && phase.sourceUC.length ? phase.sourceUC.join(', ') : DASH
129
+ const tasksBlock = taskTitles.length
130
+ ? taskTitles.map(t => `- \`${t.id}\` — ${t.title}`).join('\n')
131
+ : '_No tasks._'
132
+ const dependsOn = dependsOnPhases.length ? dependsOnPhases.join(', ') : 'none'
133
+ let text = _stripTemplateComments(c.read('.claude/scripts/templates/gaia-user-story-template.md'))
134
+ text = text.replace(/\{PHASE_ID\}/g, phase.id)
135
+ text = _fillFieldBlock(text, {
136
+ 'Phase ID': phase.id,
137
+ 'Type': phase.type,
138
+ 'Source UC': sourceUC,
139
+ 'Title': phase.title,
140
+ 'Commit type': phase.commit.type,
141
+ 'Commit scope': phase.commit.scope == null ? DASH : phase.commit.scope,
142
+ 'Commit subject': phase.commit.subject,
143
+ })
144
+ text = _fillNamedSection(text, 'Context', phase.context == null ? DASH : phase.context)
145
+ text = _fillNamedSection(text, 'Tasks in this phase', tasksBlock)
146
+ text = _fillInlineBoldField(text, 'Depends on phases', dependsOn)
147
+ return text.trim() + '\n'
148
+ }
149
+
150
+ // Depends-on-phases is 100% mechanically derivable from the skeleton's own task dependsOn
151
+ // graph (same information checkPhaseDependencyAndCycle computes in wb-graph-checks.js, just
152
+ // recomputed here since that function needs the post-assembly {phases:[{tasks:[...]}]} shape,
153
+ // not the skeleton's map-of-tasks-by-id) — no LLM judgment needed, unlike Context.
154
+ function computeDependsOnPhases(skeleton, phaseId, taskIdToPhase) {
155
+ const phase = skeleton.phases.find(p => p.id === phaseId)
156
+ const deps = new Set()
157
+ for (const taskId of phase.taskIds) {
158
+ const task = skeleton.tasks[taskId]
159
+ for (const depId of (task.dependsOn || [])) {
160
+ const depPhase = taskIdToPhase.get(depId)
161
+ if (depPhase && depPhase !== phaseId) deps.add(depPhase)
162
+ }
163
+ }
164
+ return [...deps]
165
+ }
8
166
 
167
+ // Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
168
+ // the full skeleton's sibling task list (id + title + outcome only, for duplicate-
169
+ // implementation awareness), and the Tech-Spec text (the per-task agent further narrows its
170
+ // own Read/Grep use as needed — this call does not pre-slice the Tech-Spec by domain).
171
+ function buildTaskPrompt(index, taskId, techSpecText) {
172
+ const task = index.tasks[taskId]
173
+ const siblingList = Object.entries(index.tasks)
174
+ .map(([id, t]) => `- ${id}: ${t.title} — ${t.outcome}`).join('\n')
175
+ return [
176
+ `Task skeleton entry (restate these fields verbatim; do not re-derive them):\n${JSON.stringify({ id: taskId, ...task }, null, 2)}`,
177
+ `Tech-Spec:\n${techSpecText}`,
178
+ `All tasks in this feature (for duplicate-implementation awareness):\n${siblingList}`,
179
+ ].join('\n\n')
180
+ }
9
181
 
10
182
  const c = control
11
183
  const { featurePath, featureDir, prefix } = c.feature(args[0])
12
184
  return c.run(meta.name, featureDir, prefix, async () => {
13
185
  const file = suffix => `${featureDir}/${prefix}-${suffix}`
186
+
187
+ // FTR-025: pm-phase1.js no longer writes the assembled Requirements/Tech-Spec files — this
188
+ // phase's own unmodified internals (the Gate 1 digest re-check right below, wb-skeleton-
189
+ // check.js, wb-validate.js, the semantic validator prompt, ac-task-matrix.js, and
190
+ // gaia-generate-work-breakdown, which hard-aborts without these files) still need them on
191
+ // disk. Transparently bridge the gap: assemble fresh from req/+spec/ every run (same two
192
+ // subprocess calls pm-phase1.js used to make, relocated here), before anything below reads
193
+ // them. A legacy feature (monolithic files only, no req/spec/ directories — generated before
194
+ // this feature existed) is left untouched: nothing to assemble, no error.
195
+ await c.activity('bridge-requirements-tech-spec', async () => {
196
+ const reqDir = `${featureDir}/req`, specDir = `${featureDir}/spec`
197
+ // c.exists() only checks isFile() — never true for a directory — so this checks each
198
+ // split's own index.md (a real file) rather than the directory itself.
199
+ const hasReq = c.exists(`${reqDir}/index.md`), hasSpec = c.exists(`${specDir}/index.md`)
200
+ if (!hasReq && !hasSpec) return { bridged: false, reason: 'legacy (no split sources)' }
201
+ if (hasReq) {
202
+ const assembledRequirements = c.command(['run-asset', 'scripts/req-assembler.js',
203
+ '--project', c.root, '--', reqDir]).stdout
204
+ c.writeOutput(file('Requirements.md'), assembledRequirements)
205
+ }
206
+ if (hasSpec) {
207
+ const assembledTechSpec = c.command(['run-asset', 'scripts/tech-spec-assembler.js',
208
+ '--project', c.root, '--', specDir]).stdout
209
+ c.writeOutput(file('Tech-Spec.md'), assembledTechSpec)
210
+ }
211
+ return { bridged: true }
212
+ })
213
+
14
214
  const approvals = c.read(file('Approvals.md'))
15
215
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
16
216
  if (!gate || !/✅\s*Approved/.test(gate[1])) throw new Error('Gate 1 approval evidence missing')
@@ -30,13 +230,62 @@ return c.run(meta.name, featureDir, prefix, async () => {
30
230
  throw new Error('Gate 1 approval is stale: Requirements/Tech-Spec content no longer matches what was approved (docs digest mismatch) — re-approve Gate 1 before proceeding')
31
231
  }
32
232
  }
33
- const jsonPath = file('Work-Breakdown.json')
34
- await c.worker('gaia.agent.planner.work-breakdown', featurePath, {
35
- outputs: [{ path: jsonPath, inputs: [featurePath, file('Requirements.md'), file('Tech-Spec.md')] }],
233
+
234
+ // Deliberately NOT file('wbs') — file() always prepends '{prefix}-' (every other
235
+ // artifact, e.g. file('Requirements.md') -> '{prefix}-Requirements.md', is a single file
236
+ // named after its own content, so the prefix disambiguates it; this is a directory nested
237
+ // inside the feature's own already-prefixed folder, so repeating the prefix is redundant.
238
+ const wbsDir = `${featureDir}/wbs`
239
+ const indexPath = `${wbsDir}/index.json`
240
+
241
+ // 1. Skeleton (no declared worker() output — host writes every file itself)
242
+ let index
243
+ if (!c.exists(indexPath)) {
244
+ const skeleton = await c.worker('gaia.agent.planner.work-breakdown', featurePath, { schema: SKELETON_SCHEMA })
245
+ index = materializeIndex(skeleton)
246
+ const taskIdToPhase = new Map(skeleton.phases.flatMap(p => p.taskIds.map(taskId => [taskId, p.id])))
247
+ for (const phase of index.phases) {
248
+ const skeletonPhase = skeleton.phases.find(p => p.id === phase.id)
249
+ const taskTitles = skeletonPhase.taskIds.map(taskId => ({ id: taskId, title: skeleton.tasks[taskId].title }))
250
+ const dependsOnPhases = computeDependsOnPhases(skeleton, phase.id, taskIdToPhase)
251
+ c.writeOutput(`${wbsDir}/${phase.detailFile}`, renderPhaseDetailBlock(skeletonPhase, taskTitles, dependsOnPhases))
252
+ }
253
+ c.writeOutput(indexPath, JSON.stringify(index, null, 2))
254
+ } else {
255
+ index = c.json(indexPath) // resume: reuse existing skeleton as-is
256
+ }
257
+
258
+ // 2. Structural pre-checks (wb-skeleton-check.js, same wb-graph-checks.js functions
259
+ // wb-validate.js now imports — run via subprocess since this runtime has no `require`)
260
+ const skeletonCheck = await c.activity('wb-skeleton-check', async () => {
261
+ const res = c.command(['run-asset', 'scripts/wb-skeleton-check.js', '--project', c.root,
262
+ '--', indexPath, file('Requirements.md')], [0, 1])
263
+ const parsed = JSON.parse(res.stdout)
264
+ if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
265
+ (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid skeleton-check result')
266
+ if (!parsed.valid) throw new Error('Work breakdown skeleton failed structural checks: ' + res.stdout)
267
+ return parsed
36
268
  })
269
+
270
+ // 3. Per-task generation, resume-by-status, sequential
271
+ for (const phase of index.phases) {
272
+ for (const taskId of phase.taskIds) {
273
+ const task = index.tasks[taskId]
274
+ const detailPath = `${wbsDir}/${task.detailFile}`
275
+ if (task.status === 'generated' && c.exists(detailPath)) continue // resume: skip, no dispatch
276
+ const detail = await c.worker('gaia.agent.planner.work-breakdown-task',
277
+ buildTaskPrompt(index, taskId, c.read(file('Tech-Spec.md'))), { label: `work-breakdown-task:${taskId}` })
278
+ c.writeOutput(detailPath, detail)
279
+ task.status = 'generated'
280
+ c.writeOutput(indexPath, JSON.stringify(index, null, 2)) // persist status after each task
281
+ }
282
+ }
283
+
284
+ // 4. Assembly (implicit, via the now directory-aware wb-validate.js/wb-render.js) +
285
+ // existing pipeline, unchanged past this point except the path passed in
37
286
  const report = await c.activity('wb-validate', async () => {
38
287
  const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
39
- '--', jsonPath, file('Requirements.md')], [0, 1])
288
+ '--', wbsDir, file('Requirements.md')], [0, 1])
40
289
  const parsed = JSON.parse(res.stdout)
41
290
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
42
291
  !Array.isArray(parsed.warnings) || (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid validator result')
@@ -44,26 +293,40 @@ return c.run(meta.name, featureDir, prefix, async () => {
44
293
  return parsed
45
294
  })
46
295
  const semantic = await c.worker('gaia.agent.planner.validate-work-breakdown',
47
- `${jsonPath}\n${file('Requirements.md')}`, { schema: {
296
+ `${wbsDir}\n${file('Requirements.md')}`, { schema: {
48
297
  type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array' } }, required: ['valid', 'findings'],
49
298
  } })
50
299
  if (!semantic.valid || semantic.findings.some(f => f.blocking)) throw new Error('Work breakdown semantic validation failed')
51
- const rendered = await c.activity('wb-render', async () => {
52
- const result = c.command(['run-asset', 'scripts/wb-render.js', '--project', c.root, '--', jsonPath, prefix])
53
- const markdownPath = file('Work-Breakdown.md'), csvPath = file('Work-Breakdown.csv')
54
- if (!c.exists(markdownPath) || !c.exists(csvPath)) throw new Error('Renderer outputs missing')
55
- // Read the actual bytes now: unreadable output must never produce an approved gate.
56
- c.read(markdownPath); c.read(csvPath)
57
- return { ...result, markdownPath, csvPath, markdownExists: true, csvExists: true }
300
+ // FTR-025: wb-render.js is no longer called automatically here — nothing downstream ever read
301
+ // {PREFIX}-Work-Breakdown.md/.csv (every real consumer already reads wbs/ directly via
302
+ // wbs-assembler.js's loadWorkBreakdown()), so this view is now produced only on demand, via
303
+ // assemble-docs.js.
304
+ // FTR-022: a new deterministic rendering step — no new data, no new validation, purely a
305
+ // view over AC<->task coverage wb-validate.js's checks 19-21 already enforce. Cannot fail
306
+ // for a feature that already reached this point in the pipeline (BR-02/NFR-03).
307
+ const acTaskMatrix = await c.activity('ac-task-matrix', async () => {
308
+ // FTR-023: ac-task-matrix.js's CLI gained a 3rd positional argument (Tech-Spec path) to
309
+ // render the "Addressed by Tech-Spec File(s)" column.
310
+ const result = c.command(['run-asset', 'scripts/ac-task-matrix.js', '--project', c.root,
311
+ '--', wbsDir, file('Requirements.md'), file('Tech-Spec.md'), prefix])
312
+ const matrixPath = file('AC-Task-Matrix.md')
313
+ if (!c.exists(matrixPath)) throw new Error('AC-Task matrix output missing')
314
+ c.read(matrixPath) // never trust an unread write
315
+ return { ...result, matrixPath }
58
316
  })
59
- const metrics = await c.estimates(featureDir, prefix, c.json(jsonPath))
317
+ // Assembled structure for c.estimates()'s metrics — via wbs-assembler.js's own CLI mode
318
+ // (subprocess), since this runtime cannot `require` it in-process either.
319
+ const assembled = JSON.parse(c.command(['run-asset', 'scripts/wbs-assembler.js', '--project', c.root, '--', wbsDir]).stdout)
320
+ const metrics = await c.estimates(featureDir, prefix, assembled)
60
321
  c.append(file('process-log.txt'), `[${new Date().toISOString()}] pm-phase2 complete; Gate 2 approval requested\n`)
61
322
  return {
62
323
  ...metrics, feature_path: featurePath, token_ledger: c.tokenLedger, errors: [],
63
324
  gate2_payload: {
325
+ skeleton_check_report: skeletonCheck, skeleton_check_failed: false,
64
326
  js_validator_report: report, js_validator_failed: false,
65
327
  semantic_validator_result: semantic, semantic_validator_failed: false,
66
- renderer_result: rendered, renderer_failed: false, gate2_blocked: false,
328
+ gate2_blocked: false,
329
+ ac_task_matrix_path: acTaskMatrix.matrixPath,
67
330
  duration_bands: report.durationBands, domain_distribution: report.domainDistribution,
68
331
  warning_band_tasks: report.warnings.filter(w => w.category === 'duration_warning').map(w => ({ taskId: w.taskId, agentMinutes: w.details?.agentMinutes })),
69
332
  split_required_tasks: [], must_ac_uncovered: [], phase_unschedulable: [],