@dtlabs/ai-toolkit 0.17.0 → 0.17.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 CHANGED
@@ -1,4 +1,24 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.17.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.1...v0.17.2) (2026-10-12)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **am-phase1:** resume already-landed assessor output after a crash ([057916e](https://github.com/dtlab-tech/ai-toolkit/commit/057916e975cca4e98f838000309546bf2ceb8787))
8
+ * **cli:** harden installer/CLI crash paths and fix several silent-failure bugs ([f93d18a](https://github.com/dtlab-tech/ai-toolkit/commit/f93d18a04cbea5a8d393102e3125170b170b6ae2))
9
+ * **execution-ledger:** retry rename on transient EPERM instead of unlinking destination ([fc07a4c](https://github.com/dtlab-tech/ai-toolkit/commit/fc07a4c1fdad0f953dd38f7027bec96b0b8807c2))
10
+ * **gaia-install-toolkit:** resolve self-contradiction on editing settings.local.json ([b385325](https://github.com/dtlab-tech/ai-toolkit/commit/b38532586f4661ef788b1178013991b81ecc0ea5))
11
+ * harden workflow gates, CLI installer, and ledger durability ([a9e8b65](https://github.com/dtlab-tech/ai-toolkit/commit/a9e8b6509dea2ead6d63277835254eda8ba3e5e0))
12
+ * **pm-phase:** bind Gate 1 approval to doc content, not just its text ([8f5b2f1](https://github.com/dtlab-tech/ai-toolkit/commit/8f5b2f1cbaa9262d1f11c559302b798dedcf4b4f))
13
+
14
+ ## [0.17.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.0...v0.17.1) (2026-10-08)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **task-executor:** persist the reviewer's raw response on its receipt ([487b267](https://github.com/dtlab-tech/ai-toolkit/commit/487b2676467b72d4f6649c784b9fc6311d7819ac))
20
+ * **task-executor:** persist the reviewer's raw response on its receipt ([1ca8491](https://github.com/dtlab-tech/ai-toolkit/commit/1ca8491e81ea35cc27a5f5b9f2b970fe85b1aeef))
21
+
2
22
  ## [0.17.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.9...v0.17.0) (2026-10-08)
3
23
 
4
24
 
package/bin/cli.js CHANGED
@@ -322,8 +322,10 @@ function writeManifest(destRoot, fileList, installationMode, fileHashes) {
322
322
  try {
323
323
  ensureDir(manifestPath);
324
324
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
325
+ return true;
325
326
  } catch (err) {
326
327
  console.log(dim(`Warning: could not write manifest — ${err.message}`));
328
+ return false;
327
329
  }
328
330
  }
329
331
 
@@ -372,11 +374,19 @@ async function runInstall(label, mappings, force, destRoot, dryRun = false, inst
372
374
  let keptCount = 0;
373
375
 
374
376
  if (!dryRun) {
377
+ // A failed orphan removal (e.g. a locked file) must not abort the whole
378
+ // install — new/modified files still need copying and the manifest still
379
+ // needs writing. Warn and keep going; the orphan is left in place for a
380
+ // future run to retry.
375
381
  if (force) {
376
382
  for (const orphan of existingOrphans) {
377
- moveToTrash(destRoot, orphan);
378
- removedCount++;
379
- console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
383
+ try {
384
+ moveToTrash(destRoot, orphan);
385
+ removedCount++;
386
+ console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
387
+ } catch (err) {
388
+ console.log(` ${clr('yellow', '⚠')} Could not remove ${dim(orphan)} — ${err.message}`);
389
+ }
380
390
  }
381
391
  } else {
382
392
  for (const orphan of existingOrphans) {
@@ -384,9 +394,13 @@ async function runInstall(label, mappings, force, destRoot, dryRun = false, inst
384
394
  if (!fs.existsSync(fullPath)) continue;
385
395
  const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
386
396
  if (ok) {
387
- moveToTrash(destRoot, orphan);
388
- console.log(` ${clr('green', '✔')} Moved to trash\n`);
389
- removedCount++;
397
+ try {
398
+ moveToTrash(destRoot, orphan);
399
+ console.log(` ${clr('green', '✔')} Moved to trash\n`);
400
+ removedCount++;
401
+ } catch (err) {
402
+ console.log(` ${clr('yellow', '⚠')} Could not move to trash — ${err.message}\n`);
403
+ }
390
404
  } else {
391
405
  console.log(` ${clr('gray', '✖')} Kept as-is\n`);
392
406
  keptCount++;
@@ -432,8 +446,7 @@ async function runInstall(label, mappings, force, destRoot, dryRun = false, inst
432
446
 
433
447
  if (modified.length === 0) {
434
448
  console.log(` ${clr('green', '✔')} All new files copied. No conflicts.\n`);
435
- writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
436
- return;
449
+ return writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
437
450
  }
438
451
 
439
452
  if (force) {
@@ -443,8 +456,7 @@ async function runInstall(label, mappings, force, destRoot, dryRun = false, inst
443
456
  fs.copyFileSync(e.src, e.dest);
444
457
  console.log(` ${clr('yellow', '↺')} ${dim(path.relative(process.cwd(), e.dest))}`);
445
458
  }
446
- writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
447
- return;
459
+ return writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
448
460
  }
449
461
 
450
462
  console.log(` ${clr('yellow', 'These files differ from the toolkit version — decide for each:')}\n`);
@@ -472,7 +484,7 @@ async function runInstall(label, mappings, force, destRoot, dryRun = false, inst
472
484
  ` ${clr('gray', `✖ Kept as-is: ${skipped}`)}\n`
473
485
  );
474
486
 
475
- writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
487
+ return writeManifest(destRoot, newFileSet, installationMode, _buildFileHashes(destRoot, newFileSet));
476
488
  }
477
489
 
478
490
  // Compute SHA-256 for every relative path in relPaths under destRoot.
@@ -1804,10 +1816,15 @@ async function installLocal(targetDir, force, dryRun = false) {
1804
1816
  { src: path.join(packageRoot, 'docs'), dest: path.join(targetDir, 'docs') },
1805
1817
  { src: path.join(packageRoot, 'CLAUDE.md'), dest: path.join(targetDir, 'CLAUDE.md') },
1806
1818
  ];
1807
- await runInstall(`local project`, mappings, force, targetDir, dryRun, 'local');
1819
+ const manifestWritten = await runInstall(`local project`, mappings, force, targetDir, dryRun, 'local');
1808
1820
  if (dryRun) return;
1809
1821
  writeInstalledVersion(targetDir);
1810
- console.log(` ${clr('green', '✔')} ${bold('Install complete.')}\n`);
1822
+ if (manifestWritten === false) {
1823
+ console.log(` ${clr('yellow', '⚠')} ${bold('Install complete, but the manifest could not be written.')}\n` +
1824
+ ` Reinstall/orphan-cleanup detection will not see these files until it is.\n`);
1825
+ } else {
1826
+ console.log(` ${clr('green', '✔')} ${bold('Install complete.')}\n`);
1827
+ }
1811
1828
  checkSpawnDepth(targetDir);
1812
1829
  console.log(divider());
1813
1830
  console.log(`\n ${bold('Next steps:')}`);
@@ -1839,10 +1856,15 @@ async function installGlobal(force, dryRun = false, homeOverride = undefined) {
1839
1856
  { src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
1840
1857
  { src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
1841
1858
  ];
1842
- await runInstall('global Claude folder', mappings, force, homedir, dryRun, 'global');
1859
+ const manifestWritten = await runInstall('global Claude folder', mappings, force, homedir, dryRun, 'global');
1843
1860
  if (dryRun) return;
1844
1861
  writeInstalledVersion(homedir);
1845
- console.log(` ${clr('green', '✔')} ${bold('Global install complete.')}\n`);
1862
+ if (manifestWritten === false) {
1863
+ console.log(` ${clr('yellow', '⚠')} ${bold('Global install complete, but the manifest could not be written.')}\n` +
1864
+ ` Reinstall/orphan-cleanup detection will not see these files until it is.\n`);
1865
+ } else {
1866
+ console.log(` ${clr('green', '✔')} ${bold('Global install complete.')}\n`);
1867
+ }
1846
1868
  checkSpawnDepth(homedir);
1847
1869
  console.log(divider());
1848
1870
  console.log(`\n ${bold('Next steps:')}`);
@@ -1891,7 +1913,9 @@ async function main() {
1891
1913
  if (sub === '--project') {
1892
1914
  await installLocal(argv[2] || '.', force, dryRun);
1893
1915
  } else if (sub === '--global') {
1894
- await installGlobal(force, dryRun);
1916
+ const homeIdx = argv.indexOf('--home');
1917
+ const homeOverride = homeIdx !== -1 ? argv[homeIdx + 1] : undefined;
1918
+ await installGlobal(force, dryRun, homeOverride);
1895
1919
  } else {
1896
1920
  // No sub-flag: install into current directory (same as bare invocation)
1897
1921
  await installLocal(sub && !sub.startsWith('-') ? sub : '.', force, dryRun);
@@ -1955,6 +1979,10 @@ async function main() {
1955
1979
  const home = homeIdx !== -1 ? remaining[homeIdx + 1] : undefined;
1956
1980
  runDoctorAgents({ projectDir, home });
1957
1981
  process.exit(0);
1982
+ } else if (argv[0] === 'doctor') {
1983
+ process.stderr.write(`Unknown doctor subcommand: ${argv[1] || '(none)'}\n` +
1984
+ 'Available: doctor resolution, doctor agents\n');
1985
+ process.exit(1);
1958
1986
  } else if (argv[0] === 'validate-purity') {
1959
1987
  runValidatePurity(argv[1]);
1960
1988
  } else if (argv[0] === 'resolve-asset') {
@@ -2102,7 +2130,10 @@ async function main() {
2102
2130
  process.exit(0);
2103
2131
  } else if (argv[0] === 'resolve-features-root') {
2104
2132
  try {
2105
- const resolved = resolveFeaturesRoot(process.cwd());
2133
+ const remaining = argv.slice(1);
2134
+ const overrideIdx = remaining.indexOf('--features-root');
2135
+ const featuresRoot = overrideIdx !== -1 ? remaining[overrideIdx + 1] : undefined;
2136
+ const resolved = resolveFeaturesRoot(process.cwd(), { featuresRoot });
2106
2137
  process.stdout.write(resolved + '\n');
2107
2138
  process.exit(0);
2108
2139
  } catch (err) {
@@ -2495,17 +2526,31 @@ async function handleAgentsCommand(argv) {
2495
2526
  const effectiveHome = home !== undefined ? path.resolve(home) : os.homedir();
2496
2527
  const effectiveProject = path.resolve(projectDir);
2497
2528
 
2498
- // Locate the active manifest (project-scoped takes precedence over global).
2529
+ // Locate the active manifest. Both scopes present is ambiguous — matches
2530
+ // agentRegistry.resolveAgent/listRegisteredAgents' own 'conflict' handling
2531
+ // for the same dual-manifest case, rather than silently preferring project.
2499
2532
  const projectManifestPath = path.join(effectiveProject, '.claude', MANIFEST_FILE);
2500
2533
  const globalManifestPath = path.join(effectiveHome, '.claude', MANIFEST_FILE);
2534
+ const hasProjectManifest = fs.existsSync(projectManifestPath);
2535
+ const hasGlobalManifest = fs.existsSync(globalManifestPath);
2536
+
2537
+ if (hasProjectManifest && hasGlobalManifest) {
2538
+ process.stdout.write(
2539
+ JSON.stringify({ status: 'conflict', candidates: [], missing: [],
2540
+ error: 'Both local and global toolkit installations found; installation is ambiguous. ' +
2541
+ 'Remove one installation or reinstall to a single scope.' }, null, 2) + '\n'
2542
+ );
2543
+ process.exitCode = 0;
2544
+ return;
2545
+ }
2501
2546
 
2502
2547
  let installRoot = null;
2503
2548
  let manifestData = null;
2504
2549
 
2505
- if (fs.existsSync(projectManifestPath)) {
2550
+ if (hasProjectManifest) {
2506
2551
  installRoot = effectiveProject;
2507
2552
  manifestData = readJsonSafe(projectManifestPath);
2508
- } else if (fs.existsSync(globalManifestPath)) {
2553
+ } else if (hasGlobalManifest) {
2509
2554
  installRoot = effectiveHome;
2510
2555
  manifestData = readJsonSafe(globalManifestPath);
2511
2556
  }
@@ -2934,7 +2979,10 @@ async function handleExecutorCommand(argv) {
2934
2979
  // When required as a module (e.g., by Jest), skip main() and export pure
2935
2980
  // functions so they can be unit-tested without side effects.
2936
2981
  if (require.main === module) {
2937
- main();
2982
+ main().catch(err => {
2983
+ console.error(`\n ${clr('red', '✖')} ${err && err.message ? err.message : err}\n`);
2984
+ process.exitCode = 1;
2985
+ });
2938
2986
  } else {
2939
2987
  module.exports = {
2940
2988
  fileHash,
@@ -2947,6 +2995,7 @@ if (require.main === module) {
2947
2995
  moveToTrash,
2948
2996
  trashTimestamp,
2949
2997
  writeManifest,
2998
+ runInstall,
2950
2999
  CANONICAL_ALLOW,
2951
3000
  CANONICAL_ASK,
2952
3001
  commandToPermission,
@@ -64,6 +64,32 @@ always throws `NOT_IMPLEMENTED` (exit 1, attributed to `US-06-TASK-BE-02`); unti
64
64
  deeper per-attempt evidence inspection means using `reconcile`/`resume`, both fully implemented
65
65
  and reporting each task's classification.
66
66
 
67
+ **If the run ends up `blocked`: do not default to restarting from scratch.** `execute()` always
68
+ mints a brand-new `runId` and resets every task to `pending` — a plain `start` on the same feature
69
+ re-dispatches real, budget-consuming agents against tasks that already succeeded, re-proving work
70
+ that is already correct and already committed. Found costing real budget on a real run across
71
+ multiple restarts before anyone thought to check for an alternative: the alternative already
72
+ exists, it is just never suggested at the one moment it matters.
73
+
74
+ Before restarting, check whether `replan` + `start --from-replan-run-id` applies instead (full
75
+ contract in `docs/task-executor-cli.md`'s `replan`/`start` sections):
76
+
77
+ 1. Confirm no worker from the blocked run is still live (`replan` itself refuses otherwise via
78
+ `LIVE_WORKER_BLOCKS_REPLAN` — this is enforced, not just advised).
79
+ 2. Call `replan` against the blocked run with a `--task-mapping-json` that maps every already-
80
+ `checkpointed`/`integrated` task to itself (or to its corrected ID, if the Work Breakdown
81
+ genuinely changed) — the successor plan does **not** need to differ from the original; "same
82
+ plan, just let me continue" is a valid, intended use, not only "the plan changed".
83
+ 3. Start the successor run with `--from-replan-run-id <the-blocked-run's-id>`. Every task
84
+ `replan` marked `carriesCompletion: true` is seeded straight to `checkpointed` — no
85
+ re-dispatch, no new commit, no re-spent budget — and only the tasks that genuinely still need
86
+ work are actually dispatched.
87
+
88
+ This does **not** help a task that was completed by hand outside the executor entirely (direct
89
+ subagent dispatch, no executor run ever recorded it) — `replan` can only carry forward evidence
90
+ the executor itself already checkpointed. For that gap, see section 4c below
91
+ ("`resume()`/`reconcile()` only inspect and report").
92
+
67
93
  For the full flag reference, exit-code table, and JSON result schemas of every subcommand
68
94
  (`start`, `status`, `diagnose`, `stop`, `reconcile`, `resume`, `replan`), see
69
95
  `docs/task-executor-cli.md` — this document intentionally does not restate that contract.
@@ -205,6 +231,13 @@ the interrupted run will automatically finish its remaining tasks. `execute()` a
205
231
  brand-new run (a fresh `runId`, all tasks reset to pending); there is no run loop wired into
206
232
  `resume()`.
207
233
 
234
+ **Update:** this gap now has a real, explicit (never automatic) partial workaround for the
235
+ executor's own already-checkpointed work — see section 1's "If the run ends up `blocked`"
236
+ guidance above (`replan` + `start --from-replan-run-id`). It still does not cover work completed
237
+ entirely outside the executor (direct subagent dispatch that never recorded an attempt) — there
238
+ is still no way to adopt that into the evidence trail short of a human/agent confirming it and
239
+ deciding what to do by hand.
240
+
208
241
  ### d. Dormant concurrency gap in `dispatchTaskAttempt` (must be resolved before N>1 wiring)
209
242
 
210
243
  Confirmed by US-08-TASK-TEST-01 (`tests/task-executor/parallel.test.js`, commit `2f06883`),
@@ -542,7 +542,12 @@ function _provenanceGuard(projectDir, agentId, platform, homeDir) {
542
542
  platform: platform,
543
543
  nativeName: nativeName,
544
544
  status: 'manifest-missing',
545
- error: 'No toolkit installation found; manifest absent at both project and global scope.',
545
+ // Precise claim: only the manifest is confirmed absent. Payload files or a
546
+ // version stamp may still be on disk (isToolkitInstalled in bin/cli.js checks
547
+ // those too) — this function cannot read agent registration data without a
548
+ // manifest, so it reports exactly that, not a broader "nothing is installed".
549
+ error: 'No installation manifest found at either project or global scope; ' +
550
+ 'agent registration data cannot be read.',
546
551
  };
547
552
  }
548
553
 
@@ -58,15 +58,59 @@ function _writeLedger(ledgerPath, entries) {
58
58
  throw err;
59
59
  }
60
60
 
61
- try {
62
- fs.renameSync(tmpPath, ledgerPath);
63
- } catch (err) {
64
- if (err.code === 'EEXIST' || err.code === 'EPERM') {
65
- try { fs.unlinkSync(ledgerPath); } catch (_) {}
61
+ // Never unlink ledgerPath before/instead of the rename as a recovery step:
62
+ // fs.renameSync already atomically replaces an existing destination on every
63
+ // platform this runs on (POSIX rename(2); Windows via MoveFileEx with
64
+ // REPLACE_EXISTING). An EEXIST/EPERM-triggered unlink-then-rename "fallback"
65
+ // used to live here — if the process died in the window between that unlink
66
+ // succeeding and the rename completing (or if the rename then failed for an
67
+ // unrelated reason), the ledger file was simply gone, and _readLedger's
68
+ // ENOENT handling (above) treats a missing file as "empty, no error" — real,
69
+ // silent data loss, worse than the corrupt-ledger path this same module
70
+ // guards carefully via _backupCorruptFile.
71
+ //
72
+ // The EPERM/EEXIST this used to react to is real and transient, though
73
+ // (confirmed against a genuine, unmocked failure under parallel Windows test
74
+ // load, same "known environmental characteristic" docs/task-executor-
75
+ // bootstrap.md already documents for store.js's own rename) — something
76
+ // else briefly holds a handle on ledgerPath (antivirus, indexing, a
77
+ // concurrent reader). Retrying the SAME rename a bounded number of times
78
+ // with a short backoff gives that window a chance to close WITHOUT ever
79
+ // creating a moment where ledgerPath doesn't exist, unlike the old
80
+ // unlink-first approach.
81
+ let lastErr;
82
+ for (let attempt = 0; attempt < 5; attempt++) {
83
+ try {
66
84
  fs.renameSync(tmpPath, ledgerPath);
67
- } else {
68
- try { fs.unlinkSync(tmpPath); } catch (_) {}
69
- throw err;
85
+ lastErr = null;
86
+ break;
87
+ } catch (err) {
88
+ lastErr = err;
89
+ if (err.code !== 'EPERM' && err.code !== 'EEXIST') break;
90
+ _sleepSync(20);
91
+ }
92
+ }
93
+ if (lastErr) {
94
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
95
+ throw lastErr;
96
+ }
97
+ _fsyncDirSync(dir);
98
+ }
99
+
100
+ // Best-effort directory fsync after a rename — identical to, and genuinely
101
+ // mirroring this time, lib/task-executor/store.js's own _fsyncDirSync.
102
+ function _fsyncDirSync(dir) {
103
+ let dfd;
104
+ try {
105
+ dfd = fs.openSync(dir, 'r');
106
+ fs.fsyncSync(dfd);
107
+ } catch (_) {
108
+ // Directory-handle fsync is not supported on every platform/filesystem
109
+ // (notably Windows). Best-effort only — file fsync + rename already
110
+ // happened before this call.
111
+ } finally {
112
+ if (dfd !== undefined) {
113
+ try { fs.closeSync(dfd); } catch (_) {}
70
114
  }
71
115
  }
72
116
  }
@@ -715,7 +715,7 @@ function _extractReviewVerdict(spawnResult) {
715
715
  : null;
716
716
 
717
717
  if (text === null) {
718
- return { passed: null, criticalFindings: [] };
718
+ return { passed: null, criticalFindings: [], responseText: null };
719
719
  }
720
720
 
721
721
  const verdictMatch = text.match(/Verdict:\s*(PASS|FAIL)/i);
@@ -754,7 +754,16 @@ function _extractReviewVerdict(spawnResult) {
754
754
  }
755
755
  }
756
756
 
757
- return { passed: passed, criticalFindings: criticalFindings };
757
+ // Persisted verbatim on the review receipt (persistVerificationReviewOutcome)
758
+ // so a FAIL this function cannot fully explain — any future template
759
+ // variation neither the CRITICAL section nor the Build/Tests fold-in
760
+ // above recognizes — is still diagnosable from the receipt itself,
761
+ // instead of requiring the diff to be reconstructed and re-judged by hand
762
+ // after the fact (confirmed necessary against a real run: this exact
763
+ // "reviewPassed:false, criticalFindings:[]" shape recurred on a THIRD,
764
+ // previously-clean task, meaning the Build/Tests fold-in does not cover
765
+ // every real cause of an unexplained FAIL).
766
+ return { passed: passed, criticalFindings: criticalFindings, responseText: text };
758
767
  }
759
768
 
760
769
  /**
@@ -824,7 +833,8 @@ function _extractReviewVerdict(spawnResult) {
824
833
  * runId: string, taskId: string, attemptNumber: number,
825
834
  * agentId: string, nativeAgentName: string,
826
835
  * ledgerOperationId: string, spawnResult: object,
827
- * reviewPassed: (boolean|null), criticalFindings: string[]
836
+ * reviewPassed: (boolean|null), criticalFindings: string[],
837
+ * responseText: (string|null)
828
838
  * }>}
829
839
  * @throws {Error} NO_LOCK_HELD, AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH,
830
840
  * DISPATCH_VALIDATION_ERROR, or any error surfaced by spawnClaudeAgent
@@ -918,6 +928,7 @@ async function runReview(args) {
918
928
  spawnResult: spawnResult,
919
929
  reviewPassed: verdict.passed,
920
930
  criticalFindings: verdict.criticalFindings,
931
+ responseText: verdict.responseText,
921
932
  };
922
933
  }
923
934
 
@@ -777,6 +777,14 @@ function persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptN
777
777
  attemptNumber: attemptNumber,
778
778
  reviewPassed: review.reviewPassed === undefined ? null : review.reviewPassed,
779
779
  criticalFindings: review.criticalFindings,
780
+ // Verbatim review-solution reply, persisted so a FAIL neither the
781
+ // CRITICAL section nor the Build/Tests fold-in can explain
782
+ // (_extractReviewVerdict's own criticalFindings: [] either way) is
783
+ // still diagnosable from this receipt — never requires reconstructing
784
+ // and re-judging the diff by hand after the fact. null (never '') when
785
+ // the reply had no readable envelope at all — see
786
+ // _extractReviewVerdict's own null-compatibility.
787
+ responseText: review.responseText === undefined ? null : review.responseText,
780
788
  authoritative: verificationPassed === true,
781
789
  });
782
790
  }
@@ -286,6 +286,10 @@ function createControl(options) {
286
286
  feature, parseArgs, tokenLedger, spent: () => spent,
287
287
  mtime: p => fs.statSync(full(p)).mtimeMs,
288
288
  json: p => JSON.parse(read(p)),
289
+ // Same 'sha256:'-prefixed format validateIdentity already uses for agent
290
+ // definition integrity (line ~40) — reused here to bind a Gate approval
291
+ // to the exact document content it approved, not just "a file exists".
292
+ hash: text => 'sha256:' + crypto.createHash('sha256').update(String(text), 'utf8').digest('hex'),
289
293
  log: text => (options.log || (() => {}))(String(text)),
290
294
  append: (p, text) => { fs.mkdirSync(path.dirname(full(p)), { recursive: true }); fs.appendFileSync(full(p), text); },
291
295
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -55,6 +55,8 @@ The prefix is everything up to and including the second hyphen-separated segment
55
55
 
56
56
  **Do NOT write `{PREFIX}-Work-Breakdown.md` or `{PREFIX}-Work-Breakdown.csv`.** Those are generated deterministically from the JSON by `wb-render.js` in a later pipeline step.
57
57
 
58
+ "Authoritative" above means authoritative **for this agent's own generation and for `wb-validate.js`'s structural checks** — it does not mean every downstream reader consumes the JSON. The rendered `.md` (plus the dispatch `.csv`) is the tested, complete deliverable for everything that reads a Work Breakdown afterward: `lib/task-executor/plan.js` (the real task executor parses `.md`+`.csv`, never the JSON) and the `gaia-developer-*` agents both read the `.md`, and `tests/wb-render.*.test.js` specifically guards that every field they need (dependencies, verification commands, acceptance criteria, etc.) survives the JSON→Markdown rendering byte-for-byte. Only `gaia-validate-work-breakdown-semantic` reads the JSON directly, for its own programmatic comparison against Requirements ACs.
59
+
58
60
  ---
59
61
 
60
62
  ## Atomicity Contract
@@ -243,6 +243,6 @@ If any modified files were kept by the user (not overwritten), list them so they
243
243
 
244
244
  - **Never overwrite `docs/procedures/` files** regardless of `--force` — project overrides take priority
245
245
  - **Never copy `install-toolkit.md`** — it's a toolkit-internal utility
246
- - **Never copy or edit `settings.json` / `settings.local.json`** — user-owned config; only verify and advise (Step 4b)
246
+ - **The installer (Step 2) never copies `settings.json` / `settings.local.json`**, and Step 4's spawn-depth check is read-only (verify and advise, never write). The ONE exception is Step 6's Bash allowlist, which edits `settings.local.json` — but only via the dedicated `merge-allowlist` CLI, and only after the user explicitly opts in; never edit either file directly or outside that step
247
247
  - **Create directories as needed** — destination may not have `.claude/` yet
248
248
  - **Abort cleanly** if destination path is invalid — do not create partial installs
@@ -7,7 +7,7 @@ argument-hint: <path-to-feature.md> [--force]
7
7
 
8
8
  Runs documentation and work breakdown through the deterministic Node workflow host, then implementation through the FTR-018 task executor. Approval gates remain in the main loop.
9
9
 
10
- Workflows are JavaScript assets, never agent types. Do not pass pm-phase1/2/3 or am-phase1/2 to agentType, subagent_type, or --agent. Use the workflow CLI below. Resolve the absolute Claude executable path as described in docs/task-executor-bootstrap.md.
10
+ Workflows are JavaScript assets, never agent types. Do not pass pm-phase1/2/3 or am-phase1/2 to agentType, subagent_type, or --agent. Use the workflow CLI below. Resolve the absolute Claude executable path as described in `docs/task-executor-bootstrap.md` — never relative to this skill's own package directory (which holds only this `SKILL.md`, no `docs/` subfolder). That path is relative to wherever this toolkit's `docs/` landed for the install actually in use: the project root for a local install, or `~/.claude/` for a global install (`bin/cli.js`'s `installLocal`/`installGlobal` — a global install nests `docs/` under `~/.claude/docs/` alongside `skills/`/`agents/`; a local install places it at `<project-root>/docs/`, a sibling of `.claude/`, not inside it).
11
11
 
12
12
  ---
13
13
 
@@ -68,6 +68,8 @@ Extract from the result:
68
68
  - `requirements.summary` — requirements file summary
69
69
  - `tech_spec.summary` — tech-spec file summary
70
70
  - `validation.summary` — validation result (gaps or clean)
71
+ - `docs_digest` — content digest binding this approval to the exact Requirements/Tech-Spec
72
+ bytes just produced; embed verbatim into `{PREFIX}-Approvals.md` in Step 2 below
71
73
  - `token_ledger` — array of per-agent token data from phase 1
72
74
  - `errors` — any agent failures during phase 1
73
75
 
@@ -117,6 +119,8 @@ The pipeline CANNOT continue until you reply directly.
117
119
  | {PREFIX}-Tech-Spec.md | ✅ Approved | {today} | {notes or —} |
118
120
  | {PREFIX}-Validation-Report.md | ✅ Approved | {today} | {notes or —} |
119
121
 
122
+ **Docs Digest:** `{docs_digest}`
123
+
120
124
  ## Approval History
121
125
 
122
126
  | Cycle | Action | Date | Details |
@@ -32,9 +32,21 @@ return c.run(meta.name, outputDir, prefix, async () => {
32
32
  // Neither assessor below has a Write tool: each returns its report as plain response
33
33
  // text, which the host persists to its declared `outputs` path (see worker()'s own
34
34
  // comment in lib/workflow-control.js for why — "Never ask an LLM to do I/O").
35
+ // Resume: unlike pm-phase1's own fresh() check, a crash between two assessors
36
+ // finishing and a third one still running used to force ALL of them to be
37
+ // re-dispatched from scratch on retry — c.parallel()'s own Promise.allSettled
38
+ // already lets the OTHER assessors' outputs land on disk even when one fails
39
+ // (confirmed in lib/workflow-control.js), but nothing here checked for that
40
+ // already-landed output before paying for a fresh dispatch. An assessor's own
41
+ // output is never partially written (worker() persists it only after a
42
+ // successful, fully-parsed response), so a non-empty existing file is safe to
43
+ // treat as already-done and skip.
35
44
  const assessmentResults = await c.parallel([...groups].map(([id, areas]) => async () => {
36
45
  const suffix = { 'gaia.agent.assessment.generic': 'Generic', 'gaia.agent.assessment.layered-architecture': 'Layer', 'gaia.agent.assessment.concurrency': 'Concurrency' }[id]
37
46
  const output_file = `${outputDir}/${prefix}-${suffix}-Assessment.md`
47
+ if (c.exists(output_file) && c.read(output_file).trim().length > 0) {
48
+ return { agent: id, scopes: areas, result: c.read(output_file), output_file }
49
+ }
38
50
  const result = await c.worker(id,
39
51
  `${targetPath} --prefix ${prefix} --output-dir ${outputDir}\n` +
40
52
  `Read-only assessment. Explicit scope: ${areas.join(', ')}. Inspect risks, evidence, severity and recommendations for EACH listed scope. ` +
@@ -31,24 +31,43 @@ return c.run(meta.name, featureDir, prefix, async () => {
31
31
  let validationSummary = 'skipped (fresh)'
32
32
  if (force || changed || !fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) {
33
33
  for (let cycle = 1; cycle <= 3; cycle++) {
34
- const result = await c.worker(valId, featurePath + '\nValidate only; the host owns the revision loop. Return valid, a findings array (one string per remaining gap), and the full validation report content. Do not dispatch other agents.', { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, file('Requirements'), file('Tech-Spec')])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, report: { type: 'string' } }, required: ['valid', 'findings', 'report'] } })
35
- const text = result.findings.join('\n')
36
- if (result.valid && result.findings.length === 0) {
34
+ // needsRequirementsFix/needsTechSpecFix: explicit structured routing fields,
35
+ // not inferred by substring-matching the free-text `findings` array (which a
36
+ // finding mentioning both document names, or neither, would mis-route or
37
+ // silently skip). The validator agent itself decides which document(s) its
38
+ // own findings implicate.
39
+ const result = await c.worker(valId, featurePath + '\nValidate 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), and the full validation report content. Do not dispatch other agents.', { label: `validation:${cycle}`, outputs: [output('Validation-Report', [featurePath, file('Requirements'), file('Tech-Spec')])], schema: { type: 'object', properties: { valid: { type: 'boolean' }, findings: { type: 'array', items: { type: 'string' } }, needsRequirementsFix: { type: 'boolean' }, needsTechSpecFix: { type: 'boolean' }, report: { type: 'string' } }, required: ['valid', 'findings', 'needsRequirementsFix', 'needsTechSpecFix', 'report'] } })
40
+ // A model returning valid:true alongside non-empty findings (or valid:false
41
+ // with none) contradicts its own schema's intent — never silently treated as
42
+ // "not clean yet" and spent on another cycle without surfacing the
43
+ // inconsistency; fail closed instead of masking a confused response.
44
+ if (result.valid === (result.findings.length > 0)) {
45
+ throw new Error(`Validation agent returned a contradictory result on cycle ${cycle}: valid=${result.valid} with ${result.findings.length} finding(s)`)
46
+ }
47
+ if (result.valid) {
37
48
  if (!fresh(file('Validation-Report'), [featurePath, file('Requirements'), file('Tech-Spec')])) throw new Error('Validation agent did not produce its report')
38
49
  validationSummary = `0 gaps (clean on cycle ${cycle})`
39
50
  break
40
51
  }
41
52
  if (cycle === 3) throw new Error('Validation gaps remain after 3 cycles')
42
- if (!text.includes('Tech-Spec') || text.includes('Requirements')) await requirements()
43
- if (!text.includes('Requirements') || text.includes('Tech-Spec')) await techSpec()
53
+ if (!result.needsRequirementsFix && !result.needsTechSpecFix) throw new Error(`Validation agent reported gaps on cycle ${cycle} but flagged neither document for revision`)
54
+ if (result.needsRequirementsFix) await requirements()
55
+ if (result.needsTechSpecFix) await techSpec()
44
56
  }
45
57
  }
58
+ // Binds Gate 1's eventual approval to the EXACT Requirements/Tech-Spec bytes
59
+ // produced here, not just "a file named *-Requirements.md exists" — SKILL.md's
60
+ // Step 2 embeds this verbatim into Approvals.md, and pm-phase2.js's Gate 1 check
61
+ // fails closed if the current file content no longer matches it (content
62
+ // regenerated after approval, e.g. via a later --force run, must re-approve).
63
+ const docsDigest = c.hash(c.read(file('Requirements')) + '\u0000' + c.read(file('Tech-Spec')))
46
64
  c.append(`${featureDir}/${prefix}-process-log.txt`, `[${new Date().toISOString()}] pm-phase1: ${validationSummary}; Gate 1 approval requested\n`)
47
65
  return {
48
66
  prefix, feature_dir: featureDir, feature_path: featurePath,
49
67
  requirements: { path: file('Requirements'), summary: 'Requirements ready' },
50
68
  tech_spec: { path: file('Tech-Spec'), summary: 'Tech-Spec ready' },
51
69
  validation: { path: file('Validation-Report'), summary: validationSummary },
70
+ docs_digest: docsDigest,
52
71
  token_ledger: c.tokenLedger, errors: [],
53
72
  }
54
73
  })
@@ -14,6 +14,22 @@ return c.run(meta.name, featureDir, prefix, async () => {
14
14
  const approvals = c.read(file('Approvals.md'))
15
15
  const gate = approvals.match(/^## Gate 1\b[^\n]*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m)
16
16
  if (!gate || !/✅\s*Approved/.test(gate[1])) throw new Error('Gate 1 approval evidence missing')
17
+ // Binds this approval to the EXACT Requirements/Tech-Spec content pm-phase1
18
+ // produced when it was approved (docs_digest, written into Approvals.md by
19
+ // SKILL.md's Step 2) — never just "a Gate 1 row says Approved". A missing
20
+ // digest (an approval recorded before this check existed) is not enforced,
21
+ // same null-compatible "unavailable, never fabricated" convention used
22
+ // throughout this toolkit; a PRESENT digest that no longer matches the
23
+ // current files fails closed — the approved documents changed since
24
+ // approval (e.g. a later --force re-run of pm-phase1) and must be
25
+ // re-approved, not silently carried forward on stale consent.
26
+ const digestMatch = gate[1].match(/\*\*Docs Digest:\*\*\s*`([^`]+)`/)
27
+ if (digestMatch) {
28
+ const currentDigest = c.hash(c.read(file('Requirements.md')) + '\u0000' + c.read(file('Tech-Spec.md')))
29
+ if (digestMatch[1] !== currentDigest) {
30
+ throw new Error('Gate 1 approval is stale: Requirements/Tech-Spec content no longer matches what was approved (docs digest mismatch) — re-approve Gate 1 before proceeding')
31
+ }
32
+ }
17
33
  const jsonPath = file('Work-Breakdown.json')
18
34
  await c.worker('gaia.agent.planner.work-breakdown', featurePath, {
19
35
  outputs: [{ path: jsonPath, inputs: [featurePath, file('Requirements.md'), file('Tech-Spec.md')] }],