@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
@@ -11,32 +11,358 @@ const force = args.includes('--force')
11
11
  return c.run(meta.name, featureDir, prefix, async () => {
12
12
  const file = suffix => `${featureDir}/${prefix}-${suffix}.md`
13
13
  const fresh = (output, inputs = [featurePath]) => c.exists(output) && c.read(output).trim().length > 0 && inputs.every(input => c.exists(input) && c.mtime(output) >= c.mtime(input))
14
+ // A long single-turn response (a feature complex enough to need many Use
15
+ // Cases/sections) can exceed the model's configured output-token ceiling
16
+ // mid-reply — confirmed against a real run where the CLI reported a clean
17
+ // exit (exitCode 0, is_error false, stop_reason "end_turn") with nothing in
18
+ // the response envelope signaling that anything was cut off. Only the
19
+ // document's own expected structure can catch this. Both Requirements (9
20
+ // top-level sections) and Tech-Spec (12) have a fixed, numbered skeleton
21
+ // (see their own agent definitions' "Output Structure"), so a missing
22
+ // "## N. " heading is a reliable, deterministic sign of truncation.
23
+ // Checked both right after generation (fail fast with an attributable
24
+ // error, not a silently-accepted half-document) and as part of the
25
+ // freshness test (so a previously truncated file left on disk from an
26
+ // earlier failed run is never mistaken for "fresh enough" on a later resume).
27
+ const missingSections = (content, count) => Array.from({ length: count }, (_, i) => i + 1).filter(n => !new RegExp(`^## ${n}\\.\\s`, 'm').test(content))
28
+ const assertCompleteSections = (label, content, count) => {
29
+ const missing = missingSections(content, count)
30
+ if (missing.length) throw new Error(`${label} output is incomplete: missing section(s) ${missing.join(', ')} of ${count} — likely truncated mid-generation (a model output-length ceiling was probably exceeded)`)
31
+ }
14
32
  const reqId = 'gaia.agent.planner.requirements'
33
+ const reqUcId = 'gaia.agent.planner.requirements-use-case'
15
34
  const specId = 'gaia.agent.planner.tech-spec'
35
+ const specComponentId = 'gaia.agent.planner.tech-spec-component'
16
36
  const valId = 'gaia.agent.planner.validate-feature-docs'
17
37
  const output = (suffix, inputs = [featurePath]) => ({ path: file(suffix), inputs })
18
- const requirements = () => c.worker(reqId, featurePath, { outputs: [output('Requirements')] })
19
- const techSpec = () => c.worker(specId, featurePath, { outputs: [output('Tech-Spec', [featurePath, file('Requirements')])] })
38
+
39
+ // Deliberately NOT file('req') — same reasoning as FTR-020's wbsDir: this is a directory
40
+ // nested inside the feature's own already-prefixed folder, so a second prefix would be
41
+ // redundant.
42
+ const reqDir = `${featureDir}/req`
43
+ const reqIndexPath = `${reqDir}/index.md`
44
+
45
+ // Shared by every req/index.md table parser below — same section-finding/table-row algorithm
46
+ // req-assembler.js's own findSectionRange/parseTableRows use, duplicated (not imported) since
47
+ // pm-phase1.js's workflow-runner sandbox has no `require`.
48
+ function findReqSection(lines, n) {
49
+ let start = -1, end = lines.length
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const m = lines[i].match(/^## (\d+)\.\s/)
52
+ if (!m) continue
53
+ const num = Number(m[1])
54
+ if (num === n) start = i
55
+ else if (start !== -1 && num > n) { end = i; break }
56
+ }
57
+ return { start, end }
58
+ }
59
+ function parseReqTableRows(lines, start, end) {
60
+ const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
61
+ if (tableLines.length < 2) return []
62
+ return tableLines.slice(2).map(line =>
63
+ line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
64
+ }
65
+
66
+ function parseUseCaseIndexTable(indexContent) {
67
+ const lines = indexContent.split('\n')
68
+ const { start, end } = findReqSection(lines, 2)
69
+ if (start === -1) throw new Error("req/index.md is missing section 2 (Use Cases)")
70
+ return parseReqTableRows(lines, start, end).map(cells => {
71
+ const [id, title, actor, priority, fileCol] = cells
72
+ const fileMatch = fileCol && fileCol.match(/\(([^)]+)\)/)
73
+ if (!fileMatch) throw new Error(`malformed File column for UC "${id}" in req/index.md`)
74
+ return { id, title, actor, priority, file: fileMatch[1] }
75
+ })
76
+ }
77
+
78
+ // Business Rules' "Applies to" / Acceptance Criteria's "Related UC" columns are the same
79
+ // cross-cutting-reference shape: a comma-separated UC-ID list, or the literal "All UCs". Both
80
+ // are 100% mechanically derivable from req/index.md's own tables — no LLM judgment needed,
81
+ // which is exactly why the per-UC generation agent is never given this data at all (see
82
+ // gaia-generate-requirements-use-case.md: it must not re-read req/index.md itself, to avoid
83
+ // re-deciding which UCs exist). The host computes and appends these cross-reference lines
84
+ // itself, after the agent's own response, mirroring how disambiguateFileSlugs/computeDependsOnPhases
85
+ // post-process agent/skeleton output elsewhere in this lineage.
86
+ function parseBusinessRulesTable(indexContent) {
87
+ const lines = indexContent.split('\n')
88
+ const { start, end } = findReqSection(lines, 3)
89
+ if (start === -1) throw new Error('req/index.md is missing section 3 (Business Rules)')
90
+ return parseReqTableRows(lines, start, end).map(([id, rule, appliesTo]) => ({ id, appliesTo }))
91
+ }
92
+ function parseAcTableRelatedUc(indexContent) {
93
+ const lines = indexContent.split('\n')
94
+ const { start, end } = findReqSection(lines, 7)
95
+ if (start === -1) throw new Error('req/index.md is missing section 7 (Acceptance Criteria)')
96
+ return parseReqTableRows(lines, start, end).map(([id, criterion, relatedUc]) => ({ id, relatedUc }))
97
+ }
98
+ function idsApplyingToUc(rows, ucId, column) {
99
+ return rows.filter(r => {
100
+ const val = (r[column] || '').trim()
101
+ return val.toLowerCase() === 'all ucs' || val.split(',').map(s => s.trim()).includes(ucId)
102
+ }).map(r => r.id)
103
+ }
104
+ function ucCrossReferences(indexContent, ucId) {
105
+ const relatedBRs = idsApplyingToUc(parseBusinessRulesTable(indexContent), ucId, 'appliesTo')
106
+ const relatedACs = idsApplyingToUc(parseAcTableRelatedUc(indexContent), ucId, 'relatedUc')
107
+ return '\n\n**Related Business Rules:** ' + (relatedBRs.length ? relatedBRs.join(', ') : '—') +
108
+ ' (see [index.md](../index.md#3-business-rules))\n\n**Related Acceptance Criteria:** ' +
109
+ (relatedACs.length ? relatedACs.join(', ') : '—') + ' (see [index.md](../index.md#7-acceptance-criteria))\n'
110
+ }
111
+
112
+ function buildUcPrompt(row, ucTable, featurePathArg) {
113
+ const compactIndex = ucTable.map(r => `${r.id}: ${r.title} (${r.actor})`).join('\n')
114
+ return `${featurePathArg}\n\nGenerate the full detail for this use case:\n` +
115
+ `UC ID: ${row.id}\nTitle: ${row.title}\nActor: ${row.actor}\nPriority: ${row.priority}\n\n` +
116
+ `Full use-case index (for cross-UC awareness, do not restate):\n${compactIndex}`
117
+ }
118
+
119
+ // forceAll: bypasses resume-by-existence entirely, re-dispatching the skeleton and every
120
+ // use case unconditionally, overwriting whatever is already on disk. Two call sites, two
121
+ // different needs: the initial-generation branch wants resume-by-existence (UC-04 — only
122
+ // pick up where a crashed/interrupted run left off); the needsRequirementsFix branch
123
+ // (validator found a gap) wants a full blind regeneration, exactly like the pre-FTR-021
124
+ // single-call behavior — by the time a fix is requested, every use case file already
125
+ // exists from the prior full run, so resume-by-existence alone would skip every one of
126
+ // them and silently reproduce the same (flagged-as-incomplete) document.
127
+ async function generateRequirements(forceAll) {
128
+ // A req/index.md that exists but is older than feature.md is NOT a resumable partial
129
+ // run — it was generated against a feature.md that has since changed, so resuming from
130
+ // it would keep stale skeleton content (UC list, Business Rules, AC table) forever.
131
+ // Only an index.md that is both present AND still fresh relative to feature.md is
132
+ // evidence of a genuine mid-run crash (UC-04) rather than staleness.
133
+ // Staleness is BOTH an mtime check and a structural-completeness check — the same
134
+ // combination the outer gate below uses to decide whether to call this function at all. A
135
+ // skeleton can be mtime-fresh (just written this run) yet truncated (a model output-length
136
+ // ceiling cut it off mid-reply); without the missingSections half here too, the outer gate
137
+ // would correctly decide "this needs regeneration" while this inner decision disagreed and
138
+ // silently reused the truncated skeleton anyway — found exactly this way, via a resume test
139
+ // that corrupts the skeleton (not an assembled file, which no longer exists to corrupt).
140
+ const skeletonStale = c.exists(reqIndexPath) &&
141
+ (!fresh(reqIndexPath) || missingSections(c.read(reqIndexPath), 9).length > 0)
142
+ const regenSkeleton = forceAll || skeletonStale || !c.exists(reqIndexPath)
143
+
144
+ // 1. Skeleton (no declared worker() output — host writes it itself, mirroring FTR-020's
145
+ // skeleton step)
146
+ if (regenSkeleton) {
147
+ const skeleton = await c.worker(reqId, featurePath)
148
+ c.writeOutput(reqIndexPath, skeleton)
149
+ }
150
+
151
+ // 2. Structural self-consistency pre-check (req-skeleton-check.js, same
152
+ // req-graph-checks.js functions — run via subprocess since this runtime has no `require`)
153
+ await c.activity('req-skeleton-check', async () => {
154
+ const res = c.command(['run-asset', 'scripts/req-skeleton-check.js', '--project', c.root,
155
+ '--', reqIndexPath], [0, 1])
156
+ const parsed = JSON.parse(res.stdout)
157
+ if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
158
+ (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid skeleton-check result')
159
+ if (!parsed.valid) throw new Error('Requirements skeleton failed structural checks: ' + res.stdout)
160
+ return parsed
161
+ })
162
+
163
+ // 3. Per-use-case generation, resume-by-existence, sequential
164
+ const ucTable = parseUseCaseIndexTable(c.read(reqIndexPath))
165
+ for (const row of ucTable) {
166
+ const ucPath = `${reqDir}/${row.file}`
167
+ // If the skeleton itself was just (re)generated, every use case must regenerate too —
168
+ // an existing use-case file would have been written against the OLD skeleton's row
169
+ // (title/actor/priority may have changed) and cannot be trusted as-is.
170
+ if (!regenSkeleton && c.exists(ucPath)) continue // resume: skip, no dispatch
171
+ const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath),
172
+ { label: `requirements-use-case:${row.id}` })
173
+ c.writeOutput(ucPath, detail.trimEnd() + ucCrossReferences(c.read(reqIndexPath), row.id))
174
+ }
175
+ // No assembly/write step here — generation only ever produces req/index.md + its per-UC
176
+ // files. Assembly happens in-memory, on demand, at the top level below (never persisted by
177
+ // this function); a user who wants a single combined file runs assemble-docs.js explicitly.
178
+ }
179
+
180
+ // Deliberately NOT file('spec') — same reasoning as reqDir/wbsDir: this directory is nested
181
+ // inside the feature's own already-prefixed folder, so a second prefix would be redundant.
182
+ const specDir = `${featureDir}/spec`
183
+ const specIndexPath = `${specDir}/index.md`
184
+
185
+ // Duplicated (not imported) from tech-spec-assembler.js's own export of the same name — this
186
+ // runtime has no `require`, so getting this via another c.command round-trip would be
187
+ // unnecessary overhead for a parse already needed in-process to drive the per-component loop.
188
+ function parseComponentsTable(indexContent) {
189
+ const lines = indexContent.split('\n')
190
+ let start = -1, end = lines.length
191
+ for (let i = 0; i < lines.length; i++) {
192
+ const m = lines[i].match(/^## (\d+)\.\s/)
193
+ if (!m) continue
194
+ const n = Number(m[1])
195
+ if (n === 9) start = i
196
+ else if (start !== -1 && n > 9) { end = i; break }
197
+ }
198
+ if (start === -1) throw new Error('spec/index.md is missing section 9 (File Inventory)')
199
+ const entries = []
200
+ for (const [heading, changeType] of [['New files', 'New'], ['Modified files', 'Modified']]) {
201
+ let subStart = -1
202
+ for (let i = start; i < end; i++) {
203
+ if (lines[i].trim() === `### ${heading}`) { subStart = i; break }
204
+ }
205
+ if (subStart === -1) continue
206
+ let subEnd = end
207
+ for (let i = subStart + 1; i < end; i++) {
208
+ if (/^#{2,3}\s/.test(lines[i])) { subEnd = i; break }
209
+ }
210
+ const tableLines = lines.slice(subStart, subEnd).filter(l => l.trim().startsWith('|'))
211
+ if (tableLines.length < 2) continue
212
+ for (const line of tableLines.slice(2)) {
213
+ const cells = line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1)
214
+ // Column order: Addresses MUST stay 3rd (index 2) — the exact position
215
+ // tech-spec-graph-checks.js's unmodified, positional parser already reads from a
216
+ // 3-column legacy table (FTR-023). Domain/Spec are new trailing columns.
217
+ const [filePath, purposeOrChange, addresses, domain, specLink] = cells
218
+ const m = specLink && specLink.match(/\(components\/([^)]+)\)/)
219
+ if (!m) throw new Error(`malformed Spec column for "${filePath}" in spec/index.md`)
220
+ entries.push({ filePath, purposeOrChange, changeType, domain, addresses, fileSlug: m[1] })
221
+ }
222
+ }
223
+ return entries
224
+ }
225
+
226
+ function buildComponentPrompt(row, components, featurePathArg) {
227
+ const compactIndex = components.map(c => `${c.filePath} (${c.domain})`).join('\n')
228
+ return `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
229
+ `Path: ${row.filePath}\nChange: ${row.changeType}\nDomain: ${row.domain}\nAddresses: ${row.addresses}\n\n` +
230
+ `Full component index (for cross-component awareness, do not restate):\n${compactIndex}`
231
+ }
232
+
233
+ // Deterministic, host-computed — never asks the model to pick unique names (same principle
234
+ // as materializeIndex's detailFile/status derivations in the Work Breakdown's own skeleton,
235
+ // FTR-020). Rewrites any colliding fileSlug's Spec-column link in place, right-to-left
236
+ // prepending path segments until unique.
237
+ //
238
+ // Operates per-LINE, not via a global string .replace() over the whole document — a real bug,
239
+ // found by testing two colliding entries directly: when both rows share byte-identical Spec
240
+ // text (the whole point of a collision), a global .replace() always rewrites the FIRST
241
+ // occurrence, never the specific (second, third, ...) row actually being disambiguated.
242
+ function disambiguateFileSlugs(skeletonText) {
243
+ const lines = skeletonText.split('\n')
244
+ const seenSlugs = new Set()
245
+ for (let i = 0; i < lines.length; i++) {
246
+ const line = lines[i]
247
+ if (!line.trim().startsWith('|')) continue
248
+ const cells = line.split('|').map(c => c.trim()).filter((_, idx, a) => idx > 0 && idx < a.length - 1)
249
+ if (cells.length < 5) continue
250
+ const filePath = cells[0]
251
+ const m = cells[4].match(/\(components\/([^)]+)\)/)
252
+ if (!m) continue // header/separator row, or a row with no valid Spec link yet
253
+ let slug = m[1]
254
+ if (seenSlugs.has(slug)) {
255
+ const segments = filePath.split('/')
256
+ let depth = 1
257
+ let candidate = slug
258
+ while (seenSlugs.has(candidate)) {
259
+ depth += 1
260
+ candidate = segments.slice(-depth).join('-') + '.md'
261
+ }
262
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
263
+ lines[i] = line.replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
264
+ slug = candidate
265
+ }
266
+ seenSlugs.add(slug)
267
+ }
268
+ return lines.join('\n')
269
+ }
270
+
271
+ // forceAll: bypasses resume-by-existence entirely, re-dispatching the skeleton and every
272
+ // component unconditionally — same two-call-site reasoning as generateRequirements(forceAll).
273
+ async function generateTechSpec(forceAll) {
274
+ // See generateRequirements's identical comment — staleness is both an mtime check and a
275
+ // structural-completeness check, consistent with the outer gate's own criteria.
276
+ const skeletonStale = c.exists(specIndexPath) &&
277
+ (!fresh(specIndexPath) || missingSections(c.read(specIndexPath), 12).length > 0)
278
+ const regenSkeleton = forceAll || skeletonStale || !c.exists(specIndexPath)
279
+
280
+ // 1. Skeleton (no declared worker() output — host writes it itself)
281
+ if (regenSkeleton) {
282
+ const skeleton = await c.worker(specId, featurePath)
283
+ c.writeOutput(specIndexPath, disambiguateFileSlugs(skeleton))
284
+ }
285
+
286
+ // 2. Structural pre-check (tech-spec-graph-checks.js, unmodified — FTR-023 — now pointed at
287
+ // the skeleton instead of the assembled document, strictly before any per-component token
288
+ // is spent)
289
+ await c.activity('tech-spec-ac-check', async () => {
290
+ const res = c.command(['run-asset', 'scripts/tech-spec-graph-checks.js', '--project', c.root,
291
+ '--', specIndexPath, reqIndexPath], [0, 1])
292
+ const parsed = JSON.parse(res.stdout)
293
+ if (typeof parsed.valid !== 'boolean' || !Array.isArray(parsed.errors) ||
294
+ (res.exitCode === 0) !== parsed.valid) throw new Error('Invalid tech-spec-ac-check result')
295
+ if (!parsed.valid) throw new Error('Tech-Spec has dangling AC references: ' + res.stdout)
296
+ return parsed
297
+ })
298
+
299
+ // 3. Per-component generation, resume-by-existence, sequential
300
+ const components = parseComponentsTable(c.read(specIndexPath))
301
+ for (const comp of components) {
302
+ const compPath = `${specDir}/components/${comp.fileSlug}`
303
+ if (!regenSkeleton && c.exists(compPath)) continue // resume: skip, no dispatch
304
+ const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath),
305
+ { label: `tech-spec-component:${comp.fileSlug}` })
306
+ c.writeOutput(compPath, detail)
307
+ }
308
+ // No assembly/write step here — generation only ever produces spec/index.md + its
309
+ // per-component files. Assembly happens in-memory, on demand, at the top level below (never
310
+ // persisted by this function); a user who wants a single combined file runs assemble-docs.js
311
+ // explicitly.
312
+ }
313
+
314
+ // In-memory assembly — same req-assembler.js/tech-spec-assembler.js subprocess calls
315
+ // generation used to write to disk itself (FTR-025); now the host keeps their stdout only in
316
+ // memory, for the validator's inlined prompt and docsDigest. Cheap and deterministic (no LLM
317
+ // cost), so safe to (re)compute unconditionally, including on a fully-fresh resume where
318
+ // neither generate function below is even called.
319
+ function assembleRequirements() {
320
+ return c.command(['run-asset', 'scripts/req-assembler.js', '--project', c.root, '--', reqDir]).stdout
321
+ }
322
+ function assembleTechSpec() {
323
+ return c.command(['run-asset', 'scripts/tech-spec-assembler.js', '--project', c.root, '--', specDir]).stdout
324
+ }
325
+
326
+ // The outer gate deciding whether to even call generateRequirements/generateTechSpec must
327
+ // notice a missing PER-ITEM file too, not just skeleton freshness/completeness — otherwise a
328
+ // crash that left one use-case/component file missing (while the skeleton itself is perfectly
329
+ // fine) would never be revisited on a non-force resume, since nothing about the skeleton's own
330
+ // freshness changed. A real gap found via this exact resume scenario: this used to be covered
331
+ // for free by gating on the assembled document's own freshness (which could never look
332
+ // "complete" while a referenced item was missing) — removing that gate (FTR-025) removed this
333
+ // side effect along with it, so it needs restating explicitly here.
334
+ const anyUcFileMissing = indexContent => parseUseCaseIndexTable(indexContent).some(row => !c.exists(`${reqDir}/${row.file}`))
335
+ const anyComponentFileMissing = indexContent => parseComponentsTable(indexContent).some(comp => !c.exists(`${specDir}/components/${comp.fileSlug}`))
336
+
20
337
  let changed = false
21
- if (force || !fresh(file('Requirements'))) {
22
- await requirements()
23
- if (!c.exists(file('Requirements'))) throw new Error('Requirements output missing')
338
+ if (force || !fresh(reqIndexPath) || missingSections(c.read(reqIndexPath), 9).length ||
339
+ (c.exists(reqIndexPath) && anyUcFileMissing(c.read(reqIndexPath)))) {
340
+ await generateRequirements(force)
341
+ if (!c.exists(reqIndexPath)) throw new Error('Requirements output missing')
342
+ assertCompleteSections('Requirements', c.read(reqIndexPath), 9)
24
343
  changed = true
25
344
  }
26
- if (force || changed || !fresh(file('Tech-Spec'), [featurePath, file('Requirements')])) {
27
- await techSpec()
28
- if (!c.exists(file('Tech-Spec'))) throw new Error('Tech-Spec output missing')
345
+ if (force || changed || !fresh(specIndexPath, [featurePath, reqIndexPath]) || missingSections(c.read(specIndexPath), 12).length ||
346
+ (c.exists(specIndexPath) && anyComponentFileMissing(c.read(specIndexPath)))) {
347
+ await generateTechSpec(force)
348
+ if (!c.exists(specIndexPath)) throw new Error('Tech-Spec output missing')
349
+ assertCompleteSections('Tech-Spec', c.read(specIndexPath), 12)
29
350
  changed = true
30
351
  }
352
+ let assembledRequirements = assembleRequirements()
353
+ let assembledTechSpec = assembleTechSpec()
31
354
  let validationSummary = 'skipped (fresh)'
32
- if (force || changed || !fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) {
355
+ if (force || changed || !fresh(file('Validation-Report'), [featurePath, reqIndexPath, specIndexPath])) {
33
356
  for (let cycle = 1; cycle <= 3; cycle++) {
34
357
  // needsRequirementsFix/needsTechSpecFix: explicit structured routing fields,
35
358
  // not inferred by substring-matching the free-text `findings` array (which a
36
359
  // finding mentioning both document names, or neither, would mis-route or
37
360
  // silently skip). The validator agent itself decides which document(s) its
38
361
  // own findings implicate.
39
- const result = await c.worker(valId, featurePath + '\nValidate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), needsRequirementsFix/needsTechSpecFix (true only if at least one finding implicates that document), and the full validation report content. Do not dispatch other agents.', { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, file('Requirements'), file('Tech-Spec')])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
362
+ const result = await c.worker(valId,
363
+ `${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${assembledTechSpec}\n\n` +
364
+ 'Validate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), needsRequirementsFix/needsTechSpecFix (true only if at least one finding implicates that document), and the full validation report content. Do not dispatch other agents.',
365
+ { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, reqIndexPath, specIndexPath])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
40
366
  // A model returning valid:true alongside non-empty findings (or valid:false
41
367
  // with none) contradicts its own schema's intent — never silently treated as
42
368
  // "not clean yet" and spent on another cycle without surfacing the
@@ -45,14 +371,14 @@ return c.run(meta.name, featureDir, prefix, async () => {
45
371
  throw new Error(`Validation agent returned a contradictory result on cycle ${cycle}: valid=${result.valid} with ${result.findings.length} finding(s)`)
46
372
  }
47
373
  if (result.valid) {
48
- if (!fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) throw new Error('Validation agent did not produce its report')
374
+ if (!fresh(file('Validation-Report'), [featurePath, reqIndexPath, specIndexPath])) throw new Error('Validation agent did not produce its report')
49
375
  validationSummary = `0 gaps (clean on cycle ${cycle})`
50
376
  break
51
377
  }
52
378
  if (cycle === 3) throw new Error('Validation gaps remain after 3 cycles')
53
379
  if (!result.needsRequirementsFix && !result.needsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
54
- if (result.needsRequirementsFix) await requirements()
55
- if (result.needsTechSpecFix) await techSpec()
380
+ if (result.needsRequirementsFix) { await generateRequirements(true); assembledRequirements = assembleRequirements() }
381
+ if (result.needsTechSpecFix) { await generateTechSpec(true); assembledTechSpec = assembleTechSpec() }
56
382
  }
57
383
  }
58
384
  // Binds Gate 1's eventual approval to the EXACT Requirements/Tech-Spec bytes
@@ -60,12 +386,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
60
386
  // Step 2 embeds this verbatim into Approvals.md, and pm-phase2.js's Gate 1 check
61
387
  // fails closed if the current file content no longer matches it (content
62
388
  // regenerated after approval, e.g. via a later --force run, must re-approve).
63
- const docsDigest = c.hash(c.read(file('Requirements')) + '\u0000' + c.read(file('Tech-Spec')))
389
+ const docsDigest = c.hash(assembledRequirements + '\u0000' + assembledTechSpec)
64
390
  c.append(`${featureDir}/${prefix}-process-log.txt`, `[${new Date().toISOString()}] pm-phase1: ${validationSummary}; Gate 1 approval requested\n`)
65
391
  return {
66
392
  prefix, feature_dir: featureDir, feature_path: featurePath,
67
- requirements: { path: file('Requirements'), summary: 'Requirements ready' },
68
- tech_spec: { path: file('Tech-Spec'), summary: 'Tech-Spec ready' },
393
+ requirements: { path: reqIndexPath, summary: 'Requirements ready (split; req/index.md + req/use-cases/*.md)' },
394
+ tech_spec: { path: specIndexPath, summary: 'Tech-Spec ready (split; spec/index.md + spec/components/*.md)' },
69
395
  validation: { path: file('Validation-Report'), summary: validationSummary },
70
396
  docs_digest: docsDigest,
71
397
  token_ledger: c.tokenLedger, errors: [],