@dtlabs/ai-toolkit 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,20 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.21.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.1...v0.21.2) (2026-10-21)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **pm-phase2:** read gaia-user-story-template.md via print-asset, not a local-install-relative path ([4913bcf](https://github.com/dtlab-tech/ai-toolkit/commit/4913bcfb71bd37b56d373d340abf9cee17478154))
8
+ * read gaia-user-story-template.md via print-asset (global-install compatibility) ([29486d9](https://github.com/dtlab-tech/ai-toolkit/commit/29486d9eeec4ae6ea2abfe02ffba4753addfc9fb))
9
+
10
+ ## [0.21.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.0...v0.21.1) (2026-10-20)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * harden pipeline resilience across four real-world failures (FTR-029) ([ef57862](https://github.com/dtlab-tech/ai-toolkit/commit/ef578623541c084d17aeb6b0f53dba076e5a449e))
16
+ * **workflow-control, pm-phase1:** harden pipeline resilience across four real-world failures (FTR-029) ([34a9ec9](https://github.com/dtlab-tech/ai-toolkit/commit/34a9ec9db645d1475e41e0c3875c8431a1ae0fda))
17
+
2
18
  ## [0.21.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.20.0...v0.21.0) (2026-10-20)
3
19
 
4
20
 
@@ -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.21.0",
3
+ "version": "0.21.2",
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
 
@@ -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
@@ -439,8 +450,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
439
450
  for (const comp of components) {
440
451
  const compPath = `${specDir}/components/${comp.fileSlug}`
441
452
  if (!regenSkeleton && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
442
- const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
443
- { 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`)
444
463
  c.writeOutput(compPath, detail)
445
464
  }
446
465
  // No assembly/write step here — generation only ever produces spec/index.md + its
@@ -514,7 +533,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
514
533
  const result = await c.worker(valId,
515
534
  `${featurePath}\n\nRequirements:\n${assembledRequirements}\n\nTech-Spec:\n${c.read(specIndexPath)}\n\n` +
516
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.',
517
- { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, reqIndexPath, specIndexPath])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' }, needsComponentDetail: { type: 'array', items: { type: 'object', properties: { claim: { type: 'string' }, fileSlug: { type: 'string' } }, required: ['claim', 'fileSlug'] } } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
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.
518
542
  // needsComponentDetail is declared in `properties` but deliberately NOT in the schema's
519
543
  // top-level `required` array: every pre-existing mocked test response for this agent
520
544
  // (predating this feature) omits the field entirely, and making it hard-required would
@@ -130,7 +130,13 @@ function renderPhaseDetailBlock(phase, taskTitles, dependsOnPhases) {
130
130
  ? taskTitles.map(t => `- \`${t.id}\` — ${t.title}`).join('\n')
131
131
  : '_No tasks._'
132
132
  const dependsOn = dependsOnPhases.length ? dependsOnPhases.join(', ') : 'none'
133
- let text = _stripTemplateComments(c.read('.claude/scripts/templates/gaia-user-story-template.md'))
133
+ // The template lives under the toolkit's own installed scripts/templates/ — outside the project
134
+ // for a globally-installed toolkit, which a plain c.read() has no way to resolve (it only reads
135
+ // project-relative paths). Read via print-asset instead, same as gaia-task-template.md below —
136
+ // this was previously a bare c.read() of a local-install-relative path, which only worked when
137
+ // the toolkit happened to be installed locally into this project's own .claude/ folder (a real
138
+ // incident: a project with only a global install failed with ENOENT on this exact line).
139
+ let text = _stripTemplateComments(c.command(['print-asset', 'scripts/templates/gaia-user-story-template.md', '--project', c.root]).stdout)
134
140
  text = text.replace(/\{PHASE_ID\}/g, phase.id)
135
141
  text = _fillFieldBlock(text, {
136
142
  'Phase ID': phase.id,