@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.
- package/CHANGELOG.md +30 -0
- package/lib/agent-registry.js +39 -0
- package/lib/workflow-control.js +65 -13
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-requirements-use-case.md +97 -0
- package/src/claude/agents/gaia-generate-requirements.md +22 -27
- package/src/claude/agents/gaia-generate-tech-spec-component.md +93 -0
- package/src/claude/agents/gaia-generate-tech-spec.md +47 -103
- package/src/claude/agents/gaia-generate-work-breakdown-task.md +151 -0
- package/src/claude/agents/gaia-generate-work-breakdown.md +94 -100
- package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
- package/src/claude/commands/assemble-docs.md +16 -0
- package/src/claude/commands/feature-status.md +9 -4
- package/src/claude/scripts/ac-task-matrix.js +87 -0
- package/src/claude/scripts/assemble-docs.js +66 -0
- package/src/claude/scripts/req-assembler.js +127 -0
- package/src/claude/scripts/req-graph-checks.js +71 -0
- package/src/claude/scripts/req-skeleton-check.js +64 -0
- package/src/claude/scripts/tech-spec-assembler.js +188 -0
- package/src/claude/scripts/tech-spec-graph-checks.js +84 -0
- package/src/claude/scripts/templates/gaia-component-template.md +64 -0
- package/src/claude/scripts/templates/gaia-dependency-graph-template.md +85 -0
- package/src/claude/scripts/templates/gaia-requirements-index-template.md +144 -0
- package/src/claude/scripts/templates/gaia-task-template.md +92 -0
- package/src/claude/scripts/templates/gaia-tech-spec-index-template.md +137 -0
- package/src/claude/scripts/templates/gaia-use-case-template.md +56 -0
- package/src/claude/scripts/templates/gaia-user-story-template.md +61 -0
- package/src/claude/scripts/wb-graph-checks.js +754 -0
- package/src/claude/scripts/wb-render.js +6 -11
- package/src/claude/scripts/wb-skeleton-check.js +99 -0
- package/src/claude/scripts/wb-validate.js +49 -655
- package/src/claude/scripts/wbs-assembler.js +368 -0
- package/src/claude/skills/implement-feature/SKILL.md +12 -6
- package/src/claude/workflows/pm-phase1.js +342 -16
- 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
|
-
|
|
19
|
-
|
|
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(
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
55
|
-
if (result.needsTechSpecFix) await
|
|
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(
|
|
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:
|
|
68
|
-
tech_spec: { path:
|
|
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: [],
|