@dtlabs/ai-toolkit 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.20.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.2...v0.20.0) (2026-10-19)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* **pm-phase1, pm-phase2:** resume-safe per-item generation (FTR-027) ([41e021f](https://github.com/dtlab-tech/ai-toolkit/commit/41e021f65aad694c18d51b6a7876e4a83373a4d9))
|
|
8
|
+
* resume-safe per-item generation + component fileSlug normalization (FTR-027) ([e7e6b89](https://github.com/dtlab-tech/ai-toolkit/commit/e7e6b89596f267c4954c9be92bc2c4b3de8b9194))
|
|
9
|
+
|
|
10
|
+
## [0.19.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.1...v0.19.2) (2026-10-19)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **workflow-control, tech-spec-assembler:** unwrap whole response fences in Markdown documents to prevent content loss ([fe88582](https://github.com/dtlab-tech/ai-toolkit/commit/fe885821bb7b813237a1d53ed827c70e09b6c9d0))
|
|
16
|
+
* **workflow-control, tech-spec-assembler:** unwrap whole response fences in Markdown documents to prevent content loss ([358e484](https://github.com/dtlab-tech/ai-toolkit/commit/358e4847fe74c314c3c1158dbeda08669530c1f8))
|
|
17
|
+
|
|
2
18
|
## [0.19.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.0...v0.19.1) (2026-10-19)
|
|
3
19
|
|
|
4
20
|
|
package/lib/workflow-control.js
CHANGED
|
@@ -238,6 +238,13 @@ function createControl(options) {
|
|
|
238
238
|
const candidate = extractDelimitedStructuredOutput(result.result.result, opts.schema);
|
|
239
239
|
if (candidate != null) { payload = candidate; usedTextFallback = true; }
|
|
240
240
|
}
|
|
241
|
+
// A non-schema worker's payload IS the document — unwrap a whole-response fence/preamble
|
|
242
|
+
// before anything downstream (the caller's own write, or this function's own outputTarget
|
|
243
|
+
// write below) ever sees it. See extractMarkdownDocument's own comment for the full story.
|
|
244
|
+
if (!opts.schema && typeof payload === 'string') {
|
|
245
|
+
const cleaned = extractMarkdownDocument(payload);
|
|
246
|
+
if (cleaned !== payload) { payload = cleaned; usedTextFallback = true; }
|
|
247
|
+
}
|
|
241
248
|
if (payload == null || (typeof payload === 'string' && !payload.trim())) {
|
|
242
249
|
if (opts.schema) {
|
|
243
250
|
const fields = (opts.schema.required || []).join(', ') || '(see requested schema)';
|
|
@@ -476,6 +483,65 @@ function extractJsonDocument(text) {
|
|
|
476
483
|
return text;
|
|
477
484
|
}
|
|
478
485
|
|
|
486
|
+
// Same problem extractJsonDocument fixes, for a Markdown document deliverable (a component/
|
|
487
|
+
// use-case/task detail file, or a Requirements/Tech-Spec skeleton) instead of a JSON one: a
|
|
488
|
+
// worker's entire response becomes the file verbatim (these agents have no Write tool, see
|
|
489
|
+
// worker()'s own comment on why — the host writes whatever text they return), and a model that
|
|
490
|
+
// wraps its deliverable in conversational prose and/or an outer code fence silently corrupts the
|
|
491
|
+
// saved file. Found empirically in a real spec/components/*.md file: the agent's own real
|
|
492
|
+
// "## Change"/"## Tests" headings ended up INSIDE an outer ```markdown fence and were (correctly)
|
|
493
|
+
// treated as fenced, non-heading content by tech-spec-assembler.js's own fence-aware parser
|
|
494
|
+
// (FTR-025) — the file wrote to disk with its real content unreachable, surfacing only much later
|
|
495
|
+
// as an opaque "component file is missing its ## Change section" error during final assembly, with
|
|
496
|
+
// no filename attached to say which one (see loadTechSpec's own fix for that half of the bug).
|
|
497
|
+
//
|
|
498
|
+
// Same "try as-is first, re-validate the candidate, never a blind substring guess, never throws"
|
|
499
|
+
// discipline as extractJsonDocument: "valid" here means "has at least one unfenced Markdown
|
|
500
|
+
// heading" — the same fence-length-aware scan tech-spec-assembler.js's own
|
|
501
|
+
// _nonFencedHeadingIndexes uses, reimplemented here as a presence check rather than a line-index
|
|
502
|
+
// list, for the identical "sandboxed runtime has no require()"-shaped reason these two files are
|
|
503
|
+
// already siblings, not shared, elsewhere in this codebase. A document that legitimately ends in
|
|
504
|
+
// its own small fenced code example (a real Tests section's last line) is never mistaken for a
|
|
505
|
+
// whole-response wrap: its real headings already appear BEFORE that trailing fence, unfenced, so
|
|
506
|
+
// the as-is candidate already satisfies this check and no unwrapping is even attempted.
|
|
507
|
+
function _hasUnfencedHeading(text) {
|
|
508
|
+
let fenceLen = 0;
|
|
509
|
+
for (const line of text.split('\n')) {
|
|
510
|
+
const m = line.match(/^(`{3,})/);
|
|
511
|
+
if (m) { const n = m[1].length; fenceLen = fenceLen === 0 ? n : (n >= fenceLen ? 0 : fenceLen); continue; }
|
|
512
|
+
if (fenceLen === 0 && /^#{1,6}\s/.test(line)) return true;
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
// Line-range based, not regex-based: a regex scan for the "first ``` ... last ```" span would
|
|
517
|
+
// wrongly stop at the first NESTED closing fence instead of the real outer one (exactly the shape
|
|
518
|
+
// a Markdown document's own "## Data Model" section legitimately needs — a fenced JSON example
|
|
519
|
+
// nested inside the model's outer ```markdown wrap). Only the LAST non-blank line being a bare
|
|
520
|
+
// fence-close, matched back to the first fence-open of the SAME backtick-run length, correctly
|
|
521
|
+
// identifies the true outer boundary regardless of what's nested between them.
|
|
522
|
+
function _unwrapWholeResponseFence(text) {
|
|
523
|
+
const lines = text.split('\n');
|
|
524
|
+
let end = lines.length;
|
|
525
|
+
while (end > 0 && lines[end - 1].trim() === '') end--;
|
|
526
|
+
if (end === 0) return null;
|
|
527
|
+
const closeMatch = lines[end - 1].match(/^(`{3,})\s*$/);
|
|
528
|
+
if (!closeMatch) return null;
|
|
529
|
+
const fenceLen = closeMatch[1].length;
|
|
530
|
+
let start = -1;
|
|
531
|
+
for (let i = 0; i < end - 1; i++) {
|
|
532
|
+
const openMatch = lines[i].match(/^(`{3,})\S*\s*$/);
|
|
533
|
+
if (openMatch && openMatch[1].length === fenceLen) { start = i; break; }
|
|
534
|
+
}
|
|
535
|
+
if (start === -1) return null;
|
|
536
|
+
return lines.slice(start + 1, end - 1).join('\n').trim() + '\n';
|
|
537
|
+
}
|
|
538
|
+
function extractMarkdownDocument(text) {
|
|
539
|
+
if (_hasUnfencedHeading(text)) return text;
|
|
540
|
+
const unwrapped = _unwrapWholeResponseFence(text);
|
|
541
|
+
if (unwrapped != null && _hasUnfencedHeading(unwrapped)) return unwrapped;
|
|
542
|
+
return text;
|
|
543
|
+
}
|
|
544
|
+
|
|
479
545
|
function validateSchema(value, schema) {
|
|
480
546
|
if (schema.type === 'object') {
|
|
481
547
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid structured object');
|
package/package.json
CHANGED
|
@@ -148,7 +148,18 @@ function loadTechSpec(inputPath) {
|
|
|
148
148
|
for (const c of components) {
|
|
149
149
|
const compPath = path.join(inputPath, 'components', c.fileSlug)
|
|
150
150
|
if (!fs.existsSync(compPath)) throw new AssemblyError(`component "${c.filePath}" has no detail file at ${compPath}`)
|
|
151
|
-
|
|
151
|
+
try {
|
|
152
|
+
detailByPath[c.filePath] = parseComponentDetail(fs.readFileSync(compPath, 'utf8'))
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// parseComponentDetail's own error names only the symptom ("missing its ## Change
|
|
155
|
+
// section"), never which file — a real diagnosability bug, found when exactly this error
|
|
156
|
+
// surfaced during final assembly with nothing identifying which of ~60 component files was
|
|
157
|
+
// actually malformed (the real culprit here was a whole-response-wrapped-in-a-fence worker
|
|
158
|
+
// reply — see lib/workflow-control.js's extractMarkdownDocument for the fix to that other
|
|
159
|
+
// half of this same incident). Re-thrown with the file identified, never silently swallowed.
|
|
160
|
+
if (err instanceof AssemblyError) throw new AssemblyError(`${err.message} (component "${c.filePath}" at ${compPath})`)
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
152
163
|
}
|
|
153
164
|
|
|
154
165
|
let doc = rewriteSection9(indexContent, components)
|
|
@@ -90,6 +90,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
90
90
|
})
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// FTR-027: resume-by-existence must not trust mere presence — a use-case file that exists but
|
|
94
|
+
// is garbled/off-topic (e.g. echoes a different UC) should be regenerated, not skipped forever.
|
|
95
|
+
// Only the ID fragment is checked, never the free-text title after the colon: the title is
|
|
96
|
+
// prose the agent may legitimately phrase differently across a retry, and comparing it would
|
|
97
|
+
// reject a valid regeneration for no real reason.
|
|
98
|
+
function isValidUseCaseFile(content, ucId) {
|
|
99
|
+
const escaped = ucId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
100
|
+
return new RegExp(`^###\\s+${escaped}:\\s`).test(content.trimStart())
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
// Business Rules' "Applies to" / Acceptance Criteria's "Related UC" columns are the same
|
|
94
104
|
// cross-cutting-reference shape: a comma-separated UC-ID list, or the literal "All UCs". Both
|
|
95
105
|
// are 100% mechanically derivable from req/index.md's own tables — no LLM judgment needed,
|
|
@@ -185,7 +195,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
185
195
|
// If the skeleton itself was just (re)generated, every use case must regenerate too —
|
|
186
196
|
// an existing use-case file would have been written against the OLD skeleton's row
|
|
187
197
|
// (title/actor/priority may have changed) and cannot be trusted as-is.
|
|
188
|
-
if (!regenSkeleton && c.exists(ucPath)) continue // resume: skip, no dispatch
|
|
198
|
+
if (!regenSkeleton && c.exists(ucPath) && isValidUseCaseFile(c.read(ucPath), row.id)) continue // resume: skip, no dispatch
|
|
189
199
|
const detail = await c.worker(reqUcId, buildUcPrompt(row, ucTable, featurePath, ucTemplate),
|
|
190
200
|
{ label: `requirements-use-case:${row.id}` })
|
|
191
201
|
c.writeOutput(ucPath, detail.trimEnd() + ucCrossReferences(c.read(reqIndexPath), row.id))
|
|
@@ -241,6 +251,36 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
241
251
|
return entries
|
|
242
252
|
}
|
|
243
253
|
|
|
254
|
+
// FTR-027: duplicates tech-spec-assembler.js's parseComponentDetail's own fence-aware
|
|
255
|
+
// "## Change"/"## Tests" heading scan (see that file for the full "why length-aware, not a
|
|
256
|
+
// boolean toggle" rationale) — returns a boolean instead of throwing/returning parsed sections,
|
|
257
|
+
// since every call site here only needs the yes/no "is this a structurally valid component
|
|
258
|
+
// file" signal, for resume-by-existence: a component file that exists but fails this check
|
|
259
|
+
// (e.g. a whole-response fence wrap, a truncated reply) must be regenerated, not skipped
|
|
260
|
+
// forever — found via a real incident where exactly this silently blocked every retry.
|
|
261
|
+
function _nonFencedHeadingIndexesLocal(lines, predicate) {
|
|
262
|
+
let fenceLen = 0
|
|
263
|
+
const indexes = []
|
|
264
|
+
for (let i = 0; i < lines.length; i++) {
|
|
265
|
+
const m = lines[i].match(/^(`{3,})/)
|
|
266
|
+
if (m) {
|
|
267
|
+
const runLen = m[1].length
|
|
268
|
+
if (fenceLen === 0) fenceLen = runLen
|
|
269
|
+
else if (runLen >= fenceLen) fenceLen = 0
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
if (fenceLen !== 0) continue
|
|
273
|
+
if (predicate(lines[i])) indexes.push(i)
|
|
274
|
+
}
|
|
275
|
+
return indexes
|
|
276
|
+
}
|
|
277
|
+
function isValidComponentDetail(content) {
|
|
278
|
+
const lines = content.split('\n')
|
|
279
|
+
const changeIdx = _nonFencedHeadingIndexesLocal(lines, l => l.trim() === '## Change')[0] ?? -1
|
|
280
|
+
const testsIdx = _nonFencedHeadingIndexesLocal(lines, l => l.trim() === '## Tests')[0] ?? -1
|
|
281
|
+
return changeIdx !== -1 && testsIdx !== -1
|
|
282
|
+
}
|
|
283
|
+
|
|
244
284
|
function buildComponentPrompt(row, components, featurePathArg, componentTemplate) {
|
|
245
285
|
const compactIndex = components.map(c => `${c.filePath} (${c.domain})`).join('\n')
|
|
246
286
|
const base = `${featurePathArg}\n\nGenerate the full technical design for this component:\n` +
|
|
@@ -254,6 +294,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
254
294
|
// FTR-020). Rewrites any colliding fileSlug's Spec-column link in place, right-to-left
|
|
255
295
|
// prepending path segments until unique.
|
|
256
296
|
//
|
|
297
|
+
// Also enforces the ".md" suffix itself, never trusting the model to have included it. The
|
|
298
|
+
// component template's own contract calls this Spec link "provisional, not final" specifically
|
|
299
|
+
// so the host can rewrite it — originally only exercised for collisions, but a real production
|
|
300
|
+
// run showed the model omits ".md" entirely a meaningful fraction of the time (confirmed: 20 of
|
|
301
|
+
// ~61 components in one real Tech-Spec skeleton), writing e.g. `components/Settings.tsx`
|
|
302
|
+
// instead of `components/Settings.tsx.md` — the host then faithfully wrote that component's
|
|
303
|
+
// Markdown CONTENT into a file literally named `Settings.tsx`. Normalized here before the
|
|
304
|
+
// collision check runs, so two rows that only differ by their original extension (already
|
|
305
|
+
// present in the Path column) still correctly collide-and-disambiguate on the normalized slug.
|
|
306
|
+
//
|
|
257
307
|
// Operates per-LINE, not via a global string .replace() over the whole document — a real bug,
|
|
258
308
|
// found by testing two colliding entries directly: when both rows share byte-identical Spec
|
|
259
309
|
// text (the whole point of a collision), a global .replace() always rewrites the FIRST
|
|
@@ -270,6 +320,12 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
270
320
|
const m = cells[4].match(/\(components\/([^)]+)\)/)
|
|
271
321
|
if (!m) continue // header/separator row, or a row with no valid Spec link yet
|
|
272
322
|
let slug = m[1]
|
|
323
|
+
if (!slug.endsWith('.md')) {
|
|
324
|
+
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
325
|
+
const normalized = `${slug}.md`
|
|
326
|
+
lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${normalized}`)
|
|
327
|
+
slug = normalized
|
|
328
|
+
}
|
|
273
329
|
if (seenSlugs.has(slug)) {
|
|
274
330
|
const segments = filePath.split('/')
|
|
275
331
|
let depth = 1
|
|
@@ -279,7 +335,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
279
335
|
candidate = segments.slice(-depth).join('-') + '.md'
|
|
280
336
|
}
|
|
281
337
|
const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
282
|
-
lines[i] =
|
|
338
|
+
lines[i] = lines[i].replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
|
|
283
339
|
slug = candidate
|
|
284
340
|
}
|
|
285
341
|
seenSlugs.add(slug)
|
|
@@ -321,7 +377,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
321
377
|
const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
|
|
322
378
|
for (const comp of components) {
|
|
323
379
|
const compPath = `${specDir}/components/${comp.fileSlug}`
|
|
324
|
-
if (!regenSkeleton && c.exists(compPath)) continue // resume: skip, no dispatch
|
|
380
|
+
if (!regenSkeleton && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
|
|
325
381
|
const detail = await c.worker(specComponentId, buildComponentPrompt(comp, components, featurePath, componentTemplate),
|
|
326
382
|
{ label: `tech-spec-component:${comp.fileSlug}` })
|
|
327
383
|
c.writeOutput(compPath, detail)
|
|
@@ -352,8 +408,17 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
352
408
|
// for free by gating on the assembled document's own freshness (which could never look
|
|
353
409
|
// "complete" while a referenced item was missing) — removing that gate (FTR-025) removed this
|
|
354
410
|
// side effect along with it, so it needs restating explicitly here.
|
|
355
|
-
|
|
356
|
-
|
|
411
|
+
// FTR-027: "missing" now also means "present but invalid" — a structurally broken-but-existing
|
|
412
|
+
// file must trigger re-entry into generation the same way an absent one already does, otherwise
|
|
413
|
+
// the outer gate alone could never notice it (only the per-item loop would, one phase later).
|
|
414
|
+
const anyUcFileMissing = indexContent => parseUseCaseIndexTable(indexContent).some(row => {
|
|
415
|
+
const ucPath = `${reqDir}/${row.file}`
|
|
416
|
+
return !c.exists(ucPath) || !isValidUseCaseFile(c.read(ucPath), row.id)
|
|
417
|
+
})
|
|
418
|
+
const anyComponentFileMissing = indexContent => parseComponentsTable(indexContent).some(comp => {
|
|
419
|
+
const compPath = `${specDir}/components/${comp.fileSlug}`
|
|
420
|
+
return !c.exists(compPath) || !isValidComponentDetail(c.read(compPath))
|
|
421
|
+
})
|
|
357
422
|
|
|
358
423
|
let changed = false
|
|
359
424
|
if (force || !fresh(reqIndexPath) || missingSections(c.read(reqIndexPath), 9).length ||
|
|
@@ -234,6 +234,22 @@ function buildTaskTechSpecExcerpt(components, task, specDir, specIndexContent) {
|
|
|
234
234
|
}).join('\n\n')
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// FTR-027: duplicates wbs-assembler.js's own _parseVerificationCommands algorithm (last
|
|
238
|
+
// occurrence of the marker wins — an illustrative example earlier in Implementation notes must
|
|
239
|
+
// never be mistaken for the real section) — returns a boolean instead of the parsed array, since
|
|
240
|
+
// resume-by-status only needs the yes/no "does this task file have at least one verification
|
|
241
|
+
// command" signal, per the task template's own documented "Must be non-empty" contract. A task
|
|
242
|
+
// file that exists and is marked `generated` but has none is regenerated, not skipped forever.
|
|
243
|
+
const FENCE_RE_LOCAL = /```[^\n]*\n([\s\S]*?)```/g
|
|
244
|
+
function hasVerificationCommands(content) {
|
|
245
|
+
const marker = '**Verification commands:**'
|
|
246
|
+
const idx = content.lastIndexOf(marker)
|
|
247
|
+
if (idx === -1) return false
|
|
248
|
+
const verifPart = content.slice(idx + marker.length)
|
|
249
|
+
FENCE_RE_LOCAL.lastIndex = 0
|
|
250
|
+
return FENCE_RE_LOCAL.test(verifPart)
|
|
251
|
+
}
|
|
252
|
+
|
|
237
253
|
// Compact prompt for the per-task detail-generation worker: this task's own skeleton entry,
|
|
238
254
|
// the full skeleton's sibling task list (id + title + outcome only, for duplicate-
|
|
239
255
|
// implementation awareness), and the Tech-Spec excerpt (FTR-026: already pre-sliced to this
|
|
@@ -351,7 +367,7 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
351
367
|
for (const taskId of phase.taskIds) {
|
|
352
368
|
const task = index.tasks[taskId]
|
|
353
369
|
const detailPath = `${wbsDir}/${task.detailFile}`
|
|
354
|
-
if (task.status === 'generated' && c.exists(detailPath)) continue // resume: skip, no dispatch
|
|
370
|
+
if (task.status === 'generated' && c.exists(detailPath) && hasVerificationCommands(c.read(detailPath))) continue // resume: skip, no dispatch
|
|
355
371
|
const techSpecExcerpt = hasSpec
|
|
356
372
|
? buildTaskTechSpecExcerpt(components, task, specDir, c.read(specIndexPath))
|
|
357
373
|
: c.read(file('Tech-Spec.md')) // legacy fallback: unchanged, full text
|