@dtlabs/ai-toolkit 0.17.1 → 0.17.3

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,25 @@
1
1
  ## 0.1.4 (2026-07-21)
2
+ ## [0.17.3](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.2...v0.17.3) (2026-10-12)
3
+
4
+
5
+ ### Bug Fixes
6
+
7
+ * **execution-ledger:** close PID-reuse gap in lock-staleness liveness check ([4a9a365](https://github.com/dtlab-tech/ai-toolkit/commit/4a9a3652bf8e7844accff46851b2e1286e24c0d7))
8
+ * **task-executor:** session-list clutter and PID-reuse lock-liveness gap ([e000a6f](https://github.com/dtlab-tech/ai-toolkit/commit/e000a6faa2e75eac5bb65069eb9b37b102a14cf8))
9
+ * **task-executor:** stop every dispatch from cluttering the Claude Code session list ([8676d66](https://github.com/dtlab-tech/ai-toolkit/commit/8676d66157bceeafb7fa225cabeb8a666dbac955))
10
+
11
+ ## [0.17.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.1...v0.17.2) (2026-10-12)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **am-phase1:** resume already-landed assessor output after a crash ([057916e](https://github.com/dtlab-tech/ai-toolkit/commit/057916e975cca4e98f838000309546bf2ceb8787))
17
+ * **cli:** harden installer/CLI crash paths and fix several silent-failure bugs ([f93d18a](https://github.com/dtlab-tech/ai-toolkit/commit/f93d18a04cbea5a8d393102e3125170b170b6ae2))
18
+ * **execution-ledger:** retry rename on transient EPERM instead of unlinking destination ([fc07a4c](https://github.com/dtlab-tech/ai-toolkit/commit/fc07a4c1fdad0f953dd38f7027bec96b0b8807c2))
19
+ * **gaia-install-toolkit:** resolve self-contradiction on editing settings.local.json ([b385325](https://github.com/dtlab-tech/ai-toolkit/commit/b38532586f4661ef788b1178013991b81ecc0ea5))
20
+ * harden workflow gates, CLI installer, and ledger durability ([a9e8b65](https://github.com/dtlab-tech/ai-toolkit/commit/a9e8b6509dea2ead6d63277835254eda8ba3e5e0))
21
+ * **pm-phase:** bind Gate 1 approval to doc content, not just its text ([8f5b2f1](https://github.com/dtlab-tech/ai-toolkit/commit/8f5b2f1cbaa9262d1f11c559302b798dedcf4b4f))
22
+
2
23
  ## [0.17.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.17.0...v0.17.1) (2026-10-08)
3
24
 
4
25
 
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
 
@@ -4,6 +4,10 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const crypto = require('crypto');
7
+ // Namespaced (not destructured) so tests can jest.spyOn(ownership, '_queryProcessStartTimeSync')
8
+ // — a destructured reference would capture the original function at require
9
+ // time and bypass any later spy on the module's own export.
10
+ const ownership = require('./task-executor/ownership');
7
11
 
8
12
  // ── Internal I/O helpers (stubs — implemented by INFRA-TASK-BE-03) ──────────
9
13
 
@@ -58,15 +62,59 @@ function _writeLedger(ledgerPath, entries) {
58
62
  throw err;
59
63
  }
60
64
 
61
- try {
62
- fs.renameSync(tmpPath, ledgerPath);
63
- } catch (err) {
64
- if (err.code === 'EEXIST' || err.code === 'EPERM') {
65
- try { fs.unlinkSync(ledgerPath); } catch (_) {}
65
+ // Never unlink ledgerPath before/instead of the rename as a recovery step:
66
+ // fs.renameSync already atomically replaces an existing destination on every
67
+ // platform this runs on (POSIX rename(2); Windows via MoveFileEx with
68
+ // REPLACE_EXISTING). An EEXIST/EPERM-triggered unlink-then-rename "fallback"
69
+ // used to live here — if the process died in the window between that unlink
70
+ // succeeding and the rename completing (or if the rename then failed for an
71
+ // unrelated reason), the ledger file was simply gone, and _readLedger's
72
+ // ENOENT handling (above) treats a missing file as "empty, no error" — real,
73
+ // silent data loss, worse than the corrupt-ledger path this same module
74
+ // guards carefully via _backupCorruptFile.
75
+ //
76
+ // The EPERM/EEXIST this used to react to is real and transient, though
77
+ // (confirmed against a genuine, unmocked failure under parallel Windows test
78
+ // load, same "known environmental characteristic" docs/task-executor-
79
+ // bootstrap.md already documents for store.js's own rename) — something
80
+ // else briefly holds a handle on ledgerPath (antivirus, indexing, a
81
+ // concurrent reader). Retrying the SAME rename a bounded number of times
82
+ // with a short backoff gives that window a chance to close WITHOUT ever
83
+ // creating a moment where ledgerPath doesn't exist, unlike the old
84
+ // unlink-first approach.
85
+ let lastErr;
86
+ for (let attempt = 0; attempt < 5; attempt++) {
87
+ try {
66
88
  fs.renameSync(tmpPath, ledgerPath);
67
- } else {
68
- try { fs.unlinkSync(tmpPath); } catch (_) {}
69
- throw err;
89
+ lastErr = null;
90
+ break;
91
+ } catch (err) {
92
+ lastErr = err;
93
+ if (err.code !== 'EPERM' && err.code !== 'EEXIST') break;
94
+ _sleepSync(20);
95
+ }
96
+ }
97
+ if (lastErr) {
98
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
99
+ throw lastErr;
100
+ }
101
+ _fsyncDirSync(dir);
102
+ }
103
+
104
+ // Best-effort directory fsync after a rename — identical to, and genuinely
105
+ // mirroring this time, lib/task-executor/store.js's own _fsyncDirSync.
106
+ function _fsyncDirSync(dir) {
107
+ let dfd;
108
+ try {
109
+ dfd = fs.openSync(dir, 'r');
110
+ fs.fsyncSync(dfd);
111
+ } catch (_) {
112
+ // Directory-handle fsync is not supported on every platform/filesystem
113
+ // (notably Windows). Best-effort only — file fsync + rename already
114
+ // happened before this call.
115
+ } finally {
116
+ if (dfd !== undefined) {
117
+ try { fs.closeSync(dfd); } catch (_) {}
70
118
  }
71
119
  }
72
120
  }
@@ -87,6 +135,78 @@ function _sleepSync(ms) {
87
135
  while (Date.now() < end) { /* busy-wait for synchronous cross-process backoff */ }
88
136
  }
89
137
 
138
+ // This process's own OS-level start time, probed via ownership.js's
139
+ // host:pid:startTime technique exactly ONCE per process lifetime (not once
140
+ // per lock acquisition) and cached forever after — a process's own start
141
+ // time never changes. Recording this in every lock token we write is what
142
+ // later lets a CONTENDED acquisition attempt tell "the recorded owner is
143
+ // still running" apart from "a recycled pid now belongs to someone else"
144
+ // (see _confirmSameOwnerProcess). `undefined` means not yet probed;
145
+ // `null` means the probe was inconclusive (non-Windows, or it failed) —
146
+ // callers must treat that the same as "no evidence either way", never as
147
+ // proof of death.
148
+ let _selfProcessStartTime;
149
+ function _getSelfProcessStartTime() {
150
+ if (_selfProcessStartTime === undefined) {
151
+ const probe = ownership._queryProcessStartTimeSync(process.pid);
152
+ _selfProcessStartTime = (probe && probe.found) ? probe.startTime : null;
153
+ }
154
+ return _selfProcessStartTime;
155
+ }
156
+
157
+ // Per-nonce verdict cache for the PID-reuse check below. A lock's nonce is
158
+ // unique per acquisition (crypto.randomBytes at write time), so a verdict
159
+ // for a given nonce never goes stale: either the recorded owner is
160
+ // confirmed still the same OS process (forever, until it exits), or it was
161
+ // already confirmed to be a different process that reused the pid
162
+ // (forever — that fact doesn't change either). Caching here is what keeps
163
+ // the expensive OS probe to once per contended lock episode instead of once
164
+ // per ~100ms poll in _acquireLock's retry loop. Bounded defensively; a
165
+ // single process is not expected to ever accumulate more than a handful of
166
+ // distinct contended nonces in practice.
167
+ const _ownerProcessVerdictCache = new Map();
168
+
169
+ // Resolves whether `token.pid` is still the SAME OS process that wrote this
170
+ // lock, using the host:pid:startTime identity technique (same one
171
+ // ownership.js already uses for task leases) — `process.kill(pid, 0)` alone
172
+ // cannot tell a still-alive original owner apart from an unrelated process
173
+ // that reused a recycled pid after the owner crashed.
174
+ //
175
+ // Returns:
176
+ // true — confirmed same process (startTime probes match)
177
+ // false — confirmed a DIFFERENT process now holds this pid (mismatch)
178
+ // null — inconclusive (non-Windows, probe failed, or token predates this
179
+ // field) — caller must fall back to trusting process.kill() alone,
180
+ // exactly like before this check existed; never treated as death.
181
+ function _confirmSameOwnerProcess(token) {
182
+ if (typeof token.processStartTime !== 'string' || !token.nonce) return null;
183
+
184
+ const cached = _ownerProcessVerdictCache.get(token.nonce);
185
+ if (cached !== undefined) return cached;
186
+
187
+ const probe = ownership._queryProcessStartTimeSync(token.pid);
188
+ // Inconclusive probe (non-Windows, or the query failed) or the OS
189
+ // confirms no such pid despite process.kill() saying alive (a narrow
190
+ // race): do not cache — there is nothing stable to remember, and a
191
+ // later retry might get a conclusive answer.
192
+ if (!probe) return null;
193
+ if (!probe.found) return null;
194
+
195
+ const verdict = probe.startTime === token.processStartTime;
196
+ if (_ownerProcessVerdictCache.size > 500) _ownerProcessVerdictCache.clear();
197
+ _ownerProcessVerdictCache.set(token.nonce, verdict);
198
+ return verdict;
199
+ }
200
+
201
+ // Test-only reset: both caches are intentionally process-lifetime-scoped in
202
+ // production (see their own comments for why), which would otherwise leak
203
+ // a mocked _queryProcessStartTimeSync's behavior across unrelated test cases
204
+ // sharing the same Jest worker process.
205
+ function _resetOwnerProcessCaches() {
206
+ _selfProcessStartTime = undefined;
207
+ _ownerProcessVerdictCache.clear();
208
+ }
209
+
90
210
  function _acquireLock(lockPath, opts) {
91
211
  const deadlineMs = (opts && opts.deadlineMs != null) ? opts.deadlineMs : 5000;
92
212
  const retryIntervalMs = (opts && opts.retryIntervalMs != null) ? opts.retryIntervalMs : 100;
@@ -98,7 +218,12 @@ function _acquireLock(lockPath, opts) {
98
218
  fd = fs.openSync(lockPath, 'wx');
99
219
  const nonce = crypto.randomBytes(16).toString('hex');
100
220
  const startedAt = new Date().toISOString();
101
- const ownerToken = { pid: process.pid, startedAt, nonce };
221
+ // processStartTime: this process's own OS start time (cached once per
222
+ // process lifetime, see _getSelfProcessStartTime) — null on non-Windows
223
+ // or when the probe is unavailable, matching ownership.js's own
224
+ // null-means-inconclusive convention. Lets a later contended acquirer
225
+ // tell "still us" apart from "a recycled pid, now someone else".
226
+ const ownerToken = { pid: process.pid, startedAt, nonce, processStartTime: _getSelfProcessStartTime() };
102
227
  fs.writeSync(fd, JSON.stringify(ownerToken));
103
228
  fs.fsyncSync(fd);
104
229
  fs.closeSync(fd);
@@ -226,7 +351,15 @@ function _isLockStale(content, stat) {
226
351
  }
227
352
  }
228
353
 
229
- if (ownerAlive) {
354
+ // process.kill(pid, 0) alone cannot tell a still-alive original owner
355
+ // apart from an unrelated process that reused a recycled pid after the
356
+ // owner crashed. _confirmSameOwnerProcess returns false only when it has
357
+ // positive OS evidence that today's occupant of this pid started at a
358
+ // different time than the token recorded — a confirmed PID reuse, not a
359
+ // guess. true or null (inconclusive) both fall through to the original
360
+ // "trust process.kill()" behavior, so nothing regresses on non-Windows
361
+ // or for locks written before this field existed.
362
+ if (ownerAlive && _confirmSameOwnerProcess(token) !== false) {
230
363
  return 'live';
231
364
  }
232
365
 
@@ -783,6 +916,9 @@ module.exports = {
783
916
  _acquireLock,
784
917
  _releaseLock,
785
918
  _isLockStale,
919
+ _confirmSameOwnerProcess,
920
+ _getSelfProcessStartTime,
921
+ _resetOwnerProcessCaches,
786
922
  _validateMetadata,
787
923
  _extractStoredMetadata,
788
924
  _metadataEqual,
@@ -600,9 +600,16 @@ async function dispatchTaskAttempt(args) {
600
600
 
601
601
  // 4. Only now: dispatch the verified agent.
602
602
  const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
603
+ // --no-session-persistence: this dispatch is a one-shot --print invocation
604
+ // that is never resumed (no code path in this codebase passes --resume /
605
+ // --continue / --session-id for a task-executor spawn; replan/resume
606
+ // re-dispatch with a fresh prompt, never reattach to a prior Claude
607
+ // conversation), so a saved, resumable session transcript has no purpose
608
+ // here — only cost: every dispatch would otherwise accumulate as its own
609
+ // entry in the user's Claude Code session list.
603
610
  const baseSpawnArgs = Array.isArray(args.spawnArgs)
604
611
  ? args.spawnArgs
605
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
612
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
606
613
  // Append the same processIdentity tag already persisted above as one extra
607
614
  // trailing argv element, so it appears verbatim in the spawned process's
608
615
  // own command line — this is what makes _findLiveTaggedProcess's later OS
@@ -905,7 +912,7 @@ async function runReview(args) {
905
912
  const prompt = _buildReviewPrompt(task, diff);
906
913
  const spawnArgs = Array.isArray(args.spawnArgs)
907
914
  ? args.spawnArgs
908
- : ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
915
+ : ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
909
916
  const spawnResult = await claudeProcess.spawnClaudeAgent({
910
917
  claudePath: args.claudePath,
911
918
  args: spawnArgs,
@@ -587,4 +587,10 @@ module.exports = {
587
587
  reclaimLease,
588
588
  checkOwnerLiveness,
589
589
  createOwnershipGuard,
590
+ // Exported for execution-ledger.js's lock-staleness check (US-xx liveness
591
+ // parity): the same host:pid:startTime identity technique proven here is
592
+ // the only reliable defense against a recycled PID being misread as the
593
+ // original lock owner. See execution-ledger.js's own comment at its call
594
+ // site for why this is only invoked lazily/cached there, never per-poll.
595
+ _queryProcessStartTimeSync,
590
596
  };
@@ -139,7 +139,12 @@ function createControl(options) {
139
139
  const identity = await resolve(id);
140
140
  const definition = definitionFields(read(identity.path));
141
141
  return activity(opts.label || id, async () => {
142
- const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName];
142
+ // --no-session-persistence: this worker call is a one-shot --print
143
+ // invocation that is never resumed, so a saved session transcript has
144
+ // no purpose — only cost, accumulating as a stray entry in the user's
145
+ // Claude Code session list (see lib/task-executor/index.js's dispatch
146
+ // call sites for the same reasoning).
147
+ const cliArgs = ['--print', '--output-format', 'json', '--agent', identity.nativeName, '--no-session-persistence'];
143
148
  if (opts.schema) cliArgs.push('--json-schema', JSON.stringify(opts.schema));
144
149
  if (options.workerMaxBudgetUsd != null) cliArgs.push('--max-budget-usd', String(options.workerMaxBudgetUsd));
145
150
  const outputs = opts.outputs || [];
@@ -286,6 +291,10 @@ function createControl(options) {
286
291
  feature, parseArgs, tokenLedger, spent: () => spent,
287
292
  mtime: p => fs.statSync(full(p)).mtimeMs,
288
293
  json: p => JSON.parse(read(p)),
294
+ // Same 'sha256:'-prefixed format validateIdentity already uses for agent
295
+ // definition integrity (line ~40) — reused here to bind a Gate approval
296
+ // to the exact document content it approved, not just "a file exists".
297
+ hash: text => 'sha256:' + crypto.createHash('sha256').update(String(text), 'utf8').digest('hex'),
289
298
  log: text => (options.log || (() => {}))(String(text)),
290
299
  append: (p, text) => { fs.mkdirSync(path.dirname(full(p)), { recursive: true }); fs.appendFileSync(full(p), text); },
291
300
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
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')] }],