@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 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
 
@@ -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' },
@@ -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, but ONLY for a recognizable transient
173
- // infrastructure failure — a CLI/SDK-level "API Error: ..." (stream idle
174
- // timeout, overload, etc.), never a permission denial, schema violation,
175
- // or any other deterministic content failure, which would just fail the
176
- // same way again. Confirmed safe to retry: every agent dispatched through
177
- // this path is read-only by design (no Write tool — see this function's
178
- // own comment on why outputs are host-written, not agent-written; any
179
- // Bash access granted is explicitly scoped to read-only assessment), so
180
- // there is no partial side effect from a dead stream to clean up before
181
- // retrying. Token usage from EVERY attempt is accumulated — a failed
182
- // attempt still burns real output tokens before the stream dies — and
183
- // each attempt's outcome is kept in evidence for diagnosing a recurring
184
- // pattern like this one, rather than only ever seeing the last attempt.
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
- if (!_isTransientWorkerApiError(result) || attempt === WORKER_MAX_ATTEMPTS) break;
205
- await _sleep(WORKER_RETRY_BACKOFF_MS[attempt - 1]);
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 extractDelimitedStructuredOutput(text, schema) {
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
- for (const raw of candidates) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -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 — 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".
@@ -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
- acTable = parseAcTable(indexContent)
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
- const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
200
- { label: `requirements-use-case:${row.id}` })
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
- const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
382
- { label: `tech-spec-component:${comp.fileSlug}` })
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${assembledTechSpec}\n\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: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
452
- // A model returning valid:true alongside non-empty findings (or valid:false
453
- // with none) contradicts its own schema's intent — never silently treated as
454
- // "not clean yet" and spent on another cycle without surfacing the
455
- // inconsistency; fail closed instead of masking a confused response.
456
- if (result.valid === (result.findings.length > 0)) {
457
- throw new Error(`Validation agent returned a contradictory result on cycle ${cycle}: valid=${result.valid} with ${result.findings.length} finding(s)`)
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 (result.valid) {
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 (!result.needsRequirementsFix && !result.needsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
466
- if (result.needsRequirementsFix) { await generateRequirements(true); assembledRequirements = assembleRequirements() }
467
- if (result.needsTechSpecFix) { await generateTechSpec(true); assembledTechSpec = assembleTechSpec() }
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