@dtlabs/ai-toolkit 0.20.0 → 0.21.1
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/lib/workflow-control.js +103 -18
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-requirements.md +5 -4
- 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/scripts/req-assembler.js +10 -0
- package/src/claude/scripts/req-graph-checks.js +24 -0
- package/src/claude/scripts/req-skeleton-check.js +9 -2
- package/src/claude/workflows/pm-phase1.js +157 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.21.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.0...v0.21.1) (2026-10-20)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* harden pipeline resilience across four real-world failures (FTR-029) ([ef57862](https://github.com/dtlab-tech/ai-toolkit/commit/ef578623541c084d17aeb6b0f53dba076e5a449e))
|
|
8
|
+
* **workflow-control, pm-phase1:** harden pipeline resilience across four real-world failures (FTR-029) ([34a9ec9](https://github.com/dtlab-tech/ai-toolkit/commit/34a9ec9db645d1475e41e0c3875c8431a1ae0fda))
|
|
9
|
+
|
|
10
|
+
## [0.21.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.20.0...v0.21.0) (2026-10-20)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* bound the feature-doc validator to the Tech-Spec skeleton (FTR-028) ([80869ce](https://github.com/dtlab-tech/ai-toolkit/commit/80869ced8b8d24ef7b0669fabbb152e8bf243794))
|
|
16
|
+
* **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))
|
|
17
|
+
|
|
2
18
|
## [0.20.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.2...v0.20.0) (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/lib/workflow-control.js
CHANGED
|
@@ -169,25 +169,31 @@ function createControl(options) {
|
|
|
169
169
|
});
|
|
170
170
|
(options.log || (() => {}))('Worker evidence: ' + evidence.file);
|
|
171
171
|
try {
|
|
172
|
-
// Retry a bounded number of times,
|
|
173
|
-
//
|
|
174
|
-
// timeout, overload, etc.),
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
172
|
+
// Retry a bounded number of times, for either of two recognizable, safe-to-retry
|
|
173
|
+
// conditions: (a) a transient CLI/SDK-level infrastructure failure ("API Error: ..." —
|
|
174
|
+
// stream idle timeout, overload, etc.), or (b) — added by FTR-029 — a schema-required
|
|
175
|
+
// attempt that comes back a clean exit but with no usable structured output even after the
|
|
176
|
+
// existing extractDelimitedStructuredOutput fallback. (b) was, until this feature, treated
|
|
177
|
+
// as a permanent content defect never worth retrying — confirmed EMPIRICALLY WRONG for at
|
|
178
|
+
// least one real, reproducible case: gaia-validate-feature-docs consistently replies with a
|
|
179
|
+
// logically-complete JSON candidate (valid/findings/report present) that simply omits two
|
|
180
|
+
// now-optional boolean fields (needsRequirementsFix/needsTechSpecFix) the schema used to
|
|
181
|
+
// require unconditionally. Neither condition is a permission denial or a present-but-wrong-
|
|
182
|
+
// type schema violation, which would still just fail the same way again. Confirmed safe to
|
|
183
|
+
// retry: every agent dispatched through this path is read-only by design (no Write tool —
|
|
184
|
+
// see this function's own comment on why outputs are host-written, not agent-written; any
|
|
185
|
+
// Bash access granted is explicitly scoped to read-only assessment), so there is no partial
|
|
186
|
+
// side effect from a dead stream to clean up before retrying. Token usage from EVERY attempt
|
|
187
|
+
// is accumulated — a failed attempt still burns real output tokens before the stream dies —
|
|
188
|
+
// and each attempt's outcome is kept in evidence for diagnosing a recurring pattern like
|
|
189
|
+
// this one, rather than only ever seeing the last attempt.
|
|
185
190
|
let result;
|
|
186
191
|
let accumulatedTokens = 0;
|
|
187
192
|
const attempts = [];
|
|
193
|
+
let currentPrompt = prompt;
|
|
188
194
|
for (let attempt = 1; attempt <= WORKER_MAX_ATTEMPTS; attempt++) {
|
|
189
195
|
result = await (options.dispatch || spawnClaudeAgent)({
|
|
190
|
-
claudePath: options.claudePath, cwd: root, args: cliArgs, prompt,
|
|
196
|
+
claudePath: options.claudePath, cwd: root, args: cliArgs, prompt: currentPrompt,
|
|
191
197
|
taskTimeoutMs: options.taskTimeoutMs || 1800000,
|
|
192
198
|
});
|
|
193
199
|
const usage = result.result?.usage;
|
|
@@ -201,8 +207,15 @@ function createControl(options) {
|
|
|
201
207
|
resultPreview: typeof result.result?.result === 'string' ? result.result.result.slice(0, 300) : null,
|
|
202
208
|
tokens: attemptTokens });
|
|
203
209
|
evidence.save({ attempts });
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
const transientApiError = _isTransientWorkerApiError(result);
|
|
211
|
+
const missingStructuredOutput = !!opts.schema && _isMissingStructuredOutput(result, opts.schema);
|
|
212
|
+
if ((!transientApiError && !missingStructuredOutput) || attempt === WORKER_MAX_ATTEMPTS) break;
|
|
213
|
+
if (missingStructuredOutput) {
|
|
214
|
+
currentPrompt = `${prompt}\n\n${STRUCTURED_OUTPUT_RETRY_NUDGE}`;
|
|
215
|
+
await _sleep(STRUCTURED_OUTPUT_RETRY_BACKOFF_MS);
|
|
216
|
+
} else {
|
|
217
|
+
await _sleep(WORKER_RETRY_BACKOFF_MS[attempt - 1]);
|
|
218
|
+
}
|
|
206
219
|
}
|
|
207
220
|
// Count usage before any business/protocol check, including denied tools.
|
|
208
221
|
measuredTokens = accumulatedTokens > 0 ? accumulatedTokens : null;
|
|
@@ -364,6 +377,32 @@ function _isTransientWorkerApiError(result) {
|
|
|
364
377
|
typeof result.result.result === 'string' && /^API Error:/i.test(result.result.result.trim()));
|
|
365
378
|
}
|
|
366
379
|
|
|
380
|
+
// A clean-exit attempt (never a protocol-level failure — that's handled separately, after the
|
|
381
|
+
// loop, by the existing exitCode/parseError/timedOut/is_error check) whose structured_output is
|
|
382
|
+
// empty AND whose text contains NO JSON-shaped candidate at all — never a candidate that exists
|
|
383
|
+
// but fails schema validation (wrong field names/types), which is a deterministic content defect
|
|
384
|
+
// that would just fail the same way again, not worth retrying (hence the extra
|
|
385
|
+
// _hasAnyParseableJsonCandidate check, not just "extractDelimitedStructuredOutput returned null").
|
|
386
|
+
// Deliberately reuses extractDelimitedStructuredOutput itself for the recoverable case (not a
|
|
387
|
+
// cheaper proxy) so this retry decision and the post-loop fallback extraction can never disagree.
|
|
388
|
+
function _isMissingStructuredOutput(result, schema) {
|
|
389
|
+
if (result.exitCode !== 0 || result.parseError || result.timedOut ||
|
|
390
|
+
!result.result || result.result.is_error !== false) return false;
|
|
391
|
+
if (result.result.structured_output != null) return false;
|
|
392
|
+
if (typeof result.result.result !== 'string') return true;
|
|
393
|
+
if (extractDelimitedStructuredOutput(result.result.result, schema) != null) return false;
|
|
394
|
+
return !_hasAnyParseableJsonCandidate(result.result.result);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const STRUCTURED_OUTPUT_RETRY_NUDGE = 'Your previous response did not populate the required ' +
|
|
398
|
+
'structured JSON output via the --json-schema tool-call channel. You MUST return your final ' +
|
|
399
|
+
'answer ONLY as that structured output — do not narrate it in prose or wrap it in a Markdown ' +
|
|
400
|
+
'code fence in your visible reply.';
|
|
401
|
+
// No network condition to wait out here (unlike a transient API error) — an immediate retry is
|
|
402
|
+
// both faster and sufficient; reusing WORKER_RETRY_BACKOFF_MS's real multi-second delays would pay
|
|
403
|
+
// a wait with no corresponding benefit, for every genuinely unrecoverable response too.
|
|
404
|
+
const STRUCTURED_OUTPUT_RETRY_BACKOFF_MS = 0;
|
|
405
|
+
|
|
367
406
|
// A model asked to emit a JSON blob as part of a chat reply (rather than through a real
|
|
368
407
|
// serializer, i.e. whenever structured_output didn't get populated and extraction has to
|
|
369
408
|
// recover the payload from free text) is prone to one specific, well-understood defect: a
|
|
@@ -412,7 +451,7 @@ function _repairUnescapedControlCharsInStrings(text) {
|
|
|
412
451
|
// the real closing delimiter, so brace-balance recovery anchored after the last ```json or
|
|
413
452
|
// <json> marker is tried next — immune to that nesting because it tracks braces, not
|
|
414
453
|
// delimiters — followed by an unanchored whole-text brace span as the last resort.
|
|
415
|
-
function
|
|
454
|
+
function _collectJsonCandidates(text) {
|
|
416
455
|
const candidates = [];
|
|
417
456
|
for (const m of [...text.matchAll(/<(\w+)>([\s\S]*?)<\/\1>/g)].reverse()) candidates.push(m[2]);
|
|
418
457
|
for (const m of [...text.matchAll(/```[a-zA-Z]*\s*\n?([\s\S]*?)```/g)].reverse()) candidates.push(m[1]);
|
|
@@ -425,7 +464,11 @@ function extractDelimitedStructuredOutput(text, schema) {
|
|
|
425
464
|
const first = text.indexOf('{');
|
|
426
465
|
const last = text.lastIndexOf('}');
|
|
427
466
|
if (first !== -1 && last > first) candidates.push(text.slice(first, last + 1));
|
|
428
|
-
|
|
467
|
+
return candidates;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function extractDelimitedStructuredOutput(text, schema) {
|
|
471
|
+
for (const raw of _collectJsonCandidates(text)) {
|
|
429
472
|
const trimmed = raw.trim();
|
|
430
473
|
let parsed;
|
|
431
474
|
try {
|
|
@@ -442,6 +485,20 @@ function extractDelimitedStructuredOutput(text, schema) {
|
|
|
442
485
|
return null;
|
|
443
486
|
}
|
|
444
487
|
|
|
488
|
+
// FTR-029: distinguishes "no JSON-shaped candidate anywhere in the text" (genuinely missing
|
|
489
|
+
// structured output — worth a retry, see _isMissingStructuredOutput) from "a candidate exists but
|
|
490
|
+
// doesn't match this schema" (a deterministic content defect — e.g. wrong field names — that would
|
|
491
|
+
// just fail the same way again, never worth retrying). Deliberately ignores the schema entirely;
|
|
492
|
+
// only asks "did the model attempt to produce SOME JSON object at all".
|
|
493
|
+
function _hasAnyParseableJsonCandidate(text) {
|
|
494
|
+
for (const raw of _collectJsonCandidates(text)) {
|
|
495
|
+
const trimmed = raw.trim();
|
|
496
|
+
try { JSON.parse(trimmed); return true; } catch (e) { /* try repaired form below */ }
|
|
497
|
+
try { JSON.parse(_repairUnescapedControlCharsInStrings(trimmed)); return true; } catch (e2) { /* try next candidate */ }
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
445
502
|
// A worker with a declared JSON output has no Write tool (see worker()'s own comment on why):
|
|
446
503
|
// its entire response text becomes the file verbatim. Some models still wrap that deliverable
|
|
447
504
|
// in conversational prose and/or a Markdown code fence despite being told not to (observed
|
|
@@ -535,10 +592,38 @@ function _unwrapWholeResponseFence(text) {
|
|
|
535
592
|
if (start === -1) return null;
|
|
536
593
|
return lines.slice(start + 1, end - 1).join('\n').trim() + '\n';
|
|
537
594
|
}
|
|
595
|
+
// UC-03: an unclosed whole-response fence wrap (the model opens a ```fence — possibly after some
|
|
596
|
+
// conversational preamble, same as the existing closed-fence case already tolerates — but the
|
|
597
|
+
// response simply ends without a matching close) defeats _unwrapWholeResponseFence entirely (no
|
|
598
|
+
// closing line to match against) AND _hasUnfencedHeading (fence depth never returns to zero,
|
|
599
|
+
// hiding every real heading after the opener). Scans for the FIRST fence-open marker anywhere in
|
|
600
|
+
// the text (mirroring _unwrapWholeResponseFence's own tolerance for preamble before the fence);
|
|
601
|
+
// if NO line anywhere after it closes a fence of that same backtick length, the opener itself is
|
|
602
|
+
// the only defect — strip everything up to and including that one line. Same "always re-validate
|
|
603
|
+
// the candidate" discipline as the existing unwrap: the caller only uses this if the stripped
|
|
604
|
+
// result reveals a real heading.
|
|
605
|
+
function _stripUnclosedLeadingFence(text) {
|
|
606
|
+
const lines = text.split('\n');
|
|
607
|
+
for (let i = 0; i < lines.length; i++) {
|
|
608
|
+
const openMatch = lines[i].match(/^(`{3,})\S*\s*$/);
|
|
609
|
+
if (!openMatch) continue;
|
|
610
|
+
const fenceLen = openMatch[1].length;
|
|
611
|
+
let closesSomewhere = false;
|
|
612
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
613
|
+
const closeMatch = lines[j].match(/^(`{3,})\s*$/);
|
|
614
|
+
if (closeMatch && closeMatch[1].length === fenceLen) { closesSomewhere = true; break; }
|
|
615
|
+
}
|
|
616
|
+
if (!closesSomewhere) return lines.slice(i + 1).join('\n').trim() + '\n';
|
|
617
|
+
return null; // the first fence-opener found DOES close somewhere — not this case
|
|
618
|
+
}
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
538
621
|
function extractMarkdownDocument(text) {
|
|
539
622
|
if (_hasUnfencedHeading(text)) return text;
|
|
540
623
|
const unwrapped = _unwrapWholeResponseFence(text);
|
|
541
624
|
if (unwrapped != null && _hasUnfencedHeading(unwrapped)) return unwrapped;
|
|
625
|
+
const stripped = _stripUnclosedLeadingFence(text);
|
|
626
|
+
if (stripped != null && _hasUnfencedHeading(stripped)) return stripped;
|
|
542
627
|
return text;
|
|
543
628
|
}
|
|
544
629
|
|
package/package.json
CHANGED
|
@@ -123,11 +123,12 @@ Describe how pages connect and how the user moves between them.
|
|
|
123
123
|
|
|
124
124
|
## 7. Acceptance Criteria
|
|
125
125
|
|
|
126
|
-
For each use case, define testable acceptance criteria
|
|
126
|
+
For each use case, define testable acceptance criteria. Use the EXACT 4-column shape below — do
|
|
127
|
+
not add, remove, split, or reorder columns; the host's positional parser requires this exact shape:
|
|
127
128
|
|
|
128
|
-
| ID | Criterion | Related UC |
|
|
129
|
-
|
|
130
|
-
| AC-01 | Given [context], when [action], then [result] | UC-01 |
|
|
129
|
+
| ID | Criterion | Related UC | Priority |
|
|
130
|
+
|----|-----------|-------------|----------|
|
|
131
|
+
| AC-01 | Given [context], when [action], then [result] | UC-01 | Must |
|
|
131
132
|
|
|
132
133
|
## 8. Dependencies & Assumptions
|
|
133
134
|
|
|
@@ -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".
|
|
@@ -38,6 +38,15 @@ function parseTableRows(lines, start, end) {
|
|
|
38
38
|
line.split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1))
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Returns the raw header-row cells for the first Markdown table found inside [start, end), or
|
|
42
|
+
// null if no table is present at all. Companion to parseTableRows, which deliberately discards
|
|
43
|
+
// this same row — UC-02's AC-table header validation (req-skeleton-check.js) needs it back.
|
|
44
|
+
function parseTableHeader(lines, start, end) {
|
|
45
|
+
const tableLines = lines.slice(start, end).filter(l => l.trim().startsWith('|'))
|
|
46
|
+
if (tableLines.length < 1) return null
|
|
47
|
+
return tableLines[0].split('|').map(c => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1)
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
// The "## 2. Use Cases" index table: UC ID | Title | Actor | Priority | File.
|
|
42
51
|
// `File` is a Markdown link `[use-cases/UC-01.md](use-cases/UC-01.md)` — the link
|
|
43
52
|
// TARGET (inside the parens) is authoritative, not the link text.
|
|
@@ -123,5 +132,6 @@ module.exports = {
|
|
|
123
132
|
spliceSection2,
|
|
124
133
|
findSectionRange,
|
|
125
134
|
parseTableRows,
|
|
135
|
+
parseTableHeader,
|
|
126
136
|
AssemblyError,
|
|
127
137
|
}
|
|
@@ -63,9 +63,33 @@ function checkCrossReferencesResolve({ ucIds, acTable, businessRules }) {
|
|
|
63
63
|
return errors
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// UC-02: the AC table's first 3 columns must be exactly "ID", "Criterion", "Related UC" in that
|
|
67
|
+
// order — mirrors wb-graph-checks.js's own identical check for the Work-Breakdown-side AC table
|
|
68
|
+
// (same names, same order, same "at least 3 columns" tolerance for a missing trailing Priority
|
|
69
|
+
// column). Runs BEFORE req-assembler.js's parseAcTable positional destructure, so a malformed
|
|
70
|
+
// header is reported as its own distinct, correctly-attributed error instead of a misleading
|
|
71
|
+
// downstream DANGLING_AC_UC_REFERENCE (the real incident this closes: a 5-column table shifted
|
|
72
|
+
// every cell, silently misattributing "When [...]" text into the Related UC field).
|
|
73
|
+
function checkAcTableHeader(headerCells) {
|
|
74
|
+
if (!headerCells || headerCells.length < 3) {
|
|
75
|
+
return [{
|
|
76
|
+
category: 'MALFORMED_AC_TABLE_HEADER', severity: 'error',
|
|
77
|
+
message: `AC table header has fewer than 3 columns (found ${headerCells ? headerCells.length : 0})`,
|
|
78
|
+
}]
|
|
79
|
+
}
|
|
80
|
+
if (headerCells[0] !== 'ID' || headerCells[1] !== 'Criterion' || headerCells[2] !== 'Related UC') {
|
|
81
|
+
return [{
|
|
82
|
+
category: 'MALFORMED_AC_TABLE_HEADER', severity: 'error',
|
|
83
|
+
message: `AC table columns must be "ID", "Criterion", "Related UC" in order; got "${headerCells[0]}", "${headerCells[1]}", "${headerCells[2]}"`,
|
|
84
|
+
}]
|
|
85
|
+
}
|
|
86
|
+
return []
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
module.exports = {
|
|
67
90
|
checkUniqueUcIds,
|
|
68
91
|
checkUcIdFormat,
|
|
69
92
|
checkUcTableFileParity,
|
|
70
93
|
checkCrossReferencesResolve,
|
|
94
|
+
checkAcTableHeader,
|
|
71
95
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const fs = require('fs')
|
|
14
14
|
const path = require('path')
|
|
15
15
|
const graphChecks = require('./req-graph-checks')
|
|
16
|
-
const { parseUseCaseIndexTable, parseAcTable, parseBusinessRulesTable } = require('./req-assembler')
|
|
16
|
+
const { parseUseCaseIndexTable, parseAcTable, parseBusinessRulesTable, findSectionRange, parseTableHeader } = require('./req-assembler')
|
|
17
17
|
|
|
18
18
|
function main() {
|
|
19
19
|
const indexPath = process.argv[2]
|
|
@@ -31,9 +31,15 @@ function main() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
let ucTable, acTable, businessRules
|
|
34
|
+
const acHeaderErrors = []
|
|
34
35
|
try {
|
|
35
36
|
ucTable = parseUseCaseIndexTable(indexContent)
|
|
36
|
-
|
|
37
|
+
// UC-02: validate the AC table's column header BEFORE parseAcTable's positional destructure
|
|
38
|
+
// ever runs across data rows — a malformed header (e.g. a 5-column table) must never be
|
|
39
|
+
// silently misparsed into the wrong fields.
|
|
40
|
+
const { start, end, lines } = findSectionRange(indexContent, 7)
|
|
41
|
+
acHeaderErrors.push(...graphChecks.checkAcTableHeader(parseTableHeader(lines, start, end)))
|
|
42
|
+
acTable = acHeaderErrors.length ? [] : parseAcTable(indexContent)
|
|
37
43
|
businessRules = parseBusinessRulesTable(indexContent)
|
|
38
44
|
} catch (e) {
|
|
39
45
|
console.log(JSON.stringify({ valid: false, errors: [{ message: `parse failure: ${e.message}` }] }))
|
|
@@ -47,6 +53,7 @@ function main() {
|
|
|
47
53
|
: []
|
|
48
54
|
|
|
49
55
|
const errors = [
|
|
56
|
+
...acHeaderErrors,
|
|
50
57
|
...graphChecks.checkUniqueUcIds({ ucIds }),
|
|
51
58
|
...graphChecks.checkUcIdFormat({ ucIds }),
|
|
52
59
|
...graphChecks.checkUcTableFileParity({ ucIds }, existingUcFiles),
|
|
@@ -35,6 +35,10 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
35
35
|
const specComponentId = 'gaia.agent.planner.tech-spec-component'
|
|
36
36
|
const valId = 'gaia.agent.planner.validate-feature-docs'
|
|
37
37
|
const output = (suffix, inputs = [featurePath]) => ({ path: file(suffix), inputs })
|
|
38
|
+
// FTR-029/UC-04: bounded immediate retry, fixed constants (not externally configurable) for the
|
|
39
|
+
// per-UC and per-component generation loops' own fail-fast validity check.
|
|
40
|
+
const USE_CASE_RETRY_ATTEMPTS = 2
|
|
41
|
+
const COMPONENT_RETRY_ATTEMPTS = 2
|
|
38
42
|
|
|
39
43
|
// Templates live under the toolkit's own installed scripts/templates/ — outside this project
|
|
40
44
|
// for a globally-installed toolkit, which a non-interactive `--print` worker invocation has no
|
|
@@ -196,8 +200,15 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
196
200
|
// an existing use-case file would have been written against the OLD skeleton's row
|
|
197
201
|
// (title/actor/priority may have changed) and cannot be trusted as-is.
|
|
198
202
|
if (!regenSkeleton && c.exists(ucPath) && isValidUseCaseFile(c.read(ucPath), row.id)) continue // resume: skip, no dispatch
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
// FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
|
|
204
|
+
// use case — same reasoning as the per-component loop above.
|
|
205
|
+
let detail, valid = false
|
|
206
|
+
for (let attempt = 1; attempt <= USE_CASE_RETRY_ATTEMPTS && !valid; attempt++) {
|
|
207
|
+
detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
|
|
208
|
+
{ label: `requirements-use-case:${row.id}:${attempt}` })
|
|
209
|
+
valid = isValidUseCaseFile(detail, row.id)
|
|
210
|
+
}
|
|
211
|
+
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`)
|
|
201
212
|
c.writeOutput(ucPath, detail.trimEnd() + ucCrossReferences(c.read(reqIndexPath), row.id))
|
|
202
213
|
}
|
|
203
214
|
// No assembly/write step here — generation only ever produces req/index.md + its per-UC
|
|
@@ -281,6 +292,67 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
281
292
|
return changeIdx !== -1 && testsIdx !== -1
|
|
282
293
|
}
|
|
283
294
|
|
|
295
|
+
// FTR-028/UC-02: purely deterministic, zero-LLM-cost AC<->component traceability check. Reuses
|
|
296
|
+
// the two table parsers already duplicated in this file — no new parsing contract invented.
|
|
297
|
+
// Both directions produce a "Tech-Spec: " finding (always closed by a Tech-Spec revision —
|
|
298
|
+
// either add missing coverage, or fix a bad AC-ID reference).
|
|
299
|
+
function computeAcComponentTraceabilityGaps(reqIndexContent, specIndexContent) {
|
|
300
|
+
const acIds = new Set(parseAcTableRelatedUc(reqIndexContent).map(r => r.id))
|
|
301
|
+
const components = parseComponentsTable(specIndexContent)
|
|
302
|
+
const addressedAcIds = new Set()
|
|
303
|
+
const findings = []
|
|
304
|
+
for (const comp of components) {
|
|
305
|
+
const refs = (comp.addresses || '').split(',').map(s => s.trim()).filter(s => s && s !== '—')
|
|
306
|
+
for (const ref of refs) {
|
|
307
|
+
addressedAcIds.add(ref)
|
|
308
|
+
if (!acIds.has(ref)) findings.push(`Tech-Spec: component ${comp.filePath} references nonexistent ${ref}`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const id of acIds) {
|
|
312
|
+
if (!addressedAcIds.has(id)) findings.push(`Tech-Spec: ${id} is not addressed by any Tech-Spec component`)
|
|
313
|
+
}
|
|
314
|
+
return findings
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// FTR-028/UC-03: validates each needsComponentDetail entry's fileSlug against the real
|
|
318
|
+
// component table (a hallucinated/mistyped reference is dropped and reported, never
|
|
319
|
+
// dispatched), deduplicates by fileSlug (two claims against the same file collapse into one
|
|
320
|
+
// batch slot), and splits the remaining unique components into groups of at most
|
|
321
|
+
// DRILL_DOWN_BATCH_SIZE — never one call per claim, never one call covering every flagged
|
|
322
|
+
// component regardless of count.
|
|
323
|
+
const DRILL_DOWN_BATCH_SIZE = 8 // fixed constant, not externally configurable
|
|
324
|
+
function prepareDrillDownBatches(needsComponentDetail, specIndexContent) {
|
|
325
|
+
const validSlugs = new Set(parseComponentsTable(specIndexContent).map(c => c.fileSlug))
|
|
326
|
+
const extraFindings = []
|
|
327
|
+
const claimsByFileSlug = new Map()
|
|
328
|
+
for (const entry of needsComponentDetail || []) {
|
|
329
|
+
if (!validSlugs.has(entry.fileSlug)) {
|
|
330
|
+
extraFindings.push(`Tech-Spec: component-detail check referenced a non-existent file "${entry.fileSlug}" for claim "${entry.claim}"`)
|
|
331
|
+
continue
|
|
332
|
+
}
|
|
333
|
+
if (!claimsByFileSlug.has(entry.fileSlug)) claimsByFileSlug.set(entry.fileSlug, [])
|
|
334
|
+
claimsByFileSlug.get(entry.fileSlug).push(entry.claim)
|
|
335
|
+
}
|
|
336
|
+
const fileSlugs = [...claimsByFileSlug.keys()]
|
|
337
|
+
const batches = []
|
|
338
|
+
for (let i = 0; i < fileSlugs.length; i += DRILL_DOWN_BATCH_SIZE) {
|
|
339
|
+
batches.push(fileSlugs.slice(i, i + DRILL_DOWN_BATCH_SIZE)
|
|
340
|
+
.map(slug => ({ fileSlug: slug, claims: claimsByFileSlug.get(slug) })))
|
|
341
|
+
}
|
|
342
|
+
return { batches, extraFindings }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// specDir is already absolute (derived from c.feature()'s realpath-resolved featureDir), so
|
|
346
|
+
// this needs no extra path.resolve — same reasoning that already lets featurePath be handed to
|
|
347
|
+
// agents directly elsewhere in this file.
|
|
348
|
+
function buildDrillDownPrompt(batch) {
|
|
349
|
+
return batch.map(({ fileSlug, claims }) =>
|
|
350
|
+
claims.map(claim => `Claim: ${claim}\nComponent file: ${specDir}/components/${fileSlug}`).join('\n\n')
|
|
351
|
+
).join('\n\n')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const drillDownId = 'gaia.agent.planner.validate-tech-spec-component-detail'
|
|
355
|
+
|
|
284
356
|
function buildComponentPrompt(row, components, featurePathArg, componentTemplate) {
|
|
285
357
|
const compactIndex = components.map(c => `${c.filePath} (${c.domain})`).join('\n')
|
|
286
358
|
const base = `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
|
|
@@ -378,8 +450,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
378
450
|
for (const comp of components) {
|
|
379
451
|
const compPath = `${specDir}/components/${comp.fileSlug}`
|
|
380
452
|
if (!regenSkeleton && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
|
|
381
|
-
|
|
382
|
-
|
|
453
|
+
// FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
|
|
454
|
+
// component — a bad result otherwise silently continued through every remaining component,
|
|
455
|
+
// only discovered (expensively) at final assembly or a later invocation's resume check.
|
|
456
|
+
let detail, valid = false
|
|
457
|
+
for (let attempt = 1; attempt <= COMPONENT_RETRY_ATTEMPTS && !valid; attempt++) {
|
|
458
|
+
detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
|
|
459
|
+
{ label: `tech-spec-component:${comp.fileSlug}:${attempt}` })
|
|
460
|
+
valid = isValidComponentDetail(detail)
|
|
461
|
+
}
|
|
462
|
+
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`)
|
|
383
463
|
c.writeOutput(compPath, detail)
|
|
384
464
|
}
|
|
385
465
|
// No assembly/write step here — generation only ever produces spec/index.md + its
|
|
@@ -445,26 +525,86 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
445
525
|
// finding mentioning both document names, or neither, would mis-route or
|
|
446
526
|
// silently skip). The validator agent itself decides which document(s) its
|
|
447
527
|
// own findings implicate.
|
|
528
|
+
// FTR-028/UC-01: Tech-Spec text is the raw skeleton (c.read(specIndexPath)), never
|
|
529
|
+
// assembledTechSpec's fully-spliced output — bounds this prompt's size to component COUNT,
|
|
530
|
+
// not per-component prose length. Requirements stays assembledRequirements (full UC
|
|
531
|
+
// detail), unchanged — no real incident has shown UC count x flow length to pose the same
|
|
532
|
+
// risk.
|
|
448
533
|
const result = await c.worker(valId,
|
|
449
|
-
`${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${
|
|
450
|
-
'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.',
|
|
451
|
-
{ 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: ['
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
534
|
+
`${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${c.read(specIndexPath)}\n\n` +
|
|
535
|
+
'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.',
|
|
536
|
+
{ 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', 'report'] } })
|
|
537
|
+
// FTR-029: needsRequirementsFix/needsTechSpecFix stay declared in `properties` (still
|
|
538
|
+
// type-checked when present) but are no longer hard-required — confirmed against three
|
|
539
|
+
// real evidence files that the model reasonably omits both when there is nothing to fix;
|
|
540
|
+
// the host's own merge logic already treats an absent value as falsy
|
|
541
|
+
// (mergedNeedsRequirementsFix/mergedNeedsTechSpecFix below), so no further change needed.
|
|
542
|
+
// needsComponentDetail is declared in `properties` but deliberately NOT in the schema's
|
|
543
|
+
// top-level `required` array: every pre-existing mocked test response for this agent
|
|
544
|
+
// (predating this feature) omits the field entirely, and making it hard-required would
|
|
545
|
+
// break every one of them for no real gain — prepareDrillDownBatches already treats an
|
|
546
|
+
// absent/undefined value as "no entries" via `needsComponentDetail || []`.
|
|
547
|
+
|
|
548
|
+
// Raw self-consistency guard on the MODEL'S OWN response, before any host-side merge: a
|
|
549
|
+
// pending needsComponentDetail entry is itself a form of "not yet resolved", same as a
|
|
550
|
+
// non-empty findings array — valid:true requires BOTH to be empty, valid:false requires at
|
|
551
|
+
// least one to be non-empty. This is intentionally separate from mergedValid below (which
|
|
552
|
+
// is NOT required to track this raw self-report: a valid:false caused only by
|
|
553
|
+
// needsComponentDetail entries that the drill-down subsequently confirms must still resolve
|
|
554
|
+
// to a clean cycle, not a false "contradiction").
|
|
555
|
+
const rawHasOpenItems = result.findings.length > 0 || (result.needsComponentDetail || []).length > 0
|
|
556
|
+
if (result.valid === rawHasOpenItems) {
|
|
557
|
+
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)`)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// FTR-028/UC-02 + UC-03: every finding source for this cycle, computed/dispatched AFTER the
|
|
561
|
+
// main call returns (re-reading the index files fresh, since a prior cycle may have
|
|
562
|
+
// regenerated them). Zero added LLM cost when both come back empty (the common case).
|
|
563
|
+
const reqIndexContent = c.read(reqIndexPath)
|
|
564
|
+
const specIndexContent = c.read(specIndexPath)
|
|
565
|
+
const structuralFindings = computeAcComponentTraceabilityGaps(reqIndexContent, specIndexContent)
|
|
566
|
+
const { batches, extraFindings } = prepareDrillDownBatches(result.needsComponentDetail, specIndexContent)
|
|
567
|
+
|
|
568
|
+
const drillDownFindings = []
|
|
569
|
+
for (let b = 0; b < batches.length; b++) {
|
|
570
|
+
const batchResult = await c.worker(drillDownId, buildDrillDownPrompt(batches[b]),
|
|
571
|
+
{ label: `validation:${cycle}:component-detail:${b + 1}`,
|
|
572
|
+
schema: { type: 'object', properties: { findings: { type: 'array', items: { type: 'string' } } }, required: ['findings'] } })
|
|
573
|
+
drillDownFindings.push(...batchResult.findings)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// BR-04: merge everything BEFORE the contradiction guard. A model returning valid:true
|
|
577
|
+
// while the host's own independent checks found a real gap is not a self-contradiction (it
|
|
578
|
+
// was never shown that gap) — it is simply now-invalid, same as any other unresolved
|
|
579
|
+
// finding.
|
|
580
|
+
const newFindings = [...structuralFindings, ...extraFindings, ...drillDownFindings]
|
|
581
|
+
const mergedFindings = [...result.findings, ...newFindings]
|
|
582
|
+
// Deliberately NOT `result.valid && ...`: result.valid may be false SOLELY because of
|
|
583
|
+
// needsComponentDetail entries that the drill-down above has now fully confirmed (zero
|
|
584
|
+
// drillDownFindings) — that must resolve to clean, not stay stuck on the model's own
|
|
585
|
+
// now-stale self-report. The only authoritative signal for the final verdict is whether any
|
|
586
|
+
// finding remains, from any source, after every check this cycle has run.
|
|
587
|
+
const mergedValid = mergedFindings.length === 0
|
|
588
|
+
const mergedNeedsTechSpecFix = result.needsTechSpecFix || newFindings.some(f => f.startsWith('Tech-Spec:'))
|
|
589
|
+
const mergedNeedsRequirementsFix = result.needsRequirementsFix || newFindings.some(f => f.startsWith('Requirements:'))
|
|
590
|
+
|
|
591
|
+
// c.worker() already wrote result.report verbatim via its own outputs[0] contract; only
|
|
592
|
+
// overwrite it when this cycle actually added findings the LLM's own report text doesn't
|
|
593
|
+
// mention — the common clean case (newFindings.length === 0) never re-writes the file.
|
|
594
|
+
if (newFindings.length) {
|
|
595
|
+
c.write(file('Validation-Report'), result.report +
|
|
596
|
+
'\n\n## Additional findings (deterministic + component-detail drill-down)\n' +
|
|
597
|
+
newFindings.map(f => `- ${f}`).join('\n') + '\n')
|
|
458
598
|
}
|
|
459
|
-
if (
|
|
599
|
+
if (mergedValid) {
|
|
460
600
|
if (!fresh(file('Validation-Report'), [featurePath, reqIndexPath, specIndexPath])) throw new Error('Validation agent did not produce its report')
|
|
461
601
|
validationSummary = `0 gaps (clean on cycle ${cycle})`
|
|
462
602
|
break
|
|
463
603
|
}
|
|
464
604
|
if (cycle === 3) throw new Error('Validation gaps remain after 3 cycles')
|
|
465
|
-
if (!
|
|
466
|
-
if (
|
|
467
|
-
if (
|
|
605
|
+
if (!mergedNeedsRequirementsFix && !mergedNeedsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
|
|
606
|
+
if (mergedNeedsRequirementsFix) { await generateRequirements(true); assembledRequirements = assembleRequirements() }
|
|
607
|
+
if (mergedNeedsTechSpecFix) { await generateTechSpec(true); assembledTechSpec = assembleTechSpec() }
|
|
468
608
|
}
|
|
469
609
|
}
|
|
470
610
|
// Binds Gate 1's eventual approval to the EXACT Requirements/Tech-Spec bytes
|