@dtlabs/ai-toolkit 0.19.2 → 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,12 @@
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
+
2
10
  ## [0.19.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.19.1...v0.19.2) (2026-10-19)
3
11
 
4
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.19.2",
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/",
@@ -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