@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 +20 -0
- package/bin/cli.js +70 -21
- package/docs/task-executor-bootstrap.md +33 -0
- package/lib/agent-registry.js +6 -1
- package/lib/execution-ledger.js +52 -8
- package/lib/task-executor/index.js +14 -3
- package/lib/task-executor/store.js +8 -0
- package/lib/workflow-control.js +4 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-generate-work-breakdown.md +2 -0
- package/src/claude/agents/gaia-install-toolkit.md +1 -1
- package/src/claude/skills/implement-feature/SKILL.md +5 -1
- package/src/claude/workflows/am-phase1.js +12 -0
- package/src/claude/workflows/pm-phase1.js +24 -5
- package/src/claude/workflows/pm-phase2.js +16 -0
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
2550
|
+
if (hasProjectManifest) {
|
|
2506
2551
|
installRoot = effectiveProject;
|
|
2507
2552
|
manifestData = readJsonSafe(projectManifestPath);
|
|
2508
|
-
} else if (
|
|
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`),
|
package/lib/agent-registry.js
CHANGED
|
@@ -542,7 +542,12 @@ function _provenanceGuard(projectDir, agentId, platform, homeDir) {
|
|
|
542
542
|
platform: platform,
|
|
543
543
|
nativeName: nativeName,
|
|
544
544
|
status: 'manifest-missing',
|
|
545
|
-
|
|
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
|
|
package/lib/execution-ledger.js
CHANGED
|
@@ -58,15 +58,59 @@ function _writeLedger(ledgerPath, entries) {
|
|
|
58
58
|
throw err;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/workflow-control.js
CHANGED
|
@@ -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
|
@@ -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
|
-
- **
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 (!
|
|
43
|
-
if (
|
|
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')] }],
|