@dtlabs/ai-toolkit 0.24.0 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,26 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.24.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.24.1...v0.24.2) (2026-10-26)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **pm-phase1:** make Tech-Spec component identity permanent via a manifest ([642527f](https://github.com/dtlab-tech/ai-toolkit/commit/642527ffd7de4269d663330a02e1f652920a097e))
8
+
9
+
10
+ ### Performance Improvements
11
+
12
+ * bound worker prompt context and stop source-tree exploration ([ebd273b](https://github.com/dtlab-tech/ai-toolkit/commit/ebd273b541fa4adbe34c18fc6172acacc3cb2854))
13
+ * **pm-phase1,pm-phase2:** bound worker prompt context and stop source-tree exploration ([801681a](https://github.com/dtlab-tech/ai-toolkit/commit/801681abbcbdff83600ca68c7434729c9cd27be6))
14
+
15
+ ## [0.24.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.24.0...v0.24.1) (2026-10-24)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **gaia-generate-work-breakdown-task:** drop stale estimate-as-input contradiction from FTR-032 ([c157277](https://github.com/dtlab-tech/ai-toolkit/commit/c157277847aedb33b69022d76d2b87ed5eb0ff49))
21
+ * **pm-phase1:** tolerate a leading conversational preamble before per-item headings ([afe638f](https://github.com/dtlab-tech/ai-toolkit/commit/afe638f6bb138335b34b97b1eda776c77ab0778e))
22
+ * tolerate per-item generation preamble + drop stale estimate contradiction in work-breakdown-task agent ([d3438d4](https://github.com/dtlab-tech/ai-toolkit/commit/d3438d4bde7f29b12b12ba589903f951a6c2bf10))
23
+
2
24
  ## [0.24.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.23.2...v0.24.0) (2026-10-22)
3
25
 
4
26
 
@@ -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 <= 0)) throw new Error('Worker budget must be a positive finite number');
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
- if (options.workerMaxBudgetUsd != null) cliArgs.push('--max-budget-usd', String(options.workerMaxBudgetUsd));
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');
@@ -361,6 +366,12 @@ function createControl(options) {
361
366
  // retried by hand from the CLI. 3 total attempts, waiting 5s then 20s between them: long
362
367
  // enough for a brief network stall to clear, short enough not to turn a real, deterministic
363
368
  // failure into a multi-minute stall before it's reported.
369
+ // Deliberately well above any healthy call (the most expensive legitimate worker measured across
370
+ // two real projects was $1.96, and the prompt-scoping work done alongside this brings that down)
371
+ // — this exists to stop a runaway, not to shape normal spend. Override per run with
372
+ // --worker-max-budget-usd; pass 0 to disable entirely.
373
+ const DEFAULT_WORKER_MAX_BUDGET_USD = 5;
374
+
364
375
  const WORKER_MAX_ATTEMPTS = 3;
365
376
  const WORKER_RETRY_BACKOFF_MS = [5000, 20000];
366
377
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -49,7 +49,10 @@ do not reword, re-derive, or second-guess them. Your only new content is **Preco
49
49
 
50
50
  Return exactly this block as your final response, and nothing else (no preamble, no "here is the
51
51
  use case", no Markdown code fence wrapping the whole thing) — you do not have a Write tool; the
52
- host persists your response verbatim to `req/use-cases/{UC-ID}.md`:
52
+ host persists your response verbatim to `req/use-cases/{UC-ID}.md`. This means: do NOT write a
53
+ lead-in sentence like "Now I'll generate the complete use case details for {UC-ID}..." before the
54
+ `### {UC-ID}: {Title}` heading — the heading is the first thing in your response, with nothing
55
+ before it, not even a single line of narration.
53
56
 
54
57
  ```markdown
55
58
  ### {UC-ID}: {Title}
@@ -48,7 +48,10 @@ description, its actual design, and its `Tests` section.
48
48
 
49
49
  Return exactly this block as your final response, and nothing else (no preamble, no "here is the
50
50
  component", no Markdown code fence wrapping the whole thing) — you do not have a Write tool; the
51
- host persists your response verbatim to `spec/components/{fileSlug}.md`:
51
+ host persists your response verbatim to `spec/components/{fileSlug}.md`. This means: do NOT write
52
+ a lead-in sentence like "Now I have the information. Let me generate the design for {Path}..."
53
+ before the `# {Path} — {New | Modified}` heading — the heading is the first thing in your
54
+ response, with nothing before it, not even a single line of narration.
52
55
 
53
56
  ```markdown
54
57
  # {Path} — {New | Modified}
@@ -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, Glob, Grep
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
- ## Step 0 — Read Project Conventions (MANDATORY)
38
+ ## Do not explore the project's source tree
35
39
 
36
- Read `AGENTS.md` from the current working directory — tech stack/frameworks (domain
37
- classification), directory structure (task file paths), build/verification commands. Ensures your
38
- tasks reference the correct technologies, paths, and commands.
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 for Requirements/Tech-Spec content — both are inlined.
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,13 +1,13 @@
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: sonnet
4
+ model: haiku
5
5
  tools: Read, Glob, Grep
6
6
  ---
7
7
 
8
8
  # Generate Work Breakdown Task Detail
9
9
 
10
- You are a **senior implementation planner**. You are given exactly one task's already-decided skeleton entry — its ID, title, outcome, domain, agent type, dependencies, acceptance criteria, estimate, output count, and commit subject are already fixed — and you fill in the fields that are still missing: **implementation notes**, **verification commands**, and, if applicable, **grouping rationale**. You do not re-decide anything the skeleton already fixed.
10
+ You are a **senior implementation planner**. You are given exactly one task's already-decided skeleton entry — its ID, title, outcome, domain, agent type, dependencies, acceptance criteria, output count, and commit subject are already fixed — and you fill in the fields that are still missing: **implementation notes**, **verification commands**, **estimate** (`agentMinutes`/`tokens` — the skeleton pass deliberately does not produce this; see the dedicated Estimate section below), and, if applicable, **grouping rationale**. You do not re-decide anything the skeleton already fixed.
11
11
 
12
12
  This is the **second of two generation passes** (FTR-020). The skeleton pass (`gaia-generate-work-breakdown`) already produced the whole task graph in one bounded call; this agent is dispatched once per task, so a failure or truncation on one task never requires regenerating any other task.
13
13
 
@@ -17,7 +17,7 @@ This is the **second of two generation passes** (FTR-020). The skeleton pass (`g
17
17
 
18
18
  The caller supplies, in the prompt (you do not read `{PREFIX}-Work-Breakdown.json`/`wbs/index.json` yourself — the host extracts and hands you exactly what you need):
19
19
 
20
- 1. **This task's own skeleton entry**, verbatim: `id`, `title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `estimate`, `outputCount`, `commit`.
20
+ 1. **This task's own skeleton entry**, verbatim: `id`, `title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `outputCount`, `commit`. It deliberately has no `estimate` field — that is your own new content (see the Estimate section below), not something already decided for you to restate.
21
21
  2. **The relevant Tech-Spec excerpt** for this task's own Acceptance Criteria (matched by the host against each component's `Addresses` column in `spec/index.md` — not the whole Tech-Spec, which is the whole point of the per-task split). If no component's `Addresses` column cites any of this task's Acceptance Criteria, you instead receive the full Components table from `spec/index.md` as a fallback — in that case, use your own judgment about which listed component(s) are actually relevant, the same way you would if asked to work from the full Tech-Spec directly.
22
22
  3. **The compact full-skeleton task list**: every task's `id` + `title` + `outcome` only (no detail), so you can tell whether another task already built the behavior this one would otherwise duplicate.
23
23
  4. **The canonical output template**, inlined under a `Template (tasks/{TASK_ID}.md):` label — the
@@ -28,22 +28,25 @@ 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
- If the prompt is missing any of these four, stop and report exactly what is missing — do not guess at a skeleton entry you were not given.
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
- ## Step 0 — Read Project Conventions (MANDATORY)
37
+ ## Scope of the Tech-Spec you are given
36
38
 
37
- Before writing verification commands, you MUST:
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
- 1. Read `AGENTS.md` from the current working directory — this defines build/test commands appropriate for the project's tech stack.
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
 
43
45
  ## Mandatory Instruction — Restate, Do Not Re-Derive
44
46
 
45
- Restate every given field (`title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `estimate`, `outputCount`, `commit`) **exactly as given** in your output block — do not reword, re-estimate, or re-derive them, even if you would have phrased them differently. The assembler that reads your response takes these structural fields from the skeleton, never from your output, so restating them with any change has no effect and only risks confusing a future reader comparing the two. Your only genuinely new content is:
47
+ Restate every given field (`title`, `outcome`, `domain`, `agentType`, `dependsOn`, `acceptanceCriteria`, `outputCount`, `commit`) **exactly as given** in your output block — do not reword or re-derive them, even if you would have phrased them differently. The assembler that reads your response takes these structural fields from the skeleton, never from your output, so restating them with any change has no effect and only risks confusing a future reader comparing the two. Your only genuinely new content is:
46
48
  - **Implementation notes** — always required, non-empty (see the dedicated section below)
49
+ - **Estimate** (`agentMinutes`/`tokens`) — always required; you decide this, the skeleton never supplies it (see the dedicated Estimate section below)
47
50
  - `verification.commands` — always required, non-empty
48
51
  - `groupingRationale` — required (non-null) only when the skeleton's `outputCount` is greater than 1; otherwise `—`
49
52
 
@@ -82,7 +85,7 @@ Before writing verification commands, scan the compact sibling task list for any
82
85
 
83
86
  ## Verification Commands
84
87
 
85
- 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 (read from `AGENTS.md`). Examples:
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:
86
89
  - Compilation check: `npx tsc --noEmit`, `dotnet build`, `node --check <file>`
87
90
  - Targeted test run: `npm test -- --testPathPattern=<file>`, `npx jest --testPathPattern=<file>`, `dotnet test --filter <class>`
88
91
  - File existence: `test -f <path>`
@@ -111,6 +114,26 @@ If the skeleton's `outputCount` is greater than `1`: write a short, specific exp
111
114
 
112
115
  ---
113
116
 
117
+ ## Estimate
118
+
119
+ The skeleton deliberately does not supply `estimate.agentMinutes`/`estimate.tokens` — you decide both,
120
+ now, with the fuller picture you have (the task's own Tech-Spec excerpt and the Implementation
121
+ Notes you are about to write), which the skeleton pass never had. Do not treat this as a field to
122
+ look for in your input; it does not exist there.
123
+
124
+ Base `agentMinutes` on the same bands the Work Breakdown's own atomicity rule already uses: target
125
+ ≤15 (the common case for a single, atomic outcome); 16–20 is acceptable without flagging anything;
126
+ 21–30 is a non-blocking warning and requires a real, non-`—` `Grouping rationale` justifying why
127
+ this task's scope could not be split further; if your own honest estimate would exceed 30, say so
128
+ plainly in Implementation Notes instead of understating the number — a task this large should have
129
+ been split at the skeleton stage, and silently reporting a lower number here only hides that. Base
130
+ `tokens` on a rough order of magnitude for the agent invocation that will actually do this work
131
+ (reading the relevant files, writing the change, running the verification commands) — thousands to
132
+ tens of thousands is typical for a single atomic task; there is no fixed formula, use judgment
133
+ informed by the task's own real scope.
134
+
135
+ ---
136
+
114
137
  ## Output
115
138
 
116
139
  Return exactly this block as your final response, and nothing else (no preamble, no explanatory prose, no Markdown code fence wrapping it) — you do not have a Write tool; the host persists your response verbatim to the task's detail file:
@@ -104,13 +104,32 @@ return c.run(meta.name, featureDir, prefix, async () => {
104
104
  return new RegExp(`^###\\s+${escaped}:\\s`).test(content.trimStart())
105
105
  }
106
106
 
107
+ // A per-item generation agent occasionally prefixes its real deliverable with a short
108
+ // conversational lead-in ("Now I'll generate the complete use case details for...") before the
109
+ // heading it was explicitly instructed to start with — a real, recurring model habit confirmed
110
+ // across multiple production runs and multiple agents (gaia-generate-requirements-use-case,
111
+ // gaia-generate-tech-spec-component), not prevented by the agents' own existing "no preamble"
112
+ // instruction alone. Tolerated here, bounded and conservative: only lines STRICTLY BEFORE the
113
+ // first line satisfying headingTest, and only within the first few lines, are ever dropped —
114
+ // content whose heading never appears at all within that short window (genuinely wrong/missing,
115
+ // not just narrated) is returned completely unchanged and still fails the caller's own validity
116
+ // check exactly as before this fix.
117
+ const MAX_PREAMBLE_LINES = 5
118
+ function stripLeadingNarration(content, headingTest) {
119
+ const lines = content.split('\n')
120
+ for (let i = 1; i < Math.min(lines.length, MAX_PREAMBLE_LINES); i++) {
121
+ if (headingTest(lines[i])) return lines.slice(i).join('\n')
122
+ }
123
+ return content
124
+ }
125
+
107
126
  // Business Rules' "Applies to" / Acceptance Criteria's "Related UC" columns are the same
108
127
  // cross-cutting-reference shape: a comma-separated UC-ID list, or the literal "All UCs". Both
109
128
  // are 100% mechanically derivable from req/index.md's own tables — no LLM judgment needed,
110
129
  // which is exactly why the per-UC generation agent is never given this data at all (see
111
130
  // gaia-generate-requirements-use-case.md: it must not re-read req/index.md itself, to avoid
112
131
  // re-deciding which UCs exist). The host computes and appends these cross-reference lines
113
- // itself, after the agent's own response, mirroring how disambiguateFileSlugs/computeDependsOnPhases
132
+ // itself, after the agent's own response, mirroring how applyComponentSlugs/computeDependsOnPhases
114
133
  // post-process agent/skeleton output elsewhere in this lineage.
115
134
  function parseBusinessRulesTable(indexContent) {
116
135
  const lines = indexContent.split('\n')
@@ -202,10 +221,13 @@ return c.run(meta.name, featureDir, prefix, async () => {
202
221
  if (!regenSkeleton && c.exists(ucPath) && isValidUseCaseFile(c.read(ucPath), row.id)) continue // resume: skip, no dispatch
203
222
  // FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
204
223
  // use case — same reasoning as the per-component loop above.
224
+ const ucEscaped = row.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
225
+ const ucHeadingRe = new RegExp(`^###\\s+${ucEscaped}:\\s`)
205
226
  let detail, valid = false
206
227
  for (let attempt = 1; attempt <= USE_CASE_RETRY_ATTEMPTS && !valid; attempt++) {
207
- detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
228
+ const raw = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
208
229
  { label: `requirements-use-case:${row.id}:${attempt}` })
230
+ detail = stripLeadingNarration(raw, l => ucHeadingRe.test(l))
209
231
  valid = isValidUseCaseFile(detail, row.id)
210
232
  }
211
233
  if (!valid) throw new Error(`Requirements use case "${row.id}" failed structural validation after ${USE_CASE_RETRY_ATTEMPTS} attempts — missing the required "### ${row.id}: " heading`)
@@ -221,6 +243,96 @@ return c.run(meta.name, featureDir, prefix, async () => {
221
243
  const specDir = `${featureDir}/spec`
222
244
  const specIndexPath = `${specDir}/index.md`
223
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
+
224
336
  // Duplicated (not imported) from tech-spec-assembler.js's own export of the same name — this
225
337
  // runtime has no `require`, so getting this via another c.command round-trip would be
226
338
  // unnecessary overhead for a parse already needed in-process to drive the per-component loop.
@@ -375,58 +487,36 @@ return c.run(meta.name, featureDir, prefix, async () => {
375
487
  return `${base}\n\nTemplate (spec/components/{fileSlug}.md):\n${componentTemplate}`
376
488
  }
377
489
 
378
- // Deterministic, host-computed — never asks the model to pick unique names (same principle
379
- // as materializeIndex's detailFile/status derivations in the Work Breakdown's own skeleton,
380
- // FTR-020). Rewrites any colliding fileSlug's Spec-column link in place, right-to-left
381
- // prepending path segments until unique.
382
- //
383
- // Also enforces the ".md" suffix itself, never trusting the model to have included it. The
384
- // component template's own contract calls this Spec link "provisional, not final" specifically
385
- // so the host can rewrite it — originally only exercised for collisions, but a real production
386
- // run showed the model omits ".md" entirely a meaningful fraction of the time (confirmed: 20 of
387
- // ~61 components in one real Tech-Spec skeleton), writing e.g. `components/Settings.tsx`
388
- // instead of `components/Settings.tsx.md` — the host then faithfully wrote that component's
389
- // Markdown CONTENT into a file literally named `Settings.tsx`. Normalized here before the
390
- // collision check runs, so two rows that only differ by their original extension (already
391
- // present in the Path column) still correctly collide-and-disambiguate on the normalized slug.
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.
392
506
  //
393
- // Operates per-LINE, not via a global string .replace() over the whole document — a real bug,
394
- // found by testing two colliding entries directly: when both rows share byte-identical Spec
395
- // text (the whole point of a collision), a global .replace() always rewrites the FIRST
396
- // occurrence, never the specific (second, third, ...) row actually being disambiguated.
397
- function disambiguateFileSlugs(skeletonText) {
398
- const lines = skeletonText.split('\n')
399
- const seenSlugs = new Set()
400
- for (let i = 0; i < lines.length; i++) {
401
- const line = lines[i]
402
- if (!line.trim().startsWith('|')) continue
403
- const cells = line.split('|').map(c => c.trim()).filter((_, idx, a) => idx > 0 && idx < a.length - 1)
404
- if (cells.length < 5) continue
405
- const filePath = cells[0]
406
- const m = cells[4].match(/\(components\/([^)]+)\)/)
407
- if (!m) continue // header/separator row, or a row with no valid Spec link yet
408
- let slug = m[1]
409
- if (!slug.endsWith('.md')) {
410
- const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
411
- const normalized = `${slug}.md`
412
- lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${normalized}`)
413
- slug = normalized
414
- }
415
- if (seenSlugs.has(slug)) {
416
- const segments = filePath.split('/')
417
- let depth = 1
418
- let candidate = slug
419
- while (seenSlugs.has(candidate)) {
420
- depth += 1
421
- candidate = segments.slice(-depth).join('-') + '.md'
422
- }
423
- const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
424
- lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
425
- slug = candidate
426
- }
427
- 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 }
428
518
  }
429
- return lines.join('\n')
519
+ return rowUnchangedByPath
430
520
  }
431
521
 
432
522
  // forceAll: bypasses resume-by-existence entirely, re-dispatching the skeleton and every
@@ -438,28 +528,23 @@ return c.run(meta.name, featureDir, prefix, async () => {
438
528
  (!fresh(specIndexPath) || missingSections(c.read(specIndexPath), 12).length > 0)
439
529
  const regenSkeleton = forceAll || skeletonStale || !c.exists(specIndexPath)
440
530
 
441
- // FTR-030: capture the OLD component rows (keyed by fileSlug — the stable per-component
442
- // identity, a Spec-link target) BEFORE the skeleton is overwritten, so a NATURALLY-triggered
443
- // skeleton regen (staleness/missing, never an explicit forceAll) doesn't force EVERY component
444
- // to redispatch when only a FEW rows actually changed. Real incident: hand-editing 2 of ~58
445
- // rows (adding a missing AC reference) triggered a full skeleton regeneration, which — with no
446
- // per-row comparison — forced ALL ~88 components to redispatch even though the vast majority
447
- // of rows were byte-identical to before. Deliberately NOT captured when forceAll is true: an
448
- // explicit full regen (a `--force` run, or a needsTechSpecFix revision cycle) means something
449
- // was flagged wrong that per-row diffing cannot see (e.g. a missing cross-reference only
450
- // visible in a component's own body) — that path keeps today's existing "regenerate
451
- // everything unconditionally" behavior, unchanged.
452
- const oldComponentsByFileSlug = new Map()
453
- 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)) {
454
539
  try {
455
- for (const comp of parseComponentsTable(c.read(specIndexPath))) oldComponentsByFileSlug.set(comp.fileSlug, comp)
456
- } catch (e) { /* an unparseable old skeleton has nothing worth diffing against — treat as empty */ }
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 */ }
457
542
  }
458
543
 
459
544
  // 1. Skeleton (no declared worker() output — host writes it itself)
460
545
  if (regenSkeleton) {
461
546
  const skeleton = await c.worker(specId, withTemplate(featurePath, 'spec/index.md', 'gaia-tech-spec-index-template.md'))
462
- c.writeOutput(specIndexPath, disambiguateFileSlugs(skeleton))
547
+ c.writeOutput(specIndexPath, applyComponentSlugs(skeleton, manifest))
463
548
  }
464
549
 
465
550
  // 2. Structural pre-check (tech-spec-graph-checks.js, unmodified — FTR-023 — now pointed at
@@ -477,6 +562,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
477
562
 
478
563
  // 3. Per-component generation, resume-by-existence, sequential
479
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)
480
571
  // Read once, outside the loop — the template is identical for every component in this run.
481
572
  const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
482
573
  for (const comp of components) {
@@ -486,18 +577,23 @@ return c.run(meta.name, featureDir, prefix, async () => {
486
577
  // NEW or actually CHANGED does. `regenSkeleton` alone used to force every component to
487
578
  // redispatch unconditionally the moment the skeleton changed at all; this narrows that to
488
579
  // rows that genuinely changed, while still regenerating any row that's new or different.
489
- const oldComp = oldComponentsByFileSlug.get(comp.fileSlug)
490
- const rowUnchanged = !!oldComp && oldComp.filePath === comp.filePath &&
491
- oldComp.changeType === comp.changeType && oldComp.purposeOrChange === comp.purposeOrChange &&
492
- oldComp.addresses === comp.addresses && oldComp.domain === comp.domain
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)
493
585
  if ((!regenSkeleton || rowUnchanged) && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
494
586
  // FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
495
587
  // component — a bad result otherwise silently continued through every remaining component,
496
588
  // only discovered (expensively) at final assembly or a later invocation's resume check.
589
+ // isValidComponentDetail itself tolerates a leading preamble (it searches for ## Change/##
590
+ // Tests anywhere, not anchored to the start), so it never hard-fails on one — but the file
591
+ // actually WRITTEN to disk must not carry that narration forward silently; strip it first.
497
592
  let detail, valid = false
498
593
  for (let attempt = 1; attempt <= COMPONENT_RETRY_ATTEMPTS && !valid; attempt++) {
499
- detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
594
+ const raw = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
500
595
  { label: `tech-spec-component:${comp.fileSlug}:${attempt}` })
596
+ detail = stripLeadingNarration(raw, l => /^#\s+\S/.test(l))
501
597
  valid = isValidComponentDetail(detail)
502
598
  }
503
599
  if (!valid) throw new Error(`Tech-Spec component "${comp.filePath}" (${comp.fileSlug}) failed structural validation after ${COMPONENT_RETRY_ATTEMPTS} attempts — missing a real "## Change"/"## Tests" heading`)
@@ -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
- // Matches a task's own acceptanceCriteria against each component's Addresses column (the same
335
- // relation spec/index.md's own Section 9 table already expresses) to give the per-task worker
336
- // only the Tech-Spec content relevant to its own task, instead of the entire assembled spec —
337
- // the single largest structural token cost this feature removes. Falls back to the full
338
- // Components table (never zero content) when nothing matches, e.g. a purely organizational task.
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 relevant = components.filter(comp =>
412
+ const byAc = components.filter(comp =>
342
413
  (comp.addresses || '').split(',').map(s => s.trim()).some(acId => acSet.has(acId)))
343
- if (relevant.length === 0) {
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
- return relevant.map(comp => {
348
- const body = c.read(`${specDir}/components/${comp.fileSlug}`)
349
- return `### ${comp.filePath}\n\n${body}`
350
- }).join('\n\n')
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
- // the full skeleton's sibling task list (id + title + outcome only, for duplicate-
371
- // implementation awareness), and the Tech-Spec excerpt (FTR-026: already pre-sliced to this
372
- // task's own relevant component(s) by buildTaskTechSpecExcerpt, or the full legacy Tech-Spec
373
- // text for a feature with no spec/ directory).
374
- function buildTaskPrompt(index, taskId, techSpecText, taskTemplate) {
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
- return [
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
- `All tasks in this feature (for duplicate-implementation awareness):\n${siblingList}`,
382
- `Template (tasks/{TASK_ID}.md):\n${taskTemplate}`,
383
- ].join('\n\n')
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