@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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -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
- detailByPath[c.filePath] = parseComponentDetail(fs.readFileSync(compPath, 'utf8'))
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] = line.replace(new RegExp(`components/${escaped}`, 'g'), `components/${candidate}`)
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
- const anyUcFileMissing = indexContent => parseUseCaseIndexTable(indexContent).some(row => !c.exists(`${reqDir}/${row.file}`))
356
- const anyComponentFileMissing = indexContent => parseComponentsTable(indexContent).some(comp => !c.exists(`${specDir}/components/${comp.fileSlug}`))
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