@dtlabs/ai-toolkit 0.17.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/agent-registry.js +39 -0
  3. package/package.json +1 -1
  4. package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
  5. package/src/claude/agents/gaia-generate-requirements.md +22 -27
  6. package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
  7. package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
  8. package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
  9. package/src/claude/agents/gaia-generate-work-breakdown.md +124 -105
  10. package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
  11. package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
  12. package/src/claude/commands/assemble-docs.md +16 -0
  13. package/src/claude/commands/feature-status.md +9 -4
  14. package/src/claude/scripts/ac-task-matrix.js +121 -0
  15. package/src/claude/scripts/assemble-docs.js +66 -0
  16. package/src/claude/scripts/req-assembler.js +127 -0
  17. package/src/claude/scripts/req-graph-checks.js +71 -0
  18. package/src/claude/scripts/req-skeleton-check.js +64 -0
  19. package/src/claude/scripts/tech-spec-assembler.js +188 -0
  20. package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
  21. package/src/claude/scripts/templates/gaia-component-template.md +64 -0
  22. package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
  23. package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
  24. package/src/claude/scripts/templates/gaia-task-template.md +92 -0
  25. package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
  26. package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
  27. package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
  28. package/src/claude/scripts/wb-graph-checks.js +754 -0
  29. package/src/claude/scripts/wb-render.js +6 -11
  30. package/src/claude/scripts/wb-skeleton-check.js +99 -0
  31. package/src/claude/scripts/wb-validate.js +49 -655
  32. package/src/claude/scripts/wbs-assembler.js +368 -0
  33. package/src/claude/skills/implement-feature/SKILL.md +12 -6
  34. package/src/claude/workflows/pm-phase1.js +324 -18
  35. package/src/claude/workflows/pm-phase2.js +358 -15
@@ -5,12 +5,270 @@ 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
+ }
166
+
167
+ // FTR-026: local re-implementation of tech-spec-assembler.js's findSectionRange/parseTableRows
168
+ // (from req-assembler.js)/findSubsectionRange/parseComponentsTable — duplicated, not imported,
169
+ // same "sandboxed runtime has no `require`" reason as the template-engine primitives above.
170
+ // Keep in sync with tech-spec-assembler.js if spec/index.md's Section 9 table shape ever changes.
171
+ function _findSectionRange(content, sectionNumber) {
172
+ const lines = content.split('\n')
173
+ let start = -1, end = lines.length
174
+ for (let i = 0; i < lines.length; i++) {
175
+ const m = lines[i].match(/^## (\d+)\.\s/)
176
+ if (!m) continue
177
+ const n = Number(m[1])
178
+ if (n === sectionNumber) start = i
179
+ else if (start !== -1 && n > sectionNumber) { end = i; break }
180
+ }
181
+ if (start === -1) throw new Error(`missing section ${sectionNumber}`)
182
+ return { start, end, lines }
183
+ }
184
+ function _parseTableRows(lines, start, end) {
185
+ const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
186
+ if (tableLines.length < 2) return []
187
+ return tableLines.slice(2).map(line =>
188
+ line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
189
+ }
190
+ function _findSubsectionRange(lines, start, end, heading) {
191
+ let subStart = -1
192
+ for (let i = start; i < end; i++) {
193
+ if (lines[i].trim() === `### ${heading}`) { subStart = i; break }
194
+ }
195
+ if (subStart === -1) return null
196
+ let subEnd = end
197
+ for (let i = subStart + 1; i < end; i++) {
198
+ if (/^#{2,3}\s/.test(lines[i])) { subEnd = i; break }
199
+ }
200
+ return { start: subStart, end: subEnd }
201
+ }
202
+ function parseComponentsTableLocal(specIndexContent) {
203
+ const { start, end, lines } = _findSectionRange(specIndexContent, 9)
204
+ const entries = []
205
+ for (const [heading, changeType] of [['New files', 'New'], ['Modified files', 'Modified']]) {
206
+ const sub = _findSubsectionRange(lines, start, end, heading)
207
+ if (!sub) continue
208
+ for (const cells of _parseTableRows(lines, sub.start, sub.end)) {
209
+ const [filePath, purposeOrChange, addresses, domain, specLink] = cells
210
+ const m = specLink && specLink.match(/\(components\/([^)]+)\)/)
211
+ if (!m) throw new Error(`malformed Spec column for "${filePath}" in spec/index.md`)
212
+ entries.push({ filePath, purposeOrChange, changeType, domain, addresses, fileSlug: m[1] })
213
+ }
214
+ }
215
+ return entries
216
+ }
217
+
218
+ // Matches a task's own acceptanceCriteria against each component's Addresses column (the same
219
+ // relation spec/index.md's own Section 9 table already expresses) to give the per-task worker
220
+ // only the Tech-Spec content relevant to its own task, instead of the entire assembled spec —
221
+ // the single largest structural token cost this feature removes. Falls back to the full
222
+ // Components table (never zero content) when nothing matches, e.g. a purely organizational task.
223
+ function buildTaskTechSpecExcerpt(components, task, specDir, specIndexContent) {
224
+ const acSet = new Set(task.acceptanceCriteria || [])
225
+ const relevant = components.filter(comp =>
226
+ (comp.addresses || '').split(',').map(s => s.trim()).some(acId => acSet.has(acId)))
227
+ if (relevant.length === 0) {
228
+ return "_No component's Addresses column cites this task's Acceptance Criteria — full " +
229
+ 'Components table below._\n\n' + specIndexContent
230
+ }
231
+ return relevant.map(comp => {
232
+ const body = c.read(`${specDir}/components/${comp.fileSlug}`)
233
+ return `### ${comp.filePath}\n\n${body}`
234
+ }).join('\n\n')
235
+ }
8
236
 
237
+ // Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
238
+ // the full skeleton's sibling task list (id + title + outcome only, for duplicate-
239
+ // implementation awareness), and the Tech-Spec excerpt (FTR-026: already pre-sliced to this
240
+ // task's own relevant component(s) by buildTaskTechSpecExcerpt, or the full legacy Tech-Spec
241
+ // text for a feature with no spec/ directory).
242
+ function buildTaskPrompt(index, taskId, techSpecText) {
243
+ const task = index.tasks[taskId]
244
+ const siblingList = Object.entries(index.tasks)
245
+ .map(([id, t]) => `- ${id}: ${t.title} — ${t.outcome}`).join('\n')
246
+ return [
247
+ `Task skeleton entry (restate these fields verbatim; do not re-derive them):\n${JSON.stringify({ id: taskId, ...task }, null, 2)}`,
248
+ `Tech-Spec:\n${techSpecText}`,
249
+ `All tasks in this feature (for duplicate-implementation awareness):\n${siblingList}`,
250
+ ].join('\n\n')
251
+ }
9
252
 
10
253
  const c = control
11
254
  const { featurePath, featureDir, prefix } = c.feature(args[0])
12
255
  return c.run(meta.name, featureDir, prefix, async () => {
13
256
  const file = suffix => `${featureDir}/${prefix}-${suffix}`
257
+
258
+ // FTR-026: pm-phase2.js is now split-source-native itself — every call site below reads
259
+ // req/index.md/spec/index.md directly, in-memory assembly feeds only the Gate 1 digest
260
+ // re-check, and nothing here writes an assembled monolithic file to disk (the
261
+ // bridge-requirements-tech-spec activity FTR-025 added as a transitional shim is gone; see
262
+ // git history for FTR-025's original version of this block). A legacy feature (monolithic
263
+ // files only, no req/spec/ directories) falls back to reading those files directly at every
264
+ // site below, unchanged from pre-FTR-026 behavior.
265
+ const reqDir = `${featureDir}/req`, specDir = `${featureDir}/spec`
266
+ // c.exists() only checks isFile() — never true for a directory — so this checks each split's
267
+ // own index.md (a real file) rather than the directory itself.
268
+ const hasReq = c.exists(`${reqDir}/index.md`), hasSpec = c.exists(`${specDir}/index.md`)
269
+ const reqIndexPath = hasReq ? `${reqDir}/index.md` : file('Requirements.md')
270
+ const specIndexPath = hasSpec ? `${specDir}/index.md` : file('Tech-Spec.md')
271
+
14
272
  const approvals = c.read(file('Approvals.md'))
15
273
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
16
274
  if (!gate || !/✅\s*Approved/.test(gate[1])) throw new Error('Gate 1 approval evidence missing')
@@ -25,18 +283,83 @@ return c.run(meta.name, featureDir, prefix, async () => {
25
283
  // re-approved, not silently carried forward on stale consent.
26
284
  const digestMatch = gate[1].match(/\*\*Docs Digest:\*\*\s*`([^`]+)`/)
27
285
  if (digestMatch) {
28
- const currentDigest = c.hash(c.read(file('Requirements.md')) + '\u0000' + c.read(file('Tech-Spec.md')))
286
+ // FTR-026: recomputed in-memory from the split sources (same req-assembler.js/
287
+ // tech-spec-assembler.js subprocess calls pm-phase1.js already makes for its own
288
+ // docsDigest) instead of reading an assembled file from disk — never written here.
289
+ const assembledRequirements = hasReq
290
+ ? c.command(['run-asset', 'scripts/req-assembler.js', '--project', c.root, '--', reqDir]).stdout
291
+ : c.read(file('Requirements.md'))
292
+ const assembledTechSpec = hasSpec
293
+ ? c.command(['run-asset', 'scripts/tech-spec-assembler.js', '--project', c.root, '--', specDir]).stdout
294
+ : c.read(file('Tech-Spec.md'))
295
+ const currentDigest = c.hash(assembledRequirements + '\u0000' + assembledTechSpec)
29
296
  if (digestMatch[1] !== currentDigest) {
30
297
  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
298
  }
32
299
  }
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')] }],
300
+
301
+ // Deliberately NOT file('wbs') — file() always prepends '{prefix}-' (every other
302
+ // artifact, e.g. file('Requirements.md') -> '{prefix}-Requirements.md', is a single file
303
+ // named after its own content, so the prefix disambiguates it; this is a directory nested
304
+ // inside the feature's own already-prefixed folder, so repeating the prefix is redundant.
305
+ const wbsDir = `${featureDir}/wbs`
306
+ const indexPath = `${wbsDir}/index.json`
307
+
308
+ // 1. Skeleton (no declared worker() output — host writes every file itself)
309
+ let index
310
+ if (!c.exists(indexPath)) {
311
+ const skeleton = await c.worker('gaia.agent.planner.work-breakdown', featurePath, { schema: SKELETON_SCHEMA })
312
+ index = materializeIndex(skeleton)
313
+ const taskIdToPhase = new Map(skeleton.phases.flatMap(p => p.taskIds.map(taskId => [taskId, p.id])))
314
+ for (const phase of index.phases) {
315
+ const skeletonPhase = skeleton.phases.find(p => p.id === phase.id)
316
+ const taskTitles = skeletonPhase.taskIds.map(taskId => ({ id: taskId, title: skeleton.tasks[taskId].title }))
317
+ const dependsOnPhases = computeDependsOnPhases(skeleton, phase.id, taskIdToPhase)
318
+ c.writeOutput(`${wbsDir}/${phase.detailFile}`, renderPhaseDetailBlock(skeletonPhase, taskTitles, dependsOnPhases))
319
+ }
320
+ c.writeOutput(indexPath, JSON.stringify(index, null, 2))
321
+ } else {
322
+ index = c.json(indexPath) // resume: reuse existing skeleton as-is
323
+ }
324
+
325
+ // 2. Structural pre-checks (wb-skeleton-check.js, same wb-graph-checks.js functions
326
+ // wb-validate.js now imports — run via subprocess since this runtime has no `require`)
327
+ const skeletonCheck = await c.activity('wb-skeleton-check', async () => {
328
+ const res = c.command(['run-asset', 'scripts/wb-skeleton-check.js', '--project', c.root,
329
+ '--', indexPath, reqIndexPath], [0, 1])
330
+ const parsed = JSON.parse(res.stdout)
331
+ if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
332
+ (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid skeleton-check result')
333
+ if (!parsed.valid) throw new Error('Work breakdown skeleton failed structural checks: ' + res.stdout)
334
+ return parsed
36
335
  })
336
+
337
+ // 3. Per-task generation, resume-by-status, sequential
338
+ // FTR-026: each task's own worker call now receives only the Tech-Spec content relevant to its
339
+ // own Acceptance Criteria, not the entire assembled spec — computed once, before the loop, by
340
+ // matching each task's acceptanceCriteria against spec/index.md's own Addresses column.
341
+ const components = hasSpec ? parseComponentsTableLocal(c.read(specIndexPath)) : []
342
+ for (const phase of index.phases) {
343
+ for (const taskId of phase.taskIds) {
344
+ const task = index.tasks[taskId]
345
+ const detailPath = `${wbsDir}/${task.detailFile}`
346
+ if (task.status === 'generated' && c.exists(detailPath)) continue // resume: skip, no dispatch
347
+ const techSpecExcerpt = hasSpec
348
+ ? buildTaskTechSpecExcerpt(components, task, specDir, c.read(specIndexPath))
349
+ : c.read(file('Tech-Spec.md')) // legacy fallback: unchanged, full text
350
+ const detail = await c.worker('gaia.agent.planner.work-breakdown-task',
351
+ buildTaskPrompt(index, taskId, techSpecExcerpt), { label: `work-breakdown-task:${taskId}` })
352
+ c.writeOutput(detailPath, detail)
353
+ task.status = 'generated'
354
+ c.writeOutput(indexPath, JSON.stringify(index, null, 2)) // persist status after each task
355
+ }
356
+ }
357
+
358
+ // 4. Assembly (implicit, via the now directory-aware wb-validate.js/wb-render.js) +
359
+ // existing pipeline, unchanged past this point except the path passed in
37
360
  const report = await c.activity('wb-validate', async () => {
38
361
  const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
39
- '--', jsonPath, file('Requirements.md')], [0, 1])
362
+ '--', wbsDir, reqIndexPath], [0, 1])
40
363
  const parsed = JSON.parse(res.stdout)
41
364
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
42
365
  !Array.isArray(parsed.warnings) || (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid validator result')
@@ -44,26 +367,46 @@ return c.run(meta.name, featureDir, prefix, async () => {
44
367
  return parsed
45
368
  })
46
369
  const semantic = await c.worker('gaia.agent.planner.validate-work-breakdown',
47
- `${jsonPath}\n${file('Requirements.md')}`, { schema: {
370
+ `${wbsDir}\n${reqIndexPath}`, { schema: {
48
371
  type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array' } }, required: ['valid', 'findings'],
49
372
  } })
50
373
  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 }
374
+ // FTR-025: wb-render.js is no longer called automatically here — nothing downstream ever read
375
+ // {PREFIX}-Work-Breakdown.md/.csv (every real consumer already reads wbs/ directly via
376
+ // wbs-assembler.js's loadWorkBreakdown()), so this view is now produced only on demand, via
377
+ // assemble-docs.js.
378
+ // FTR-022: a new deterministic rendering step — no new data, no new validation, purely a
379
+ // view over AC<->task coverage wb-validate.js's checks 19-21 already enforce. Cannot fail
380
+ // for a feature that already reached this point in the pipeline (BR-02/NFR-03).
381
+ const acTaskMatrix = await c.activity('ac-task-matrix', async () => {
382
+ // FTR-023: ac-task-matrix.js's CLI gained a 3rd positional argument (Tech-Spec path) to
383
+ // render the "Addressed by Tech-Spec File(s)" column.
384
+ // FTR-026: gained a required 5th (featureDir) argument; output moved from one loose
385
+ // {PREFIX}-AC-Task-Matrix.md file into a coverage-matrix/ subfolder as a JSON+MD pair.
386
+ const result = c.command(['run-asset', 'scripts/ac-task-matrix.js', '--project', c.root,
387
+ '--', wbsDir, reqIndexPath, specIndexPath, prefix, featureDir])
388
+ const matrixJsonPath = `${featureDir}/coverage-matrix/index.json`
389
+ const matrixMdPath = `${featureDir}/coverage-matrix/index.md`
390
+ if (!c.exists(matrixJsonPath)) throw new Error('AC-Task matrix JSON output missing')
391
+ if (!c.exists(matrixMdPath)) throw new Error('AC-Task matrix MD output missing')
392
+ c.read(matrixJsonPath) // never trust an unread write
393
+ c.read(matrixMdPath)
394
+ return { ...result, matrixJsonPath, matrixMdPath }
58
395
  })
59
- const metrics = await c.estimates(featureDir, prefix, c.json(jsonPath))
396
+ // Assembled structure for c.estimates()'s metrics — via wbs-assembler.js's own CLI mode
397
+ // (subprocess), since this runtime cannot `require` it in-process either.
398
+ const assembled = JSON.parse(c.command(['run-asset', 'scripts/wbs-assembler.js', '--project', c.root, '--', wbsDir]).stdout)
399
+ const metrics = await c.estimates(featureDir, prefix, assembled)
60
400
  c.append(file('process-log.txt'), `[${new Date().toISOString()}] pm-phase2 complete; Gate 2 approval requested\n`)
61
401
  return {
62
402
  ...metrics, feature_path: featurePath, token_ledger: c.tokenLedger, errors: [],
63
403
  gate2_payload: {
404
+ skeleton_check_report: skeletonCheck, skeleton_check_failed: false,
64
405
  js_validator_report: report, js_validator_failed: false,
65
406
  semantic_validator_result: semantic, semantic_validator_failed: false,
66
- renderer_result: rendered, renderer_failed: false, gate2_blocked: false,
407
+ gate2_blocked: false,
408
+ ac_task_matrix_json_path: acTaskMatrix.matrixJsonPath,
409
+ ac_task_matrix_md_path: acTaskMatrix.matrixMdPath,
67
410
  duration_bands: report.durationBands, domain_distribution: report.domainDistribution,
68
411
  warning_band_tasks: report.warnings.filter(w => w.category === 'duration_warning').map(w => ({ taskId: w.taskId, agentMinutes: w.details?.agentMinutes })),
69
412
  split_required_tasks: [], must_ac_uncovered: [], phase_unschedulable: [],