@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.
- package/CHANGELOG.md +29 -0
- package/lib/agent-registry.js +39 -0
- 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 +124 -105
- package/src/claude/agents/gaia-validate-feature-docs.md +13 -8
- package/src/claude/agents/gaia-validate-work-breakdown-semantic.md +9 -4
- 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 +121 -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 +324 -18
- 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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
'--',
|
|
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
|
-
`${
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [],
|