@dtlabs/ai-toolkit 0.24.1 → 0.24.3
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 +21 -0
- package/lib/workflow-control.js +32 -4
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-work-breakdown-phase.md +17 -6
- package/src/claude/agents/gaia-generate-work-breakdown-task.md +8 -6
- package/src/claude/workflows/pm-phase1.js +141 -71
- package/src/claude/workflows/pm-phase2.js +118 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.24.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.24.2...v0.24.3) (2026-10-27)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* don't fail a worker call over a read-only permission denial ([1b4c521](https://github.com/dtlab-tech/ai-toolkit/commit/1b4c5217b42e9c9dcb74c9bbe60f209522644f9c))
|
|
8
|
+
* **workflow-control:** don't fail a worker call over a read-only permission denial ([fb283c6](https://github.com/dtlab-tech/ai-toolkit/commit/fb283c6a0f44f56d0ca152bcf84bf81c1ca10125))
|
|
9
|
+
|
|
10
|
+
## [0.24.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.24.1...v0.24.2) (2026-10-26)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **pm-phase1:** make Tech-Spec component identity permanent via a manifest ([642527f](https://github.com/dtlab-tech/ai-toolkit/commit/642527ffd7de4269d663330a02e1f652920a097e))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Performance Improvements
|
|
19
|
+
|
|
20
|
+
* bound worker prompt context and stop source-tree exploration ([ebd273b](https://github.com/dtlab-tech/ai-toolkit/commit/ebd273b541fa4adbe34c18fc6172acacc3cb2854))
|
|
21
|
+
* **pm-phase1,pm-phase2:** bound worker prompt context and stop source-tree exploration ([801681a](https://github.com/dtlab-tech/ai-toolkit/commit/801681abbcbdff83600ca68c7434729c9cd27be6))
|
|
22
|
+
|
|
2
23
|
## [0.24.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.24.0...v0.24.1) (2026-10-24)
|
|
3
24
|
|
|
4
25
|
|
package/lib/workflow-control.js
CHANGED
|
@@ -69,7 +69,7 @@ function deriveFeatureDir(featurePath) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function createControl(options) {
|
|
72
|
-
if (options.workerMaxBudgetUsd != null && (!Number.isFinite(options.workerMaxBudgetUsd) || options.workerMaxBudgetUsd
|
|
72
|
+
if (options.workerMaxBudgetUsd != null && (!Number.isFinite(options.workerMaxBudgetUsd) || options.workerMaxBudgetUsd < 0)) throw new Error('Worker budget must be a non-negative finite number (0 disables the cap)');
|
|
73
73
|
// Windows can alias any long/dotted path segment to an 8.3 short name (e.g. the
|
|
74
74
|
// TEMP env var itself is often short-form: C:\Users\TOMADA~1\...). The spawned
|
|
75
75
|
// CLI resolves a tool call's absolute path in long form regardless, so an
|
|
@@ -148,7 +148,12 @@ function createControl(options) {
|
|
|
148
148
|
// call sites for the same reasoning).
|
|
149
149
|
const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'];
|
|
150
150
|
if (opts.schema) cliArgs.push('--json-schema', JSON.stringify(opts.schema));
|
|
151
|
-
|
|
151
|
+
// Runaway circuit breaker, not a tuning knob: a single worker call has no legitimate reason
|
|
152
|
+
// to cost multiple dollars, but real runs have produced $3+ calls when an agent fell into a
|
|
153
|
+
// long self-directed exploration loop. Opt-out (explicit 0 disables) rather than opt-in,
|
|
154
|
+
// because the failure it guards against is exactly the one nobody thinks to configure for.
|
|
155
|
+
const budgetUsd = options.workerMaxBudgetUsd != null ? options.workerMaxBudgetUsd : DEFAULT_WORKER_MAX_BUDGET_USD;
|
|
156
|
+
if (budgetUsd) cliArgs.push('--max-budget-usd', String(budgetUsd));
|
|
152
157
|
const outputs = opts.outputs || [];
|
|
153
158
|
for (const output of outputs) {
|
|
154
159
|
if (!output || typeof output.path !== 'string' || !Array.isArray(output.inputs || [])) throw new Error('Invalid worker output contract');
|
|
@@ -225,10 +230,27 @@ function createControl(options) {
|
|
|
225
230
|
tokenLedger.push({ agent: identity.nativeName, model: definition.model,
|
|
226
231
|
phase_delta_tokens: measuredTokens, usage: result.result?.usage || null, evidence_path: evidence.file });
|
|
227
232
|
evidence.save({ response: result, attempts });
|
|
233
|
+
// A denial on a read-only tool (Read/Glob/Grep) means the agent couldn't fetch some EXTRA
|
|
234
|
+
// context — a real, recurring pattern where a path the model constructs itself (e.g. a
|
|
235
|
+
// single leading backslash with no drive letter) resolves outside the project directory
|
|
236
|
+
// and is correctly denied. Confirmed via production evidence twice, on two different
|
|
237
|
+
// paths: the agent recovers and still produces a complete, structurally valid result, which
|
|
238
|
+
// this unconditional check threw away anyway, wasting the entire call and forcing a resume.
|
|
239
|
+
// Any OTHER tool (Write/Edit/Bash/...) stays an immediate, unconditional failure — a denial
|
|
240
|
+
// there is a near-miss on a real side effect, not a missed read, and must never be silently
|
|
241
|
+
// tolerated regardless of what the rest of the response looks like.
|
|
242
|
+
const READ_ONLY_DENIAL_TOOLS = new Set(['Read', 'Glob', 'Grep'])
|
|
228
243
|
const denials = result.result?.permission_denials;
|
|
229
244
|
if (Array.isArray(denials) && denials.length) {
|
|
230
|
-
const
|
|
231
|
-
|
|
245
|
+
const nonReadOnly = denials.filter(d => !READ_ONLY_DENIAL_TOOLS.has(d.tool_name));
|
|
246
|
+
if (nonReadOnly.length) {
|
|
247
|
+
const tools = [...new Set(nonReadOnly.map(d => d.tool_name || 'unknown'))].join(', ');
|
|
248
|
+
throw new Error('Worker permission denied: ' + id + '; tools: ' + tools);
|
|
249
|
+
}
|
|
250
|
+
// Read-only denial(s) only: fall through to the normal result-validity checks below —
|
|
251
|
+
// an agent that actually proceeded with missing context will still fail there (a bad
|
|
252
|
+
// exitCode/is_error, or the caller's own downstream structural validation), the same way
|
|
253
|
+
// it would if no denial had ever happened.
|
|
232
254
|
}
|
|
233
255
|
if (result.exitCode !== 0 || result.parseError || result.timedOut ||
|
|
234
256
|
!result.result || result.result.is_error !== false) {
|
|
@@ -361,6 +383,12 @@ function createControl(options) {
|
|
|
361
383
|
// retried by hand from the CLI. 3 total attempts, waiting 5s then 20s between them: long
|
|
362
384
|
// enough for a brief network stall to clear, short enough not to turn a real, deterministic
|
|
363
385
|
// failure into a multi-minute stall before it's reported.
|
|
386
|
+
// Deliberately well above any healthy call (the most expensive legitimate worker measured across
|
|
387
|
+
// two real projects was $1.96, and the prompt-scoping work done alongside this brings that down)
|
|
388
|
+
// — this exists to stop a runaway, not to shape normal spend. Override per run with
|
|
389
|
+
// --worker-max-budget-usd; pass 0 to disable entirely.
|
|
390
|
+
const DEFAULT_WORKER_MAX_BUDGET_USD = 5;
|
|
391
|
+
|
|
364
392
|
const WORKER_MAX_ATTEMPTS = 3;
|
|
365
393
|
const WORKER_RETRY_BACKOFF_MS = [5000, 20000];
|
|
366
394
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: gaia-generate-work-breakdown-phase
|
|
3
3
|
description: "Generates one phase's own tasks (structural fields) and narrative context for a Work Breakdown, given that phase's scoped Requirements/Tech-Spec context and every prior phase's already-decided tasks. Input: the phase to generate, its scoped Requirements excerpt, the Tech-Spec skeleton, and prior-phase task projections"
|
|
4
4
|
model: sonnet
|
|
5
|
-
tools: Read
|
|
5
|
+
tools: Read
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Generate Work Breakdown — Per-Phase Task Pass
|
|
@@ -30,12 +30,22 @@ Your prompt directly inlines:
|
|
|
30
30
|
(not full task objects): use these to reference a real prerequisite in `dependsOn`, and to
|
|
31
31
|
recognize/avoid re-implementing something a prior phase already built. You will NEVER be given
|
|
32
32
|
a LATER phase's tasks — they do not exist yet; never invent a dependency on one.
|
|
33
|
+
5. **The design detail of the components this phase's own Acceptance Criteria cover** — the host
|
|
34
|
+
resolves this deterministically and inlines it. This is the technical design you need; it
|
|
35
|
+
already describes the real files, signatures and algorithms involved.
|
|
36
|
+
6. **The project's build/verification conventions**, extracted from `AGENTS.md` by the host.
|
|
33
37
|
|
|
34
|
-
##
|
|
38
|
+
## Do not explore the project's source tree
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
Everything above is supplied to you. Do **not** go reading the project's real source files to
|
|
41
|
+
rediscover a design that is already inlined, and do not read `AGENTS.md` yourself — the host has
|
|
42
|
+
already extracted the relevant part. This is not a style preference: on real features, self-directed
|
|
43
|
+
source exploration has cost 36-43 turns and several dollars per call, with the great majority of
|
|
44
|
+
that spent re-deriving information the prompt already contained.
|
|
45
|
+
|
|
46
|
+
If a decision genuinely needs a component's design body that was NOT inlined, ask for it through
|
|
47
|
+
`needsComponentDetail` (see below) — that is the supported, bounded path, and it is far cheaper
|
|
48
|
+
than exploring.
|
|
39
49
|
|
|
40
50
|
## Atomicity Contract
|
|
41
51
|
|
|
@@ -131,7 +141,8 @@ in the common case.
|
|
|
131
141
|
|
|
132
142
|
- Write in English. Derive content only from the inlined Requirements/Tech-Spec — never invent a
|
|
133
143
|
task not grounded in them.
|
|
134
|
-
- Never self-explore the filesystem
|
|
144
|
+
- Never self-explore the filesystem — Requirements, Tech-Spec, component design detail and build
|
|
145
|
+
conventions are all inlined (see "Do not explore the project's source tree" above).
|
|
135
146
|
- Atomicity is mandatory (see Atomicity Contract above).
|
|
136
147
|
- `dependsOn` may reference a task from this phase or any prior phase — never a later one, never a
|
|
137
148
|
dangling reference.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gaia-generate-work-breakdown-task
|
|
3
3
|
description: "Generates one task's detail block (implementation notes, verification commands, grouping rationale) from its skeleton entry. Input: skeleton task entry + Tech-Spec excerpt + compact sibling task list"
|
|
4
|
-
model:
|
|
4
|
+
model: haiku
|
|
5
5
|
tools: Read, Glob, Grep
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -28,15 +28,17 @@ The caller supplies, in the prompt (you do not read `{PREFIX}-Work-Breakdown.jso
|
|
|
28
28
|
fallback either. This section and the Output section below summarize it; the inlined template
|
|
29
29
|
text is the source of truth if the two ever disagree.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
5. **The project's build/verification conventions**, extracted from `AGENTS.md` by the host and inlined under its own label. Use that text for your verification commands — do **not** read `AGENTS.md` yourself; it is tens of kilobytes and the host has already pulled out the command-bearing sections.
|
|
32
|
+
|
|
33
|
+
If the prompt is missing any of these, stop and report exactly what is missing — do not guess at a skeleton entry you were not given.
|
|
32
34
|
|
|
33
35
|
---
|
|
34
36
|
|
|
35
|
-
##
|
|
37
|
+
## Scope of the Tech-Spec you are given
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
You receive an index line for every component your task's Acceptance Criteria cover, plus the full design body only for the component(s) your own task names. That is deliberate: a task normally touches one or two files, and inlining every AC-matched body measured over 100 KB per call on a real feature.
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
If the body you actually need is missing, you may read that one component file under `spec/components/` — but do not go exploring the project's wider source tree to re-derive a design the Tech-Spec already states.
|
|
40
42
|
|
|
41
43
|
---
|
|
42
44
|
|
|
@@ -83,7 +85,7 @@ Before writing verification commands, scan the compact sibling task list for any
|
|
|
83
85
|
|
|
84
86
|
## Verification Commands
|
|
85
87
|
|
|
86
|
-
Supply one or more shell commands in `verification.commands` that an agent can run to confirm the task's output is correct. The array must be non-empty. Use commands appropriate for the project's tech stack
|
|
88
|
+
Supply one or more shell commands in `verification.commands` that an agent can run to confirm the task's output is correct. The array must be non-empty. Use commands appropriate for the project's tech stack, taken from the build/verification conventions inlined in your prompt. Examples:
|
|
87
89
|
- Compilation check: `npx tsc --noEmit`, `dotnet build`, `node --check <file>`
|
|
88
90
|
- Targeted test run: `npm test -- --testPathPattern=<file>`, `npx jest --testPathPattern=<file>`, `dotnet test --filter <class>`
|
|
89
91
|
- File existence: `test -f <path>`
|
|
@@ -129,7 +129,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
129
129
|
// which is exactly why the per-UC generation agent is never given this data at all (see
|
|
130
130
|
// gaia-generate-requirements-use-case.md: it must not re-read req/index.md itself, to avoid
|
|
131
131
|
// re-deciding which UCs exist). The host computes and appends these cross-reference lines
|
|
132
|
-
// itself, after the agent's own response, mirroring how
|
|
132
|
+
// itself, after the agent's own response, mirroring how applyComponentSlugs/computeDependsOnPhases
|
|
133
133
|
// post-process agent/skeleton output elsewhere in this lineage.
|
|
134
134
|
function parseBusinessRulesTable(indexContent) {
|
|
135
135
|
const lines = indexContent.split('\n')
|
|
@@ -243,6 +243,96 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
243
243
|
const specDir = `${featureDir}/spec`
|
|
244
244
|
const specIndexPath = `${specDir}/index.md`
|
|
245
245
|
|
|
246
|
+
// The permanent identity registry for Tech-Spec components, replacing fileSlug's own
|
|
247
|
+
// per-table-position disambiguation. A slug used to be disambiguated only against the OTHER
|
|
248
|
+
// slugs present in the SAME skeleton table, so the very same source file could be `App.tsx.md`
|
|
249
|
+
// in one generation and `shell-app-App.tsx.md` in the next purely because the component set
|
|
250
|
+
// around it changed — which also meant FTR-030's regen row-diffing (keyed on that slug) missed
|
|
251
|
+
// exactly when the table changed, i.e. precisely when it was supposed to help, regenerating
|
|
252
|
+
// everything and stranding the old files as orphans. Two append-only, sticky tables instead:
|
|
253
|
+
// `pathHashes` (a directory's own short hash, computed once and reused for every sibling file
|
|
254
|
+
// from that directory that ever needs disambiguating) and `components` (a filePath's own
|
|
255
|
+
// permanently-assigned slug plus bookkeeping). Neither table is ever rewritten once an entry
|
|
256
|
+
// exists — see assignComponentSlug's own comment for why that matters.
|
|
257
|
+
const manifestPath = `${specDir}/components.manifest.json`
|
|
258
|
+
function loadManifest() {
|
|
259
|
+
if (!c.exists(manifestPath)) return { pathHashes: {}, components: {} }
|
|
260
|
+
const parsed = c.json(manifestPath)
|
|
261
|
+
return { pathHashes: parsed.pathHashes || {}, components: parsed.components || {} }
|
|
262
|
+
}
|
|
263
|
+
function saveManifest(manifest) {
|
|
264
|
+
c.writeOutput(manifestPath, JSON.stringify(manifest, null, 2))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Assigns (or reuses) one component's permanent slug. Basename-first for human readability —
|
|
268
|
+
// `App.tsx.md` reads far better than any path-derived name — falling back to a directory-level
|
|
269
|
+
// hash prefix ONLY when the basename collides with another file's ALREADY-assigned slug. The
|
|
270
|
+
// hash is computed from the component's own directory (not its full path), and cached in
|
|
271
|
+
// `pathHashes` so every other file from that same directory that ever needs disambiguating gets
|
|
272
|
+
// the SAME prefix — siblings become visually recognizable as a group (`a1b2c3d4-App.tsx.md`,
|
|
273
|
+
// `a1b2c3d4-main.tsx.md`) instead of each getting an unrelated-looking hash.
|
|
274
|
+
//
|
|
275
|
+
// Both lookups are permanent: an already-assigned slug is returned as-is, never recomputed, so a
|
|
276
|
+
// LATER file claiming the same basename can never force an EARLIER file to rename — the exact
|
|
277
|
+
// instability the old per-table disambiguation had (see the comment on loadManifest's own
|
|
278
|
+
// declaration above for the production consequence this caused). One real, accepted side
|
|
279
|
+
// effect: if the file that originally won a clean basename is later removed from the Tech-Spec,
|
|
280
|
+
// its slug is never freed — every sibling that collided with it keeps its hash prefix even
|
|
281
|
+
// though the collision is now only historical.
|
|
282
|
+
// Writes a provisional `{ slug }` entry immediately (not just returns it), so a second file with
|
|
283
|
+
// the same basename processed later in this SAME pass correctly sees the first as already taken.
|
|
284
|
+
function assignComponentSlug(manifest, filePath) {
|
|
285
|
+
const existing = manifest.components[filePath]
|
|
286
|
+
if (existing) return existing.slug
|
|
287
|
+
const basename = filePath.split('/').pop()
|
|
288
|
+
const cleanSlug = `${basename}.md`
|
|
289
|
+
const taken = Object.entries(manifest.components).some(([p, e]) => p !== filePath && e.slug === cleanSlug)
|
|
290
|
+
let slug
|
|
291
|
+
if (!taken) {
|
|
292
|
+
slug = cleanSlug
|
|
293
|
+
} else {
|
|
294
|
+
const dir = filePath.slice(0, filePath.length - basename.length - 1) || '.'
|
|
295
|
+
let dirHash = manifest.pathHashes[dir]
|
|
296
|
+
if (!dirHash) {
|
|
297
|
+
dirHash = c.hash(dir).replace(/^sha256:/, '').slice(0, 8)
|
|
298
|
+
manifest.pathHashes[dir] = dirHash
|
|
299
|
+
}
|
|
300
|
+
slug = `${dirHash}-${basename}.md`
|
|
301
|
+
}
|
|
302
|
+
manifest.components[filePath] = { slug }
|
|
303
|
+
return slug
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Rewrites every row's Spec-link in a freshly-generated skeleton to the manifest-assigned slug
|
|
307
|
+
// — never trusting whatever the agent's own response contains there (the component template's
|
|
308
|
+
// own contract already calls that link "provisional, not final" for exactly this reason: a real
|
|
309
|
+
// skeleton omitted the ".md" suffix entirely on 20 of ~61 rows). Host-computed from `filePath`
|
|
310
|
+
// alone, so a malformed, missing-suffix, or merely different-looking slug in the agent's own
|
|
311
|
+
// text is irrelevant — only well-formed `(components/...)` rows are found at all; an entirely
|
|
312
|
+
// absent Spec link is left for parseComponentsTable's own existing "malformed Spec column" error
|
|
313
|
+
// to catch later, unchanged.
|
|
314
|
+
//
|
|
315
|
+
// Operates per-LINE, not via a global string .replace() over the whole document — same real bug
|
|
316
|
+
// this function's FTR-027 predecessor (disambiguateFileSlugs) was written to avoid: when two
|
|
317
|
+
// rows share byte-identical original Spec text, a global .replace() always rewrites the FIRST
|
|
318
|
+
// occurrence, never the specific row actually being processed.
|
|
319
|
+
function applyComponentSlugs(skeletonText, manifest) {
|
|
320
|
+
const lines = skeletonText.split('\n')
|
|
321
|
+
for (let i = 0; i < lines.length; i++) {
|
|
322
|
+
const line = lines[i]
|
|
323
|
+
if (!line.trim().startsWith('|')) continue
|
|
324
|
+
const cells = line.split('|').map(c => c.trim()).filter((_, idx, a) => idx > 0 && idx < a.length - 1)
|
|
325
|
+
if (cells.length < 5) continue
|
|
326
|
+
const filePath = cells[0]
|
|
327
|
+
const m = cells[4] && cells[4].match(/\(components\/([^)]+)\)/)
|
|
328
|
+
if (!m) continue // header/separator row, or a row with no Spec link at all yet
|
|
329
|
+
const slug = assignComponentSlug(manifest, filePath)
|
|
330
|
+
const escaped = m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
331
|
+
lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${slug}`)
|
|
332
|
+
}
|
|
333
|
+
return lines.join('\n')
|
|
334
|
+
}
|
|
335
|
+
|
|
246
336
|
// Duplicated (not imported) from tech-spec-assembler.js's own export of the same name — this
|
|
247
337
|
// runtime has no `require`, so getting this via another c.command round-trip would be
|
|
248
338
|
// unnecessary overhead for a parse already needed in-process to drive the per-component loop.
|
|
@@ -397,58 +487,36 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
397
487
|
return `${base}\n\nTemplate (spec/components/{fileSlug}.md):\n${componentTemplate}`
|
|
398
488
|
}
|
|
399
489
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
490
|
+
// A row's own content fingerprint — the same four fields FTR-030's row-diffing has always
|
|
491
|
+
// compared (changeType/purposeOrChange/addresses/domain), hashed instead of held onto and
|
|
492
|
+
// compared field-by-field. Stored in the manifest so the NEXT generation's diff needs nothing
|
|
493
|
+
// but this one value, not a re-parsed copy of the previous skeleton.
|
|
494
|
+
function computeRowHash(comp) {
|
|
495
|
+
return c.hash(JSON.stringify({
|
|
496
|
+
changeType: comp.changeType, purposeOrChange: comp.purposeOrChange,
|
|
497
|
+
addresses: comp.addresses, domain: comp.domain,
|
|
498
|
+
}))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Refreshes every current row's manifest bookkeeping (slug already settled by
|
|
502
|
+
// applyComponentSlugs; this fills in rowHash and lastSeenInSkeleton) and reports, per filePath,
|
|
503
|
+
// whether its row is byte-for-byte unchanged from what the manifest held BEFORE this call — the
|
|
504
|
+
// same "did this component's own row actually change" signal FTR-030 introduced, now backed by
|
|
505
|
+
// the manifest instead of a second parse of the prior skeleton file.
|
|
414
506
|
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
function
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (cells.length < 5) continue
|
|
427
|
-
const filePath = cells[0]
|
|
428
|
-
const m = cells[4].match(/\(components\/([^)]+)\)/)
|
|
429
|
-
if (!m) continue // header/separator row, or a row with no valid Spec link yet
|
|
430
|
-
let slug = m[1]
|
|
431
|
-
if (!slug.endsWith('.md')) {
|
|
432
|
-
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
433
|
-
const normalized = `${slug}.md`
|
|
434
|
-
lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${normalized}`)
|
|
435
|
-
slug = normalized
|
|
436
|
-
}
|
|
437
|
-
if (seenSlugs.has(slug)) {
|
|
438
|
-
const segments = filePath.split('/')
|
|
439
|
-
let depth = 1
|
|
440
|
-
let candidate = slug
|
|
441
|
-
while (seenSlugs.has(candidate)) {
|
|
442
|
-
depth += 1
|
|
443
|
-
candidate = segments.slice(-depth).join('-') + '.md'
|
|
444
|
-
}
|
|
445
|
-
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
446
|
-
lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
|
|
447
|
-
slug = candidate
|
|
448
|
-
}
|
|
449
|
-
seenSlugs.add(slug)
|
|
507
|
+
// Called unconditionally on every generateTechSpec pass (not just when the skeleton itself was
|
|
508
|
+
// regenerated), so `lastSeenInSkeleton` stays current even on a pure per-component resume pass —
|
|
509
|
+
// a component absent from this call for one or more passes becomes identifiable as orphaned
|
|
510
|
+
// (its entry's lastSeenInSkeleton stops advancing) without any extra bookkeeping of its own.
|
|
511
|
+
function updateManifestBookkeeping(manifest, components, nowIso) {
|
|
512
|
+
const rowUnchangedByPath = new Map()
|
|
513
|
+
for (const comp of components) {
|
|
514
|
+
const prior = manifest.components[comp.filePath]
|
|
515
|
+
const rowHash = computeRowHash(comp)
|
|
516
|
+
rowUnchangedByPath.set(comp.filePath, !!prior && prior.rowHash === rowHash)
|
|
517
|
+
manifest.components[comp.filePath] = { slug: comp.fileSlug, rowHash, lastSeenInSkeleton: nowIso }
|
|
450
518
|
}
|
|
451
|
-
return
|
|
519
|
+
return rowUnchangedByPath
|
|
452
520
|
}
|
|
453
521
|
|
|
454
522
|
// forceAll: bypasses resume-by-existence entirely, re-dispatching the skeleton and every
|
|
@@ -460,28 +528,23 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
460
528
|
(!fresh(specIndexPath) || missingSections(c.read(specIndexPath), 12).length > 0)
|
|
461
529
|
const regenSkeleton = forceAll || skeletonStale || !c.exists(specIndexPath)
|
|
462
530
|
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
// was flagged wrong that per-row diffing cannot see (e.g. a missing cross-reference only
|
|
472
|
-
// visible in a component's own body) — that path keeps today's existing "regenerate
|
|
473
|
-
// everything unconditionally" behavior, unchanged.
|
|
474
|
-
const oldComponentsByFileSlug = new Map()
|
|
475
|
-
if (!forceAll && regenSkeleton && c.exists(specIndexPath)) {
|
|
531
|
+
const manifest = loadManifest()
|
|
532
|
+
// Bootstrap: a project with an existing spec/index.md but no manifest yet (upgrading onto
|
|
533
|
+
// this scheme) has Spec-links assigned by the OLD per-table-position disambiguation. Adopt
|
|
534
|
+
// those slugs as-is — never recompute/rename them — so the very first run after upgrading
|
|
535
|
+
// does not rename every already-generated component file. This also covers the common
|
|
536
|
+
// resume-only pass below (no regen at all): components are read from the EXISTING file, so
|
|
537
|
+
// their current slug is simply what updateManifestBookkeeping records.
|
|
538
|
+
if (Object.keys(manifest.components).length === 0 && c.exists(specIndexPath)) {
|
|
476
539
|
try {
|
|
477
|
-
for (const comp of parseComponentsTable(c.read(specIndexPath)))
|
|
478
|
-
} catch (e) { /* an unparseable
|
|
540
|
+
for (const comp of parseComponentsTable(c.read(specIndexPath))) manifest.components[comp.filePath] = { slug: comp.fileSlug }
|
|
541
|
+
} catch (e) { /* an unparseable existing skeleton has nothing to adopt — start fresh */ }
|
|
479
542
|
}
|
|
480
543
|
|
|
481
544
|
// 1. Skeleton (no declared worker() output — host writes it itself)
|
|
482
545
|
if (regenSkeleton) {
|
|
483
546
|
const skeleton = await c.worker(specId, withTemplate(featurePath, 'spec/index.md', 'gaia-tech-spec-index-template.md'))
|
|
484
|
-
c.writeOutput(specIndexPath,
|
|
547
|
+
c.writeOutput(specIndexPath, applyComponentSlugs(skeleton, manifest))
|
|
485
548
|
}
|
|
486
549
|
|
|
487
550
|
// 2. Structural pre-check (tech-spec-graph-checks.js, unmodified — FTR-023 — now pointed at
|
|
@@ -499,6 +562,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
499
562
|
|
|
500
563
|
// 3. Per-component generation, resume-by-existence, sequential
|
|
501
564
|
const components = parseComponentsTable(c.read(specIndexPath))
|
|
565
|
+
// FTR-030, manifest-backed: refreshed on EVERY pass (not just when the skeleton itself was
|
|
566
|
+
// regenerated), so lastSeenInSkeleton stays current even on a pure per-component resume —
|
|
567
|
+
// see updateManifestBookkeeping's own comment. Persisted immediately, before any worker call,
|
|
568
|
+
// so a crash mid-loop never loses slug/bookkeeping state already settled this pass.
|
|
569
|
+
const rowUnchangedByPath = updateManifestBookkeeping(manifest, components, new Date().toISOString())
|
|
570
|
+
saveManifest(manifest)
|
|
502
571
|
// Read once, outside the loop — the template is identical for every component in this run.
|
|
503
572
|
const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
|
|
504
573
|
for (const comp of components) {
|
|
@@ -508,10 +577,11 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
508
577
|
// NEW or actually CHANGED does. `regenSkeleton` alone used to force every component to
|
|
509
578
|
// redispatch unconditionally the moment the skeleton changed at all; this narrows that to
|
|
510
579
|
// rows that genuinely changed, while still regenerating any row that's new or different.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
580
|
+
// Deliberately ignored when forceAll is true: an explicit full regen (a `--force` run, or a
|
|
581
|
+
// needsTechSpecFix revision cycle) means something was flagged wrong that per-row diffing
|
|
582
|
+
// cannot see (e.g. a missing cross-reference only visible in a component's own body) — that
|
|
583
|
+
// path keeps today's existing "regenerate everything unconditionally" behavior, unchanged.
|
|
584
|
+
const rowUnchanged = !forceAll && !!rowUnchangedByPath.get(comp.filePath)
|
|
515
585
|
if ((!regenSkeleton || rowUnchanged) && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
|
|
516
586
|
// FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
|
|
517
587
|
// component — a bad result otherwise silently continued through every remaining component,
|
|
@@ -312,12 +312,75 @@ function buildPhaseRequirementsExcerpt(reqIndexContent, sourceUC, reqDirArg) {
|
|
|
312
312
|
return `${reqIndexContent}\n\n${ucBodies}`
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
// Parses req/index.md's Section 7 Acceptance Criteria table. Positional, tolerating both the
|
|
316
|
+
// 3-column (ID|Criterion|Related UC) and 4-column (… |Priority) shapes this lineage has used —
|
|
317
|
+
// only ID and Related UC are ever read. "All UCs" is preserved verbatim; callers treat it as
|
|
318
|
+
// matching every phase.
|
|
319
|
+
function parseAcTableLocal(reqIndexContent) {
|
|
320
|
+
const { start, end, lines } = _findSectionRange(reqIndexContent, 7)
|
|
321
|
+
return _parseTableRows(lines, start, end)
|
|
322
|
+
.map(cells => ({ id: cells[0], relatedUC: cells[2] || '' }))
|
|
323
|
+
.filter(row => /^AC-/.test(row.id || ''))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Compact one-line-per-component index: everything Section 9's own table already says about a
|
|
327
|
+
// component EXCEPT its design body. Cheap (~25 tokens/row) and always safe to inline in full —
|
|
328
|
+
// it is what lets a worker know a component exists (and name it in a drill-down request) without
|
|
329
|
+
// paying for its body up front.
|
|
330
|
+
function buildComponentIndexRows(components) {
|
|
331
|
+
return components.map(comp =>
|
|
332
|
+
`- ${comp.filePath} (${comp.changeType}, ${comp.domain}) — addresses ${comp.addresses || '—'} — ${comp.purposeOrChange || ''}`
|
|
333
|
+
).join('\n')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Cost-hardening: a worker handed only Section 9's index rows will, given Read/Glob/Grep, go read
|
|
337
|
+
// the project's REAL source tree to recover the design it was not given — confirmed against real
|
|
338
|
+
// runs where per-phase calls burned 36-43 turns and $2-3 each doing exactly that, with ~80% of
|
|
339
|
+
// their output tokens being exploration reasoning rather than deliverable. Inlining the component
|
|
340
|
+
// DESIGN bodies the host can already identify deterministically is far cheaper than letting the
|
|
341
|
+
// model rediscover them. Cap is a safety valve, not the expected case.
|
|
342
|
+
const PHASE_COMPONENT_MAX = 14
|
|
343
|
+
function buildPhaseComponentExcerpt(phase, components, acTable, specDirArg) {
|
|
344
|
+
const sourceUC = phase.sourceUC || []
|
|
345
|
+
const phaseAcIds = new Set(acTable
|
|
346
|
+
.filter(row => /all\s+ucs/i.test(row.relatedUC) ||
|
|
347
|
+
row.relatedUC.split(',').map(s => s.trim()).some(uc => sourceUC.includes(uc)))
|
|
348
|
+
.map(row => row.id))
|
|
349
|
+
const relevant = components
|
|
350
|
+
.filter(comp => (comp.addresses || '').split(',').map(s => s.trim()).some(ac => phaseAcIds.has(ac)))
|
|
351
|
+
.slice(0, PHASE_COMPONENT_MAX)
|
|
352
|
+
if (relevant.length === 0) return null
|
|
353
|
+
return relevant.map(comp =>
|
|
354
|
+
`### ${comp.filePath}\n\n${c.read(`${specDirArg}/components/${comp.fileSlug}`)}`).join('\n\n')
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Cost-hardening: AGENTS.md is read in full by several agents on EVERY call purely to recover
|
|
358
|
+
// build/verification commands (26-36 KB on the two real projects measured — 7-9K tokens per call,
|
|
359
|
+
// paid once per task). The host extracts just the command-bearing sections instead and inlines
|
|
360
|
+
// them, which measured 87-91% smaller on both. Returns null when nothing matches, so the caller
|
|
361
|
+
// can fall back to the agent's own read rather than silently supplying a conventions block that
|
|
362
|
+
// omits the one command the task needed.
|
|
363
|
+
const AGENTS_SECTION_RE = /(build|test|verif|command|lint|compil)/i
|
|
364
|
+
function buildAgentsConventions(root) {
|
|
365
|
+
const agentsPath = `${root}/AGENTS.md`
|
|
366
|
+
if (!c.exists(agentsPath)) return null
|
|
367
|
+
const lines = c.read(agentsPath).split('\n')
|
|
368
|
+
const kept = []
|
|
369
|
+
let keeping = false
|
|
370
|
+
for (const line of lines) {
|
|
371
|
+
const heading = line.match(/^(#{2,3})\s+(.*)/)
|
|
372
|
+
if (heading) keeping = AGENTS_SECTION_RE.test(heading[2])
|
|
373
|
+
if (keeping) kept.push(line)
|
|
374
|
+
}
|
|
375
|
+
return kept.length ? kept.join('\n').trim() : null
|
|
376
|
+
}
|
|
377
|
+
|
|
315
378
|
// Compact prompt for the per-phase task-generation worker: this phase's own skeleton entry, its
|
|
316
379
|
// scoped Requirements excerpt, the Tech-Spec skeleton, and every prior phase's own tasks as a
|
|
317
380
|
// lightweight id/title/outcome/domain projection (never a later phase's tasks, which do not exist
|
|
318
381
|
// yet) — mirrors buildTaskPrompt's own "siblings" list further below, same duplicate-
|
|
319
382
|
// implementation-awareness purpose, one tier up.
|
|
320
|
-
function buildPhaseTaskPrompt(phase, requirementsExcerpt, techSpecSkeletonText, priorTasks, drillDownText) {
|
|
383
|
+
function buildPhaseTaskPrompt(phase, requirementsExcerpt, techSpecSkeletonText, priorTasks, drillDownText, componentExcerpt, conventions) {
|
|
321
384
|
const priorTasksList = priorTasks.length
|
|
322
385
|
? priorTasks.map(t => `- ${t.id}: ${t.title} — ${t.outcome} (domain: ${t.domain})`).join('\n')
|
|
323
386
|
: '(none — this is the first phase generated)'
|
|
@@ -327,27 +390,38 @@ function buildPhaseTaskPrompt(phase, requirementsExcerpt, techSpecSkeletonText,
|
|
|
327
390
|
`Tech-Spec:\n${techSpecSkeletonText}`,
|
|
328
391
|
`Tasks already decided by prior phases:\n${priorTasksList}`,
|
|
329
392
|
]
|
|
393
|
+
if (conventions) parts.push(`Project build/verification conventions (from AGENTS.md — do not read that file yourself):\n${conventions}`)
|
|
394
|
+
if (componentExcerpt) parts.push(`Design detail for the components this phase's own Acceptance Criteria cover (do not go read the project's source to rediscover this):\n${componentExcerpt}`)
|
|
330
395
|
if (drillDownText) parts.push(`Component detail you requested:\n${drillDownText}`)
|
|
331
396
|
return parts.join('\n\n')
|
|
332
397
|
}
|
|
333
398
|
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
399
|
+
// Selects which component DESIGN BODIES a single task's worker actually needs.
|
|
400
|
+
//
|
|
401
|
+
// Matching purely on Acceptance Criteria (FTR-026's original rule) degrades badly on a real
|
|
402
|
+
// feature: AC granularity is coarse (one real feature had 12 ACs across 51 components, with a
|
|
403
|
+
// single AC cited by 14 of them), so a task citing 3 ACs pulled in 26 full design bodies —
|
|
404
|
+
// measured at 106 KB average per task, 12.6 MB across one feature, for work that usually touches
|
|
405
|
+
// one or two files. Narrowing to the component(s) a task actually NAMES in its own title/outcome
|
|
406
|
+
// measured 11 KB average instead, and covers just over half of all tasks; the remainder fall back
|
|
407
|
+
// to the AC match, capped. Section 9's index rows are always included in full regardless, so a
|
|
408
|
+
// task whose real file was not matched can still see every component that exists and go read one.
|
|
409
|
+
const TASK_COMPONENT_FALLBACK_MAX = 4
|
|
339
410
|
function buildTaskTechSpecExcerpt(components, task, specDir, specIndexContent) {
|
|
340
411
|
const acSet = new Set(task.acceptanceCriteria || [])
|
|
341
|
-
const
|
|
412
|
+
const byAc = components.filter(comp =>
|
|
342
413
|
(comp.addresses || '').split(',').map(s => s.trim()).some(acId => acSet.has(acId)))
|
|
343
|
-
if (
|
|
414
|
+
if (byAc.length === 0) {
|
|
344
415
|
return "_No component's Addresses column cites this task's Acceptance Criteria — full " +
|
|
345
416
|
'Components table below._\n\n' + specIndexContent
|
|
346
417
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
418
|
+
const haystack = `${task.title || ''} ${task.outcome || ''}`.toLowerCase()
|
|
419
|
+
const byPath = components.filter(comp => comp.filePath && haystack.includes(comp.filePath.toLowerCase()))
|
|
420
|
+
const bodies = byPath.length ? byPath : byAc.slice(0, TASK_COMPONENT_FALLBACK_MAX)
|
|
421
|
+
const bodyText = bodies.map(comp =>
|
|
422
|
+
`### ${comp.filePath}\n\n${c.read(`${specDir}/components/${comp.fileSlug}`)}`).join('\n\n')
|
|
423
|
+
return `Every component this task's Acceptance Criteria cover (index only):\n${buildComponentIndexRows(byAc)}\n\n` +
|
|
424
|
+
`Full design detail for the component(s) this task itself names:\n${bodyText}`
|
|
351
425
|
}
|
|
352
426
|
|
|
353
427
|
// FTR-027: duplicates wbs-assembler.js's own _parseVerificationCommands algorithm (last
|
|
@@ -367,20 +441,30 @@ function hasVerificationCommands(content) {
|
|
|
367
441
|
}
|
|
368
442
|
|
|
369
443
|
// Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
444
|
+
// a sibling task list (id + title + outcome only, for duplicate-implementation awareness), and
|
|
445
|
+
// the Tech-Spec excerpt (FTR-026: already pre-sliced to this task's own relevant component(s) by
|
|
446
|
+
// buildTaskTechSpecExcerpt, or the full legacy Tech-Spec text for a feature with no spec/).
|
|
447
|
+
//
|
|
448
|
+
// Cost-hardening: the sibling list used to carry EVERY task in the feature with its full outcome
|
|
449
|
+
// — 11K tokens per call on a 122-task feature, re-sent once per task, and growing quadratically
|
|
450
|
+
// with feature size. Duplicate implementation is only a real risk against work that is adjacent
|
|
451
|
+
// or already decided, so the list is scoped to this task's own phase plus every earlier phase;
|
|
452
|
+
// a genuinely cross-phase duplicate is still caught deterministically by wb-validate.js.
|
|
453
|
+
function buildTaskPrompt(index, taskId, techSpecText, taskTemplate, conventions) {
|
|
375
454
|
const task = index.tasks[taskId]
|
|
455
|
+
const phaseIdx = index.phases.findIndex(p => p.taskIds.includes(taskId))
|
|
456
|
+
const inScope = new Set(index.phases.slice(0, phaseIdx + 1).flatMap(p => p.taskIds))
|
|
376
457
|
const siblingList = Object.entries(index.tasks)
|
|
458
|
+
.filter(([id]) => inScope.has(id))
|
|
377
459
|
.map(([id, t]) => `- ${id}: ${t.title} — ${t.outcome}`).join('\n')
|
|
378
|
-
|
|
460
|
+
const parts = [
|
|
379
461
|
`Task skeleton entry (restate these fields verbatim; do not re-derive them):\n${JSON.stringify({ id: taskId, ...task }, null, 2)}`,
|
|
380
462
|
`Tech-Spec:\n${techSpecText}`,
|
|
381
|
-
`
|
|
382
|
-
|
|
383
|
-
|
|
463
|
+
`Tasks in this task's own phase and every earlier phase (for duplicate-implementation awareness):\n${siblingList}`,
|
|
464
|
+
]
|
|
465
|
+
if (conventions) parts.push(`Project build/verification conventions (from AGENTS.md — do not read that file yourself):\n${conventions}`)
|
|
466
|
+
parts.push(`Template (tasks/{TASK_ID}.md):\n${taskTemplate}`)
|
|
467
|
+
return parts.join('\n\n')
|
|
384
468
|
}
|
|
385
469
|
|
|
386
470
|
const c = control
|
|
@@ -475,6 +559,13 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
475
559
|
// work-breakdown.md's own Guidelines already produce for the phases array).
|
|
476
560
|
const reqIndexContentForPhases = c.read(reqIndexPath)
|
|
477
561
|
const techSpecSkeletonForPhases = hasSpec ? c.read(specIndexPath) : c.read(file('Tech-Spec.md'))
|
|
562
|
+
// Computed once, outside the loop — identical for every phase, and both are pure reads.
|
|
563
|
+
const componentsForPhases = hasSpec ? parseComponentsTableLocal(c.read(specIndexPath)) : []
|
|
564
|
+
// A Requirements document without a parseable Section 7 table costs this one optimisation, not
|
|
565
|
+
// the run: fall back to supplying no component excerpt rather than failing WBS generation.
|
|
566
|
+
let acTableForPhases = []
|
|
567
|
+
if (hasReq) { try { acTableForPhases = parseAcTableLocal(reqIndexContentForPhases) } catch (e) { acTableForPhases = [] } }
|
|
568
|
+
const conventions = buildAgentsConventions(c.root)
|
|
478
569
|
const taskIdToPhase = new Map()
|
|
479
570
|
for (const phase of index.phases) {
|
|
480
571
|
if (phase.taskIds.length > 0) {
|
|
@@ -485,9 +576,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
485
576
|
? buildPhaseRequirementsExcerpt(reqIndexContentForPhases, phase.sourceUC, reqDir)
|
|
486
577
|
: reqIndexContentForPhases
|
|
487
578
|
const priorTasks = Object.entries(index.tasks).map(([id, t]) => ({ id, title: t.title, outcome: t.outcome, domain: t.domain }))
|
|
579
|
+
const componentExcerpt = hasSpec
|
|
580
|
+
? buildPhaseComponentExcerpt(phase, componentsForPhases, acTableForPhases, specDir)
|
|
581
|
+
: null
|
|
488
582
|
|
|
489
583
|
let phaseResult = await c.worker('gaia.agent.planner.work-breakdown-phase',
|
|
490
|
-
buildPhaseTaskPrompt(phase, reqExcerpt, techSpecSkeletonForPhases, priorTasks),
|
|
584
|
+
buildPhaseTaskPrompt(phase, reqExcerpt, techSpecSkeletonForPhases, priorTasks, null, componentExcerpt, conventions),
|
|
491
585
|
{ schema: PHASE_TASKS_SCHEMA, label: `work-breakdown-phase:${phase.id}` })
|
|
492
586
|
|
|
493
587
|
// Bounded, single-round drill-down (FTR-028 precedent, reused unmodified from FTR-032's own
|
|
@@ -497,7 +591,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
497
591
|
const drillDownText = buildComponentDrillDownText(phaseNeedsComponentDetail, c.read(specIndexPath), specDir)
|
|
498
592
|
if (drillDownText) {
|
|
499
593
|
phaseResult = await c.worker('gaia.agent.planner.work-breakdown-phase',
|
|
500
|
-
buildPhaseTaskPrompt(phase, reqExcerpt, techSpecSkeletonForPhases, priorTasks, drillDownText),
|
|
594
|
+
buildPhaseTaskPrompt(phase, reqExcerpt, techSpecSkeletonForPhases, priorTasks, drillDownText, componentExcerpt, conventions),
|
|
501
595
|
{ schema: PHASE_TASKS_SCHEMA, label: `work-breakdown-phase:${phase.id}:with-drilldown` })
|
|
502
596
|
}
|
|
503
597
|
}
|
|
@@ -548,7 +642,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
548
642
|
? buildTaskTechSpecExcerpt(components, task, specDir, c.read(specIndexPath))
|
|
549
643
|
: c.read(file('Tech-Spec.md')) // legacy fallback: unchanged, full text
|
|
550
644
|
const detail = await c.worker('gaia.agent.planner.work-breakdown-task',
|
|
551
|
-
buildTaskPrompt(index, taskId, techSpecExcerpt, taskTemplate), { label: `work-breakdown-task:${taskId}` })
|
|
645
|
+
buildTaskPrompt(index, taskId, techSpecExcerpt, taskTemplate, conventions), { label: `work-breakdown-task:${taskId}` })
|
|
552
646
|
c.writeOutput(detailPath, detail)
|
|
553
647
|
task.status = 'generated'
|
|
554
648
|
c.writeOutput(indexPath, JSON.stringify(index, null, 2)) // persist status after each task
|