@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 +21 -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 +146 -10
- package/lib/task-executor/index.js +9 -2
- package/lib/task-executor/ownership.js +6 -0
- package/lib/workflow-control.js +10 -1
- 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,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
|
-
|
|
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
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/lib/workflow-control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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')] }],
|