@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 +16 -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/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 +29 -5
- package/src/claude/workflows/pm-phase2.js +7 -1
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
|
|
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
|
|
|
@@ -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
|
|
@@ -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
|
-
|
|
443
|
-
|
|
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', '
|
|
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
|
-
|
|
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,
|