@dtlabs/ai-toolkit 0.18.0 → 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 CHANGED
@@ -1,4 +1,12 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.19.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.18.0...v0.19.0) (2026-10-19)
3
+
4
+
5
+ ### Features
6
+
7
+ * make the Work Breakdown pipeline split-source-native (FTR-026) ([9d08a17](https://github.com/dtlab-tech/ai-toolkit/commit/9d08a1743e199534753b6c6d47b855ec7953f032))
8
+ * **pm-phase2, ac-task-matrix:** make the Work Breakdown pipeline split-source-native (FTR-026) ([067a8e3](https://github.com/dtlab-tech/ai-toolkit/commit/067a8e369f8a5db72678670bebb62c91c93303d1))
9
+
2
10
  ## [0.18.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.5...v0.18.0) (2026-10-19)
3
11
 
4
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -23,7 +23,7 @@ This is the **second of two generation passes** (FTR-020). The skeleton pass (`g
23
23
  The caller supplies, in the prompt (you do not read `{PREFIX}-Work-Breakdown.json`/`wbs/index.json` yourself — the host extracts and hands you exactly what you need):
24
24
 
25
25
  1. **This task's own skeleton entry**, verbatim: `id`, `title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `estimate`, `outputCount`, `commit`.
26
- 2. **The relevant Tech-Spec excerpt** for this task's domain (not the whole Tech-Spec — keeps this call small, which is the whole point of the per-task split).
26
+ 2. **The relevant Tech-Spec excerpt** for this task's own Acceptance Criteria (matched by the host against each component's `Addresses` column in `spec/index.md` — not the whole Tech-Spec, which is the whole point of the per-task split). If no component's `Addresses` column cites any of this task's Acceptance Criteria, you instead receive the full Components table from `spec/index.md` as a fallback — in that case, use your own judgment about which listed component(s) are actually relevant, the same way you would if asked to work from the full Tech-Spec directly.
27
27
  3. **The compact full-skeleton task list**: every task's `id` + `title` + `outcome` only (no detail), so you can tell whether another task already built the behavior this one would otherwise duplicate.
28
28
 
29
29
  If the prompt is missing any of these three, stop and report exactly what is missing — do not guess at a skeleton entry you were not given.
@@ -17,12 +17,20 @@ This is the **first of two generation passes** (FTR-020). This call decides the
17
17
 
18
18
  The user provides the path to a `feature.md` file. From the same directory, read:
19
19
 
20
- 1. `{PREFIX}-Requirements.md` — source of User Stories (derived from Use Cases), acceptance criteria, business rules
21
- 2. `{PREFIX}-Tech-Spec.md` — source of technical tasks, implementation order, file inventory, architecture decisions
20
+ 1. Requirements — `req/index.md` (the split format, FTR-021; source of User Stories derived from
21
+ Use Cases, acceptance criteria, business rules) if it exists, otherwise the legacy
22
+ `{PREFIX}-Requirements.md`
23
+ 2. Tech-Spec — `spec/index.md` (the split format, FTR-024; source of technical tasks,
24
+ implementation order, file inventory, architecture decisions) if it exists, otherwise the
25
+ legacy `{PREFIX}-Tech-Spec.md`
22
26
  3. `{PREFIX}-Validation-Report.md` — confirms both documents passed coverage validation
23
27
  4. `{PREFIX}-Approvals.md` — confirms user has approved the documents
24
28
 
25
- **This agent runs ONLY after validation and human approval.** If any of the four input files is missing, abort and report:
29
+ See Step 1 below for exactly how to read whichever shape (1)/(2) actually are, including per-item
30
+ drilldown into `req/use-cases/*.md`/`spec/components/*.md`.
31
+
32
+ **This agent runs ONLY after validation and human approval.** If Requirements/Tech-Spec exist in
33
+ neither shape, or (3)/(4) are missing, abort and report:
26
34
  ```
27
35
  Cannot generate work breakdown: `{file}` not found.
28
36
  The work breakdown requires validated and approved Requirements + Tech-Spec.
@@ -79,8 +87,25 @@ Every task must be the **smallest independently implementable, verifiable, and c
79
87
 
80
88
  ### Step 1 — Load and Cross-Reference
81
89
 
82
- 1. Read `{PREFIX}-Requirements.md` fully — extract all Use Cases (UC-XX), acceptance criteria (AC-XX), business rules (BR-XX), NFRs
83
- 2. Read `{PREFIX}-Tech-Spec.md` fully — extract architecture, file inventory, implementation order, data model, API endpoints, frontend components
90
+ 1. Check whether `req/index.md` exists (the split Requirements format, FTR-021). If it does:
91
+ - Read `req/index.md` fully — it already contains the full Use Cases index table, all Business
92
+ Rules, all Acceptance Criteria, and all NFRs (FTR-021 kept these feature-wide, not per-UC)
93
+ - For any UC whose own full flow description you need beyond the index's short Title/Actor
94
+ summary, Read `req/use-cases/{UC-ID}.md` directly
95
+ If `req/index.md` does not exist (a legacy feature), Read `{PREFIX}-Requirements.md` fully
96
+ instead — extract the same information from the monolithic file.
97
+ 2. Check whether `spec/index.md` exists (the split Tech-Spec format, FTR-024). If it does:
98
+ - Read `spec/index.md` fully — it already contains Architecture, Section 9's complete File
99
+ Inventory/Components table (with Domain/Addresses routing), Implementation Order, and Risks
100
+ - For any component whose own Backend/Frontend/design detail you need beyond the Components
101
+ table's one-line Purpose/Addresses summary, Read `spec/components/{file-slug}.md` directly —
102
+ the `Spec` column in Section 9's table gives you the exact path
103
+ - If, after reading the index, you cannot determine from its own summary tables which per-item
104
+ files are relevant to a given phase/task decision, fall back to reading every file under
105
+ `spec/components/` rather than proceeding with incomplete context
106
+ If `spec/index.md` does not exist (a legacy feature), Read `{PREFIX}-Tech-Spec.md` fully
107
+ instead — extract architecture, file inventory, implementation order, data model, API
108
+ endpoints, frontend components from the monolithic file.
84
109
 
85
110
  ### Step 2 — Derive User Stories from Use Cases
86
111
 
@@ -15,12 +15,17 @@ You do NOT repeat structural checks (schema conformance, required fields, ID for
15
15
 
16
16
  ## Input
17
17
 
18
- The user provides the path to `{PREFIX}-Work-Breakdown.json`. From the same directory, locate and read:
18
+ The prompt gives you two paths, one per line, in this order:
19
19
 
20
- 1. `{PREFIX}-Work-Breakdown.json` — the validated work breakdown (schema v2)
21
- 2. `{PREFIX}-Requirements.md` — User Story descriptions and Acceptance Criteria text
20
+ 1. The Work Breakdown directory (the split format, FTR-020) — contains `index.json` (the
21
+ validated skeleton, schema v2) plus `user-stories/*.md`/`tasks/*.md` detail files. Use Glob/Read
22
+ to explore it as needed; do not expect a single `{PREFIX}-Work-Breakdown.json` file.
23
+ 2. The Requirements source — `req/index.md` for a feature using the split Requirements format
24
+ (FTR-021; User Story descriptions and Acceptance Criteria text live in its own tables there,
25
+ unaffected by the per-UC split), or a legacy `{PREFIX}-Requirements.md` file otherwise. Read
26
+ whichever path is actually given — do not assume either shape.
22
27
 
23
- Derive the prefix from the filename (e.g., `FTR-014-Work-Breakdown.json` → prefix `FTR-014`).
28
+ Derive the prefix from the Work Breakdown directory's own `index.json`'s `feature` field.
24
29
 
25
30
  ---
26
31
 
@@ -45,6 +45,10 @@ function computeTechSpecCoverage(entries, acMap) {
45
45
  }
46
46
 
47
47
  function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
48
+ const mustEntries = [...acMap.entries()].filter(([, ac]) => ac.priority === 'Must')
49
+ const mustCovered = mustEntries.filter(([id]) => coverage.get(id).taskIds.size > 0).length
50
+ const summary = `**Must-AC coverage:** ${mustCovered}/${mustEntries.length} covered. ` +
51
+ 'Machine-readable data: [index.json](index.json).'
48
52
  const rows = [...acMap.entries()].map(([acId, ac]) => {
49
53
  const relatedUc = ac.unscoped ? 'All UCs' : ac.relatedUCs.join(', ')
50
54
  const cov = coverage.get(acId)
@@ -53,7 +57,7 @@ function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
53
57
  const techSpecFiles = [...(techSpecCoverage.get(acId) || [])].join(', ') || '—'
54
58
  return `| ${acId} | ${ac.criterion} | ${ac.priority} | ${relatedUc} | ${techSpecFiles} | ${taskIds} | ${phaseIds} |`
55
59
  })
56
- return `# AC ↔ Task Traceability Matrix — ${prefix}\n\n` +
60
+ return `# AC ↔ Task Traceability Matrix — ${prefix}\n\n${summary}\n\n` +
57
61
  '| AC ID | Criterion | Priority | Related UC | Addressed by Tech-Spec File(s) | Covering Task(s) | Covering Phase(s) |\n' +
58
62
  '|---|---|---|---|---|---|---|\n' +
59
63
  rows.join('\n') + '\n\n' +
@@ -68,10 +72,36 @@ function renderMatrix(prefix, acMap, coverage, techSpecCoverage = new Map()) {
68
72
  'belong to, comma-separated, or `—` if none.\n'
69
73
  }
70
74
 
75
+ // FTR-026: serializes the same in-memory data renderMatrix() already consumes into a
76
+ // machine-readable shape — one entry per Requirements AC, in table order. No new computation.
77
+ function buildMatrixJson(prefix, acMap, coverage, techSpecCoverage = new Map()) {
78
+ const acceptanceCriteria = [...acMap.entries()].map(([acId, ac]) => {
79
+ const cov = coverage.get(acId)
80
+ return {
81
+ id: acId,
82
+ criterion: ac.criterion,
83
+ priority: ac.priority,
84
+ unscoped: !!ac.unscoped,
85
+ relatedUCs: ac.unscoped ? [] : ac.relatedUCs,
86
+ techSpecFiles: [...(techSpecCoverage.get(acId) || [])],
87
+ taskIds: [...cov.taskIds],
88
+ phaseIds: [...cov.phaseIds],
89
+ }
90
+ })
91
+ return { prefix, generatedAt: new Date().toISOString(), acceptanceCriteria }
92
+ }
93
+
71
94
  if (require.main === module) {
72
- const [wbsPath, requirementsPath, techSpecPath, prefix] = process.argv.slice(2)
73
- if (!wbsPath || !requirementsPath || !techSpecPath || !prefix) {
74
- process.stderr.write('Usage: node ac-task-matrix.js <wbs-path> <requirements-path> <tech-spec-path> <prefix>\n')
95
+ // FTR-026: featureDir is REQUIRED (not optional) — confirmed by grep that pm-phase2.js and this
96
+ // script's own test suite are the only callers, so there is no backward-compatible no-arg case
97
+ // worth preserving. Output moves from one loose {prefix}-AC-Task-Matrix.md file (whose location
98
+ // used to be derived from path.dirname(requirementsPath) — a real bug: that would have silently
99
+ // moved once a caller passes a Requirements path living under req/) into a dedicated
100
+ // coverage-matrix/ subfolder under featureDir, as a JSON+MD pair.
101
+ const [wbsPath, requirementsPath, techSpecPath, prefix, featureDir] = process.argv.slice(2)
102
+ if (!wbsPath || !requirementsPath || !techSpecPath || !prefix || !featureDir) {
103
+ process.stderr.write(
104
+ 'Usage: node ac-task-matrix.js <wbs-path> <requirements-path> <tech-spec-path> <prefix> <feature-dir>\n')
75
105
  process.exit(1)
76
106
  }
77
107
  const wb = loadWorkBreakdown(path.resolve(wbsPath))
@@ -79,9 +109,13 @@ if (require.main === module) {
79
109
  const coverage = computeCoverage(wb, acMap)
80
110
  const techSpecEntries = parseFileInventoryEntries(fs.readFileSync(path.resolve(techSpecPath), 'utf8'))
81
111
  const techSpecCoverage = computeTechSpecCoverage(techSpecEntries, acMap)
82
- const outPath = path.join(path.dirname(path.resolve(requirementsPath)), `${prefix}-AC-Task-Matrix.md`)
83
- fs.writeFileSync(outPath, renderMatrix(prefix, acMap, coverage, techSpecCoverage))
84
- console.log(JSON.stringify({ outputPath: outPath }))
112
+ const outDir = path.join(path.resolve(featureDir), 'coverage-matrix')
113
+ fs.mkdirSync(outDir, { recursive: true })
114
+ const jsonPath = path.join(outDir, 'index.json')
115
+ const mdPath = path.join(outDir, 'index.md')
116
+ fs.writeFileSync(jsonPath, JSON.stringify(buildMatrixJson(prefix, acMap, coverage, techSpecCoverage), null, 2))
117
+ fs.writeFileSync(mdPath, renderMatrix(prefix, acMap, coverage, techSpecCoverage))
118
+ console.log(JSON.stringify({ jsonPath, mdPath }))
85
119
  }
86
120
 
87
- module.exports = { computeCoverage, renderMatrix, computeTechSpecCoverage }
121
+ module.exports = { computeCoverage, renderMatrix, computeTechSpecCoverage, buildMatrixJson }
@@ -164,10 +164,81 @@ function computeDependsOnPhases(skeleton, phaseId, taskIdToPhase) {
164
164
  return [...deps]
165
165
  }
166
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
+ }
236
+
167
237
  // Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
168
238
  // 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).
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).
171
242
  function buildTaskPrompt(index, taskId, techSpecText) {
172
243
  const task = index.tasks[taskId]
173
244
  const siblingList = Object.entries(index.tasks)
@@ -184,32 +255,19 @@ const { featurePath, featureDir, prefix } = c.feature(args[0])
184
255
  return c.run(meta.name, featureDir, prefix, async () => {
185
256
  const file = suffix => `${featureDir}/${prefix}-${suffix}`
186
257
 
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
- })
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')
213
271
 
214
272
  const approvals = c.read(file('Approvals.md'))
215
273
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
@@ -225,7 +283,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
225
283
  // re-approved, not silently carried forward on stale consent.
226
284
  const digestMatch = gate[1].match(/\*\*Docs Digest:\*\*\s*`([^`]+)`/)
227
285
  if (digestMatch) {
228
- 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)
229
296
  if (digestMatch[1] !== currentDigest) {
230
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')
231
298
  }
@@ -259,7 +326,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
259
326
  // wb-validate.js now imports — run via subprocess since this runtime has no `require`)
260
327
  const skeletonCheck = await c.activity('wb-skeleton-check', async () => {
261
328
  const res = c.command(['run-asset', 'scripts/wb-skeleton-check.js', '--project', c.root,
262
- '--', indexPath, file('Requirements.md')], [0, 1])
329
+ '--', indexPath, reqIndexPath], [0, 1])
263
330
  const parsed = JSON.parse(res.stdout)
264
331
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
265
332
  (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid skeleton-check result')
@@ -268,13 +335,20 @@ return c.run(meta.name, featureDir, prefix, async () => {
268
335
  })
269
336
 
270
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)) : []
271
342
  for (const phase of index.phases) {
272
343
  for (const taskId of phase.taskIds) {
273
344
  const task = index.tasks[taskId]
274
345
  const detailPath = `${wbsDir}/${task.detailFile}`
275
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
276
350
  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}` })
351
+ buildTaskPrompt(index, taskId, techSpecExcerpt), { label: `work-breakdown-task:${taskId}` })
278
352
  c.writeOutput(detailPath, detail)
279
353
  task.status = 'generated'
280
354
  c.writeOutput(indexPath, JSON.stringify(index, null, 2)) // persist status after each task
@@ -285,7 +359,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
285
359
  // existing pipeline, unchanged past this point except the path passed in
286
360
  const report = await c.activity('wb-validate', async () => {
287
361
  const res = c.command(['run-asset', 'scripts/wb-validate.js', '--project', c.root,
288
- '--', wbsDir, file('Requirements.md')], [0, 1])
362
+ '--', wbsDir, reqIndexPath], [0, 1])
289
363
  const parsed = JSON.parse(res.stdout)
290
364
  if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
291
365
  !Array.isArray(parsed.warnings) || (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid validator result')
@@ -293,7 +367,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
293
367
  return parsed
294
368
  })
295
369
  const semantic = await c.worker('gaia.agent.planner.validate-work-breakdown',
296
- `${wbsDir}\n${file('Requirements.md')}`, { schema: {
370
+ `${wbsDir}\n${reqIndexPath}`, { schema: {
297
371
  type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array' } }, required: ['valid', 'findings'],
298
372
  } })
299
373
  if (!semantic.valid || semantic.findings.some(f => f.blocking)) throw new Error('Work breakdown semantic validation failed')
@@ -307,12 +381,17 @@ return c.run(meta.name, featureDir, prefix, async () => {
307
381
  const acTaskMatrix = await c.activity('ac-task-matrix', async () => {
308
382
  // FTR-023: ac-task-matrix.js's CLI gained a 3rd positional argument (Tech-Spec path) to
309
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.
310
386
  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 }
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 }
316
395
  })
317
396
  // Assembled structure for c.estimates()'s metrics — via wbs-assembler.js's own CLI mode
318
397
  // (subprocess), since this runtime cannot `require` it in-process either.
@@ -326,7 +405,8 @@ return c.run(meta.name, featureDir, prefix, async () => {
326
405
  js_validator_report: report, js_validator_failed: false,
327
406
  semantic_validator_result: semantic, semantic_validator_failed: false,
328
407
  gate2_blocked: false,
329
- ac_task_matrix_path: acTaskMatrix.matrixPath,
408
+ ac_task_matrix_json_path: acTaskMatrix.matrixJsonPath,
409
+ ac_task_matrix_md_path: acTaskMatrix.matrixMdPath,
330
410
  duration_bands: report.durationBands, domain_distribution: report.domainDistribution,
331
411
  warning_band_tasks: report.warnings.filter(w => w.category === 'duration_warning').map(w => ({ taskId: w.taskId, agentMinutes: w.details?.agentMinutes })),
332
412
  split_required_tasks: [], must_ac_uncovered: [], phase_unschedulable: [],