@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 +16 -0
- package/lib/agent-registry.js +13 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-validate-feature-docs.md +45 -15
- package/src/claude/agents/gaia-validate-tech-spec-component-detail.md +54 -0
- package/src/claude/workflows/pm-phase1.js +199 -18
- package/src/claude/workflows/pm-phase2.js +17 -1
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
|
|
package/lib/agent-registry.js
CHANGED
|
@@ -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
|
@@ -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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
68
|
-
| Every
|
|
69
|
-
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
- **
|
|
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] =
|
|
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
|
-
|
|
356
|
-
|
|
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${
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 (
|
|
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 (!
|
|
401
|
-
if (
|
|
402
|
-
if (
|
|
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
|