@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
|
@@ -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
|