@dtlabs/ai-toolkit 0.19.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,20 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.21.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.20.0...v0.21.0) (2026-10-20)
3
+
4
+
5
+ ### Features
6
+
7
+ * bound the feature-doc validator to the Tech-Spec skeleton (FTR-028) ([80869ce](https://github.com/dtlab-tech/ai-toolkit/commit/80869ced8b8d24ef7b0669fabbb152e8bf243794))
8
+ * **pm-phase1, agent-registry:** bound the feature-doc validator to the Tech-Spec skeleton (FTR-028) ([9eb7f72](https://github.com/dtlab-tech/ai-toolkit/commit/9eb7f7222ff80eb2b26815a4f58f923f9ca09f1d))
9
+
10
+ ## [0.20.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.2...v0.20.0) (2026-10-19)
11
+
12
+
13
+ ### Features
14
+
15
+ * **pm-phase1, pm-phase2:** resume-safe per-item generation (FTR-027) ([41e021f](https://github.com/dtlab-tech/ai-toolkit/commit/41e021f65aad694c18d51b6a7876e4a83373a4d9))
16
+ * resume-safe per-item generation + component fileSlug normalization (FTR-027) ([e7e6b89](https://github.com/dtlab-tech/ai-toolkit/commit/e7e6b89596f267c4954c9be92bc2c4b3de8b9194))
17
+
2
18
  ## [0.19.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.1...v0.19.2) (2026-10-19)
3
19
 
4
20
 
@@ -259,6 +259,19 @@ const CATALOG = [
259
259
  deprecationTarget: null,
260
260
  platforms: ['claude'],
261
261
  },
262
+ {
263
+ agentId: 'gaia.agent.planner.validate-tech-spec-component-detail',
264
+ nativeNames: { claude: 'gaia-validate-tech-spec-component-detail' },
265
+ relativeInstallPath: '.claude/agents/gaia-validate-tech-spec-component-detail.md',
266
+ role: 'planner',
267
+ type: 'validation',
268
+ authorisedPhases: ['planning'],
269
+ allowedPipelines: ['implement-feature'],
270
+ minimumToolkitVersion: '0.13.0',
271
+ deprecated: false,
272
+ deprecationTarget: null,
273
+ platforms: ['claude'],
274
+ },
262
275
  {
263
276
  agentId: 'gaia.agent.planner.define-feature',
264
277
  nativeNames: { claude: 'gaia-define-feature' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.19.2",
3
+ "version": "0.21.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -16,11 +16,15 @@ A QA agent that cross-references `feature.md` against the Requirements and Tech-
16
16
  1. Extract the prefix from the folder name (`FTR-001-user-management` → `FTR-001`)
17
17
  2. Read `feature.md` fully — its path is given to you first; use your Read tool.
18
18
  3. The Requirements and Tech-Spec document text is included directly below, inlined into this
19
- prompt under `Requirements:` and `Tech-Spec:` labels — do NOT attempt to locate or Read
20
- `{PREFIX}-Requirements.md`/`{PREFIX}-Tech-Spec.md` on disk; they are not guaranteed to exist
21
- (both documents are generated and stored in a split, per-item format by default — `req/`,
22
- `spec/` — and only assembled into a single file when a user explicitly requests it). Use the
23
- inlined text as the complete, authoritative content of each document for this validation pass.
19
+ prompt under `Requirements:` and `Tech-Spec:` labels. The Requirements text is the FULL document
20
+ (every Use Case's own flow detail included). The Tech-Spec text is its SKELETON ONLY — Section
21
+ 9's own component index table (Purpose, Addresses, Domain, Spec-link columns, one row per
22
+ component) plus every feature-wide section (Overview, Architecture, Security, Database,
23
+ Configuration, Implementation Order, Risks). It deliberately does NOT include any component's
24
+ own Change/Algorithm/Schema/Tests design prose — that per-component detail is validated
25
+ separately, on request, via `needsComponentDetail` (Phase 3). Do NOT attempt to locate or Read
26
+ `{PREFIX}-Requirements.md`/`{PREFIX}-Tech-Spec.md` or any `spec/components/*` file on disk;
27
+ use the inlined skeleton as the complete Tech-Spec-level input for this pass.
24
28
  4. If either inlined document's text is empty or missing, record a finding identifying it, skip
25
29
  checks that require it, and complete Phases 6 and 7 with `valid: false`. Never claim full
26
30
  coverage when an input could not be read.
@@ -60,16 +64,26 @@ For each claim extracted in Phase 2, check whether it is addressed in the target
60
64
  | Every out-of-scope item → Out of Scope section | Listed |
61
65
  | Every functional behaviour → at least one Acceptance Criterion | AC in Given/When/Then format |
62
66
 
63
- ### Tech-Spec coverage (against the inlined Tech-Spec text)
67
+ ### Tech-Spec coverage (against the inlined Tech-Spec SKELETON — Section 9's component table and
68
+ feature-wide sections only; no per-component design body is available in this pass)
64
69
 
65
70
  | Check | Pass condition |
66
71
  |-------|---------------|
67
- | Every API endpoint in feature.md → endpoint spec in Tech-Spec | Method, path, request/response documented |
68
- | Every data model field → entity definition in Tech-Spec | Model/class definition present |
69
- | Every new service/component → listed in File Inventory | New files section |
70
- | Every modified file → listed in File Inventory | Modified files section |
71
- | Every Use Case ID from Requirements → referenced in Implementation Order | Traceability present |
72
- | Every external integration → documented in External Integrations | Integration section present |
72
+ | Every new/modified service/component in feature.md → listed in File Inventory | Row present with matching Purpose/Domain |
73
+ | Every external integration → documented in External Integrations (Section 5's own feature-wide prose, if any) | Mentioned explicitly |
74
+ | A claim needs exact technical detail not visible in Purpose/Addresses/Domain (e.g. an endpoint's precise method/path/response shape, a schema field, an error code) | Do NOT guess and do NOT mark it a gap outright. Emit a `needsComponentDetail` entry instead (see below) — never silently drop the check |
75
+
76
+ AC↔component traceability (every Use Case/AC referenced by the right component, and vice versa) is
77
+ NOT this agent's responsibility — it is checked deterministically, by exact ID, by the host after
78
+ this call returns. Do not attempt it here; a prose-based traceability finding in this pass would
79
+ only duplicate or conflict with that exact-ID check.
80
+
81
+ **When to use `needsComponentDetail` vs a normal finding:** if feature.md describes something a
82
+ component row's Purpose plausibly relates to, but you cannot confirm the EXACT technical detail
83
+ without reading that component's own design body, emit a `needsComponentDetail` entry naming that
84
+ row's own Spec-link `fileSlug` and the specific claim — do not guess, and do not silently treat it
85
+ as either covered or missing. If NO component row is even plausibly related to the claim at all,
86
+ that is a normal missing-coverage finding, not a drill-down case.
73
87
 
74
88
  ---
75
89
 
@@ -154,18 +168,34 @@ Compose `{PREFIX}-Validation-Report.md`'s full content following this template:
154
168
  Return a single JSON object with exactly these fields, without Markdown fences or explanatory prose:
155
169
 
156
170
  ```json
157
- {"valid": true, "findings": [], "report": "# Validation Report — FTR-001\n\n## Summary\n..."}
171
+ {"valid": true, "findings": [], "report": "# Validation Report — FTR-001\n\n## Summary\n...", "needsComponentDetail": []}
158
172
  ```
159
173
 
160
174
  For remaining gaps:
161
175
 
162
176
  ```json
163
- {"valid": false, "findings": ["Requirements: UC-01 lacks an acceptance criterion", "Tech-Spec: POST /api/users lacks a response definition"], "report": "# Validation Report — FTR-001\n\n## Summary\n..."}
177
+ {"valid": false, "findings": ["Requirements: UC-01 lacks an acceptance criterion", "Tech-Spec: POST /api/users lacks a response definition"], "report": "# Validation Report — FTR-001\n\n## Summary\n...", "needsComponentDetail": []}
178
+ ```
179
+
180
+ For a claim needing component-body detail:
181
+
182
+ ```json
183
+ {"valid": false, "findings": [], "report": "# Validation Report — FTR-001\n\n## Summary\n...", "needsComponentDetail": [{"claim": "endpoint returns 401 on expired token", "fileSlug": "AuthMiddleware.cs.md"}]}
164
184
  ```
165
185
 
166
186
  - `valid` is a boolean: `true` only when all required inputs were read, coverage is complete, and no unresolved gaps remain.
167
187
  - `findings` is an array of strings, empty only for a clean validation. Missing inputs and ambiguous claims are unresolved findings.
168
188
  - `report` is a non-empty string: the complete Phase 6 document content, verbatim, as the host will write it byte-for-byte to `{PREFIX}-Validation-Report.md`.
189
+ - `needsComponentDetail` is an array of `{ claim, fileSlug }` objects — one per claim you could not
190
+ confirm from the Tech-Spec skeleton alone, naming the specific component (by its Spec-link
191
+ `fileSlug`) whose Purpose/Domain plausibly covers it. Empty when every claim was resolvable from
192
+ the skeleton (the common case for small/medium features). An entry here is NOT the same as a
193
+ `findings` entry — do not also add a `findings` string for the same claim; the host resolves
194
+ `needsComponentDetail` entries separately and may add its own finding if the drill-down fails.
195
+ - A `valid: false` result caused only by `needsComponentDetail` entries (no other findings) is
196
+ still `valid: false` with an empty `findings` array — this is expected, not a contradiction: the
197
+ host treats pending component-detail checks as unresolved until its own drill-down pass clears
198
+ them.
169
199
  - The caller supplies `--json-schema`; use the runtime's structured-output mechanism to return this object in `structured_output`. Plain-text JSON alone does not satisfy that contract.
170
200
  - There is only one deliverable now: the structured verdict, with `report` inside it. There is no separate Write step.
171
201
 
@@ -182,4 +212,4 @@ If a claim is contradictory or its correct resolution is ambiguous, do not guess
182
212
  - **Be specific about gaps** — point to the exact section, claim, and what is missing
183
213
  - **Perform one validation pass per invocation** — return remaining gaps to the host
184
214
  - **Out-of-scope items are also validated** — ensure they are not accidentally included in outputs
185
- - **Traceability is mandatory** — every Use Case ID in Requirements must appear in Tech-Spec
215
+ - **AC↔component traceability is NOT your job** — the host checks it deterministically, by exact ID, after this call returns (see Phase 3)
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: gaia-validate-tech-spec-component-detail
3
+ description: "Verifies a small batch of claims against the specific component design files named by the host. Input: a list of {claim, component file path} pairs"
4
+ model: haiku
5
+ tools: Read, Glob, Grep
6
+ ---
7
+
8
+ # Validate Tech-Spec Component Detail
9
+
10
+ A narrow QA agent: for each claim in this batch, Read ONLY the one named component file and decide
11
+ whether its own content satisfies that claim. Do not Read, Glob, or Grep for `feature.md`,
12
+ `req/index.md`, `spec/index.md`, or any component file NOT explicitly named in this prompt — every
13
+ file you need is already named below; searching for anything else is out of scope for this agent
14
+ and wastes the narrow batch this call was sized for.
15
+
16
+ ## Phase 1 — Load This Batch
17
+
18
+ The host supplies, in the dispatch prompt, one entry per line:
19
+ `Claim: {claim text}` followed by `Component file: {absolute path}`.
20
+ Multiple claims may name the SAME component file — Read each distinct file only once, then check
21
+ every claim assigned to it against that one reading.
22
+
23
+ ## Phase 2 — Verify Each Claim
24
+
25
+ For each claim, Read its named file (if not already read for an earlier claim in this batch) and
26
+ decide: does this file's own actual content (its Change/Algorithm/Schema/Tests sections, or
27
+ whichever of those the file actually has) satisfy the claim? Be specific and literal — a claim
28
+ about an exact endpoint method/path, a schema field, or an error code is satisfied only if that
29
+ exact detail is actually present in the file, not merely plausible given the file's general topic.
30
+
31
+ ## Phase 3 — Structured Completion (MANDATORY — your final response)
32
+
33
+ Return a single JSON object with exactly one field:
34
+
35
+ ```json
36
+ {"findings": ["Tech-Spec: components/AuthMiddleware.cs.md does not document a 401 response for an expired token, as feature.md requires"]}
37
+ ```
38
+
39
+ - `findings` is an array of strings, one per claim that is NOT satisfied by its named file's
40
+ content. A satisfied claim produces NO entry — do not report successes.
41
+ - Every finding string is prefixed `"Tech-Spec: "`, matching the same convention
42
+ `gaia-validate-feature-docs`'s own `findings` array and the host's deterministic
43
+ AC↔component check already use, so the host can merge every source into one array with no
44
+ format-specific handling for this agent's own output.
45
+ - An empty array (`{"findings": []}`) means every claim in this batch was confirmed.
46
+ - The caller supplies `--json-schema`; use the runtime's structured-output mechanism to return this
47
+ object in `structured_output`.
48
+
49
+ ## Guidelines
50
+
51
+ - **Read only the named files** — never search for or infer any other document.
52
+ - **One call, one batch** — do not ask the host for more context; if a named file's content is
53
+ genuinely ambiguous about a claim, report it as NOT satisfied (fail closed) rather than guessing.
54
+ - **Be literal, not generous** — "plausibly related" is not the same as "actually documented".
@@ -90,6 +90,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
90
90
  })
91
91
  }
92
92
 
93
+ // FTR-027: resume-by-existence must not trust mere presence — a use-case file that exists but
94
+ // is garbled/off-topic (e.g. echoes a different UC) should be regenerated, not skipped forever.
95
+ // Only the ID fragment is checked, never the free-text title after the colon: the title is
96
+ // prose the agent may legitimately phrase differently across a retry, and comparing it would
97
+ // reject a valid regeneration for no real reason.
98
+ function isValidUseCaseFile(content, ucId) {
99
+ const escaped = ucId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
100
+ return new RegExp(`^###\\s+${escaped}:\\s`).test(content.trimStart())
101
+ }
102
+
93
103
  // Business Rules' "Applies to" / Acceptance Criteria's "Related UC" columns are the same
94
104
  // cross-cutting-reference shape: a comma-separated UC-ID list, or the literal "All UCs". Both
95
105
  // are 100% mechanically derivable from req/index.md's own tables — no LLM judgment needed,
@@ -185,7 +195,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
185
195
  // If the skeleton itself was just (re)generated, every use case must regenerate too —
186
196
  // an existing use-case file would have been written against the OLD skeleton's row
187
197
  // (title/actor/priority may have changed) and cannot be trusted as-is.
188
- if (!regenSkeleton && c.exists(ucPath)) continue // resume: skip, no dispatch
198
+ if (!regenSkeleton && c.exists(ucPath) && isValidUseCaseFile(c.read(ucPath), row.id)) continue // resume: skip, no dispatch
189
199
  const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
190
200
  { label: `requirements-use-case:${row.id}` })
191
201
  c.writeOutput(ucPath, detail.trimEnd() + ucCrossReferences(c.read(reqIndexPath), row.id))
@@ -241,6 +251,97 @@ return c.run(meta.name, featureDir, prefix, async () => {
241
251
  return entries
242
252
  }
243
253
 
254
+ // FTR-027: duplicates tech-spec-assembler.js's parseComponentDetail's own fence-aware
255
+ // "## Change"/"## Tests" heading scan (see that file for the full "why length-aware, not a
256
+ // boolean toggle" rationale) — returns a boolean instead of throwing/returning parsed sections,
257
+ // since every call site here only needs the yes/no "is this a structurally valid component
258
+ // file" signal, for resume-by-existence: a component file that exists but fails this check
259
+ // (e.g. a whole-response fence wrap, a truncated reply) must be regenerated, not skipped
260
+ // forever — found via a real incident where exactly this silently blocked every retry.
261
+ function _nonFencedHeadingIndexesLocal(lines, predicate) {
262
+ let fenceLen = 0
263
+ const indexes = []
264
+ for (let i = 0; i < lines.length; i++) {
265
+ const m = lines[i].match(/^(`{3,})/)
266
+ if (m) {
267
+ const runLen = m[1].length
268
+ if (fenceLen === 0) fenceLen = runLen
269
+ else if (runLen >= fenceLen) fenceLen = 0
270
+ continue
271
+ }
272
+ if (fenceLen !== 0) continue
273
+ if (predicate(lines[i])) indexes.push(i)
274
+ }
275
+ return indexes
276
+ }
277
+ function isValidComponentDetail(content) {
278
+ const lines = content.split('\n')
279
+ const changeIdx = _nonFencedHeadingIndexesLocal(lines, l => l.trim() === '## Change')[0] ?? -1
280
+ const testsIdx = _nonFencedHeadingIndexesLocal(lines, l => l.trim() === '## Tests')[0] ?? -1
281
+ return changeIdx !== -1 && testsIdx !== -1
282
+ }
283
+
284
+ // FTR-028/UC-02: purely deterministic, zero-LLM-cost AC<->component traceability check. Reuses
285
+ // the two table parsers already duplicated in this file — no new parsing contract invented.
286
+ // Both directions produce a "Tech-Spec: " finding (always closed by a Tech-Spec revision —
287
+ // either add missing coverage, or fix a bad AC-ID reference).
288
+ function computeAcComponentTraceabilityGaps(reqIndexContent, specIndexContent) {
289
+ const acIds = new Set(parseAcTableRelatedUc(reqIndexContent).map(r => r.id))
290
+ const components = parseComponentsTable(specIndexContent)
291
+ const addressedAcIds = new Set()
292
+ const findings = []
293
+ for (const comp of components) {
294
+ const refs = (comp.addresses || '').split(',').map(s => s.trim()).filter(s => s && s !== '—')
295
+ for (const ref of refs) {
296
+ addressedAcIds.add(ref)
297
+ if (!acIds.has(ref)) findings.push(`Tech-Spec: component ${comp.filePath} references nonexistent ${ref}`)
298
+ }
299
+ }
300
+ for (const id of acIds) {
301
+ if (!addressedAcIds.has(id)) findings.push(`Tech-Spec: ${id} is not addressed by any Tech-Spec component`)
302
+ }
303
+ return findings
304
+ }
305
+
306
+ // FTR-028/UC-03: validates each needsComponentDetail entry's fileSlug against the real
307
+ // component table (a hallucinated/mistyped reference is dropped and reported, never
308
+ // dispatched), deduplicates by fileSlug (two claims against the same file collapse into one
309
+ // batch slot), and splits the remaining unique components into groups of at most
310
+ // DRILL_DOWN_BATCH_SIZE — never one call per claim, never one call covering every flagged
311
+ // component regardless of count.
312
+ const DRILL_DOWN_BATCH_SIZE = 8 // fixed constant, not externally configurable
313
+ function prepareDrillDownBatches(needsComponentDetail, specIndexContent) {
314
+ const validSlugs = new Set(parseComponentsTable(specIndexContent).map(c => c.fileSlug))
315
+ const extraFindings = []
316
+ const claimsByFileSlug = new Map()
317
+ for (const entry of needsComponentDetail || []) {
318
+ if (!validSlugs.has(entry.fileSlug)) {
319
+ extraFindings.push(`Tech-Spec: component-detail check referenced a non-existent file "${entry.fileSlug}" for claim "${entry.claim}"`)
320
+ continue
321
+ }
322
+ if (!claimsByFileSlug.has(entry.fileSlug)) claimsByFileSlug.set(entry.fileSlug, [])
323
+ claimsByFileSlug.get(entry.fileSlug).push(entry.claim)
324
+ }
325
+ const fileSlugs = [...claimsByFileSlug.keys()]
326
+ const batches = []
327
+ for (let i = 0; i < fileSlugs.length; i += DRILL_DOWN_BATCH_SIZE) {
328
+ batches.push(fileSlugs.slice(i, i + DRILL_DOWN_BATCH_SIZE)
329
+ .map(slug => ({ fileSlug: slug, claims: claimsByFileSlug.get(slug) })))
330
+ }
331
+ return { batches, extraFindings }
332
+ }
333
+
334
+ // specDir is already absolute (derived from c.feature()'s realpath-resolved featureDir), so
335
+ // this needs no extra path.resolve — same reasoning that already lets featurePath be handed to
336
+ // agents directly elsewhere in this file.
337
+ function buildDrillDownPrompt(batch) {
338
+ return batch.map(({ fileSlug, claims }) =>
339
+ claims.map(claim => `Claim: ${claim}\nComponent file: ${specDir}/components/${fileSlug}`).join('\n\n')
340
+ ).join('\n\n')
341
+ }
342
+
343
+ const drillDownId = 'gaia.agent.planner.validate-tech-spec-component-detail'
344
+
244
345
  function buildComponentPrompt(row, components, featurePathArg, componentTemplate) {
245
346
  const compactIndex = components.map(c => `${c.filePath} (${c.domain})`).join('\n')
246
347
  const base = `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
@@ -254,6 +355,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
254
355
  // FTR-020). Rewrites any colliding fileSlug's Spec-column link in place, right-to-left
255
356
  // prepending path segments until unique.
256
357
  //
358
+ // Also enforces the ".md" suffix itself, never trusting the model to have included it. The
359
+ // component template's own contract calls this Spec link "provisional, not final" specifically
360
+ // so the host can rewrite it — originally only exercised for collisions, but a real production
361
+ // run showed the model omits ".md" entirely a meaningful fraction of the time (confirmed: 20 of
362
+ // ~61 components in one real Tech-Spec skeleton), writing e.g. `components/Settings.tsx`
363
+ // instead of `components/Settings.tsx.md` — the host then faithfully wrote that component's
364
+ // Markdown CONTENT into a file literally named `Settings.tsx`. Normalized here before the
365
+ // collision check runs, so two rows that only differ by their original extension (already
366
+ // present in the Path column) still correctly collide-and-disambiguate on the normalized slug.
367
+ //
257
368
  // Operates per-LINE, not via a global string .replace() over the whole document — a real bug,
258
369
  // found by testing two colliding entries directly: when both rows share byte-identical Spec
259
370
  // text (the whole point of a collision), a global .replace() always rewrites the FIRST
@@ -270,6 +381,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
270
381
  const m = cells[4].match(/\(components\/([^)]+)\)/)
271
382
  if (!m) continue // header/separator row, or a row with no valid Spec link yet
272
383
  let slug = m[1]
384
+ if (!slug.endsWith('.md')) {
385
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
386
+ const normalized = `${slug}.md`
387
+ lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${normalized}`)
388
+ slug = normalized
389
+ }
273
390
  if (seenSlugs.has(slug)) {
274
391
  const segments = filePath.split('/')
275
392
  let depth = 1
@@ -279,7 +396,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
279
396
  candidate = segments.slice(-depth).join('-') + '.md'
280
397
  }
281
398
  const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
282
- lines[i] = line.replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
399
+ lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
283
400
  slug = candidate
284
401
  }
285
402
  seenSlugs.add(slug)
@@ -321,7 +438,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
321
438
  const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
322
439
  for (const comp of components) {
323
440
  const compPath = `${specDir}/components/${comp.fileSlug}`
324
- if (!regenSkeleton && c.exists(compPath)) continue // resume: skip, no dispatch
441
+ if (!regenSkeleton && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
325
442
  const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
326
443
  { label: `tech-spec-component:${comp.fileSlug}` })
327
444
  c.writeOutput(compPath, detail)
@@ -352,8 +469,17 @@ return c.run(meta.name, featureDir, prefix, async () => {
352
469
  // for free by gating on the assembled document's own freshness (which could never look
353
470
  // "complete" while a referenced item was missing) — removing that gate (FTR-025) removed this
354
471
  // side effect along with it, so it needs restating explicitly here.
355
- const anyUcFileMissing = indexContent => parseUseCaseIndexTable(indexContent).some(row => !c.exists(`${reqDir}/${row.file}`))
356
- const anyComponentFileMissing = indexContent => parseComponentsTable(indexContent).some(comp => !c.exists(`${specDir}/components/${comp.fileSlug}`))
472
+ // FTR-027: "missing" now also means "present but invalid" — a structurally broken-but-existing
473
+ // file must trigger re-entry into generation the same way an absent one already does, otherwise
474
+ // the outer gate alone could never notice it (only the per-item loop would, one phase later).
475
+ const anyUcFileMissing = indexContent => parseUseCaseIndexTable(indexContent).some(row => {
476
+ const ucPath = `${reqDir}/${row.file}`
477
+ return !c.exists(ucPath) || !isValidUseCaseFile(c.read(ucPath), row.id)
478
+ })
479
+ const anyComponentFileMissing = indexContent => parseComponentsTable(indexContent).some(comp => {
480
+ const compPath = `${specDir}/components/${comp.fileSlug}`
481
+ return !c.exists(compPath) || !isValidComponentDetail(c.read(compPath))
482
+ })
357
483
 
358
484
  let changed = false
359
485
  if (force || !fresh(reqIndexPath) || missingSections(c.read(reqIndexPath), 9).length ||
@@ -380,26 +506,81 @@ return c.run(meta.name, featureDir, prefix, async () => {
380
506
  // finding mentioning both document names, or neither, would mis-route or
381
507
  // silently skip). The validator agent itself decides which document(s) its
382
508
  // own findings implicate.
509
+ // FTR-028/UC-01: Tech-Spec text is the raw skeleton (c.read(specIndexPath)), never
510
+ // assembledTechSpec's fully-spliced output — bounds this prompt's size to component COUNT,
511
+ // not per-component prose length. Requirements stays assembledRequirements (full UC
512
+ // detail), unchanged — no real incident has shown UC count x flow length to pose the same
513
+ // risk.
383
514
  const result = await c.worker(valId,
384
- `${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${assembledTechSpec}\n\n` +
385
- 'Validate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), needsRequirementsFix/needsTechSpecFix (true only if at least one finding implicates that document), and the full validation report content. Do not dispatch other agents.',
386
- { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, reqIndexPath, specIndexPath])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
387
- // A model returning valid:true alongside non-empty findings (or valid:false
388
- // with none) contradicts its own schema's intent — never silently treated as
389
- // "not clean yet" and spent on another cycle without surfacing the
390
- // inconsistency; fail closed instead of masking a confused response.
391
- if (result.valid === (result.findings.length > 0)) {
392
- throw new Error(`Validation agent returned a contradictory result on cycle ${cycle}: valid=${result.valid} with ${result.findings.length} finding(s)`)
515
+ `${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${c.read(specIndexPath)}\n\n` +
516
+ 'Validate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), needsRequirementsFix/needsTechSpecFix (true only if at least one finding implicates that document), needsComponentDetail (claims needing a component\'s own design body to verify — see your own instructions for when to use this instead of a finding), and the full validation report content. Do not dispatch other agents.',
517
+ { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, reqIndexPath, specIndexPath])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' }, needsComponentDetail: { type: 'array', items: { type: 'object', properties: { claim: { type: 'string' }, fileSlug: { type: 'string' } }, required: ['claim', 'fileSlug'] } } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
518
+ // needsComponentDetail is declared in `properties` but deliberately NOT in the schema's
519
+ // top-level `required` array: every pre-existing mocked test response for this agent
520
+ // (predating this feature) omits the field entirely, and making it hard-required would
521
+ // break every one of them for no real gain — prepareDrillDownBatches already treats an
522
+ // absent/undefined value as "no entries" via `needsComponentDetail || []`.
523
+
524
+ // Raw self-consistency guard on the MODEL'S OWN response, before any host-side merge: a
525
+ // pending needsComponentDetail entry is itself a form of "not yet resolved", same as a
526
+ // non-empty findings array — valid:true requires BOTH to be empty, valid:false requires at
527
+ // least one to be non-empty. This is intentionally separate from mergedValid below (which
528
+ // is NOT required to track this raw self-report: a valid:false caused only by
529
+ // needsComponentDetail entries that the drill-down subsequently confirms must still resolve
530
+ // to a clean cycle, not a false "contradiction").
531
+ const rawHasOpenItems = result.findings.length > 0 || (result.needsComponentDetail || []).length > 0
532
+ if (result.valid === rawHasOpenItems) {
533
+ throw new Error(`Validation agent returned a contradictory result on cycle ${cycle}: valid=${result.valid} with ${result.findings.length} finding(s) and ${(result.needsComponentDetail || []).length} needsComponentDetail entry(ies)`)
534
+ }
535
+
536
+ // FTR-028/UC-02 + UC-03: every finding source for this cycle, computed/dispatched AFTER the
537
+ // main call returns (re-reading the index files fresh, since a prior cycle may have
538
+ // regenerated them). Zero added LLM cost when both come back empty (the common case).
539
+ const reqIndexContent = c.read(reqIndexPath)
540
+ const specIndexContent = c.read(specIndexPath)
541
+ const structuralFindings = computeAcComponentTraceabilityGaps(reqIndexContent, specIndexContent)
542
+ const { batches, extraFindings } = prepareDrillDownBatches(result.needsComponentDetail, specIndexContent)
543
+
544
+ const drillDownFindings = []
545
+ for (let b = 0; b < batches.length; b++) {
546
+ const batchResult = await c.worker(drillDownId, buildDrillDownPrompt(batches[b]),
547
+ { label: `validation:${cycle}:component-detail:${b + 1}`,
548
+ schema: { type: 'object', properties: { findings: { type: 'array', items: { type: 'string' } } }, required: ['findings'] } })
549
+ drillDownFindings.push(...batchResult.findings)
550
+ }
551
+
552
+ // BR-04: merge everything BEFORE the contradiction guard. A model returning valid:true
553
+ // while the host's own independent checks found a real gap is not a self-contradiction (it
554
+ // was never shown that gap) — it is simply now-invalid, same as any other unresolved
555
+ // finding.
556
+ const newFindings = [...structuralFindings, ...extraFindings, ...drillDownFindings]
557
+ const mergedFindings = [...result.findings, ...newFindings]
558
+ // Deliberately NOT `result.valid && ...`: result.valid may be false SOLELY because of
559
+ // needsComponentDetail entries that the drill-down above has now fully confirmed (zero
560
+ // drillDownFindings) — that must resolve to clean, not stay stuck on the model's own
561
+ // now-stale self-report. The only authoritative signal for the final verdict is whether any
562
+ // finding remains, from any source, after every check this cycle has run.
563
+ const mergedValid = mergedFindings.length === 0
564
+ const mergedNeedsTechSpecFix = result.needsTechSpecFix || newFindings.some(f => f.startsWith('Tech-Spec:'))
565
+ const mergedNeedsRequirementsFix = result.needsRequirementsFix || newFindings.some(f => f.startsWith('Requirements:'))
566
+
567
+ // c.worker() already wrote result.report verbatim via its own outputs[0] contract; only
568
+ // overwrite it when this cycle actually added findings the LLM's own report text doesn't
569
+ // mention — the common clean case (newFindings.length === 0) never re-writes the file.
570
+ if (newFindings.length) {
571
+ c.write(file('Validation-Report'), result.report +
572
+ '\n\n## Additional findings (deterministic + component-detail drill-down)\n' +
573
+ newFindings.map(f => `- ${f}`).join('\n') + '\n')
393
574
  }
394
- if (result.valid) {
575
+ if (mergedValid) {
395
576
  if (!fresh(file('Validation-Report'), [featurePath, reqIndexPath, specIndexPath])) throw new Error('Validation agent did not produce its report')
396
577
  validationSummary = `0 gaps (clean on cycle ${cycle})`
397
578
  break
398
579
  }
399
580
  if (cycle === 3) throw new Error('Validation gaps remain after 3 cycles')
400
- if (!result.needsRequirementsFix && !result.needsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
401
- if (result.needsRequirementsFix) { await generateRequirements(true); assembledRequirements = assembleRequirements() }
402
- if (result.needsTechSpecFix) { await generateTechSpec(true); assembledTechSpec = assembleTechSpec() }
581
+ if (!mergedNeedsRequirementsFix && !mergedNeedsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
582
+ if (mergedNeedsRequirementsFix) { await generateRequirements(true); assembledRequirements = assembleRequirements() }
583
+ if (mergedNeedsTechSpecFix) { await generateTechSpec(true); assembledTechSpec = assembleTechSpec() }
403
584
  }
404
585
  }
405
586
  // Binds Gate 1's eventual approval to the EXACT Requirements/Tech-Spec bytes
@@ -234,6 +234,22 @@ function buildTaskTechSpecExcerpt(components, task, specDir, specIndexContent) {
234
234
  }).join('\n\n')
235
235
  }
236
236
 
237
+ // FTR-027: duplicates wbs-assembler.js's own _parseVerificationCommands algorithm (last
238
+ // occurrence of the marker wins — an illustrative example earlier in Implementation notes must
239
+ // never be mistaken for the real section) — returns a boolean instead of the parsed array, since
240
+ // resume-by-status only needs the yes/no "does this task file have at least one verification
241
+ // command" signal, per the task template's own documented "Must be non-empty" contract. A task
242
+ // file that exists and is marked `generated` but has none is regenerated, not skipped forever.
243
+ const FENCE_RE_LOCAL = /```[^\n]*\n([\s\S]*?)```/g
244
+ function hasVerificationCommands(content) {
245
+ const marker = '**Verification commands:**'
246
+ const idx = content.lastIndexOf(marker)
247
+ if (idx === -1) return false
248
+ const verifPart = content.slice(idx + marker.length)
249
+ FENCE_RE_LOCAL.lastIndex = 0
250
+ return FENCE_RE_LOCAL.test(verifPart)
251
+ }
252
+
237
253
  // Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
238
254
  // the full skeleton's sibling task list (id + title + outcome only, for duplicate-
239
255
  // implementation awareness), and the Tech-Spec excerpt (FTR-026: already pre-sliced to this
@@ -351,7 +367,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
351
367
  for (const taskId of phase.taskIds) {
352
368
  const task = index.tasks[taskId]
353
369
  const detailPath = `${wbsDir}/${task.detailFile}`
354
- if (task.status === 'generated' && c.exists(detailPath)) continue // resume: skip, no dispatch
370
+ if (task.status === 'generated' && c.exists(detailPath) && hasVerificationCommands(c.read(detailPath))) continue // resume: skip, no dispatch
355
371
  const techSpecExcerpt = hasSpec
356
372
  ? buildTaskTechSpecExcerpt(components, task, specDir, c.read(specIndexPath))
357
373
  : c.read(file('Tech-Spec.md')) // legacy fallback: unchanged, full text