@dtlabs/ai-toolkit 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/lib/atomic-rename.js +49 -0
- package/lib/execution-ledger.js +14 -23
- package/lib/task-executor/git.js +31 -0
- package/lib/task-executor/index.js +57 -29
- package/lib/task-executor/ownership.js +4 -1
- package/lib/task-executor/plan.js +1 -1
- package/lib/task-executor/store.js +6 -1
- package/lib/workflow-control.js +3 -1
- package/lib/workflow-worker-evidence.js +3 -1
- package/package.json +1 -1
- package/src/claude/scripts/wb-render.js +62 -60
- package/src/claude/skills/implement-feature/SKILL.md +21 -0
- package/src/claude/workflows/pm-phase1.js +42 -1
- package/src/claude/workflows/pm-phase2.js +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.22.0](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.2...v0.22.0) (2026-10-21)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* **pm-phase1:** regenerating the Tech-Spec skeleton no longer forces every component to redispatch ([f28173b](https://github.com/dtlab-tech/ai-toolkit/commit/f28173b5dde57f8432eabcb7970af1feeb93711a))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
* **atomic-rename:** retry a transient Windows EPERM/EBUSY on rename across every atomic-write site ([737d871](https://github.com/dtlab-tech/ai-toolkit/commit/737d871eb98c7d8ea4015734931e344fcef8798e))
|
|
13
|
+
* pm-phase1 AC/Tech-Spec regen, Windows rename retry, task-executor plan parsing & review scoping ([02767d0](https://github.com/dtlab-tech/ai-toolkit/commit/02767d055edea34eb488a46224655199d7c49e43))
|
|
14
|
+
* **pm-phase1:** exempt an AC documented in Risks & Mitigations from the orphan-AC check ([a607306](https://github.com/dtlab-tech/ai-toolkit/commit/a60730650016c97d5895432b20120364f3e4db89))
|
|
15
|
+
* **task-executor/plan:** parse task-detail anchors on CRLF-line-ended Work Breakdowns ([619764f](https://github.com/dtlab-tech/ai-toolkit/commit/619764ff5b4b1b9e4c2456da96ed3b43235aa74a))
|
|
16
|
+
* **task-executor:** execute()/replan() fall back to the split wbs/ directory ([49df349](https://github.com/dtlab-tech/ai-toolkit/commit/49df34902c14f50e8121335700b3fe2f9b32b978))
|
|
17
|
+
* **task-executor:** stop misattributing pre-existing uncommitted content to task commits ([04aee6c](https://github.com/dtlab-tech/ai-toolkit/commit/04aee6c8ab953c1a44997bee5842f7f89296c6d9))
|
|
18
|
+
|
|
19
|
+
## [0.21.2](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.1...v0.21.2) (2026-10-21)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **pm-phase2:** read gaia-user-story-template.md via print-asset, not a local-install-relative path ([4913bcf](https://github.com/dtlab-tech/ai-toolkit/commit/4913bcfb71bd37b56d373d340abf9cee17478154))
|
|
25
|
+
* read gaia-user-story-template.md via print-asset (global-install compatibility) ([29486d9](https://github.com/dtlab-tech/ai-toolkit/commit/29486d9eeec4ae6ea2abfe02ffba4753addfc9fb))
|
|
26
|
+
|
|
2
27
|
## [0.21.1](https://github.com/dtlab-tech/ai-toolkit/compare/v0.21.0...v0.21.1) (2026-10-20)
|
|
3
28
|
|
|
4
29
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Windows can transiently reject fs.renameSync with EPERM/EBUSY under concurrent I/O load (e.g.
|
|
4
|
+
// antivirus/indexer briefly holding a handle open, or another process reading the destination)
|
|
5
|
+
// even though the rename is logically valid and would succeed moments later — a well-documented
|
|
6
|
+
// OS characteristic (the same reason packages like write-file-atomic retry this exact case on
|
|
7
|
+
// Windows), not a defect in the atomic-write protocol around it. Previously documented in
|
|
8
|
+
// docs/task-executor-bootstrap.md as an "operational characteristic, not a code defect" with no
|
|
9
|
+
// actual mitigation; confirmed to recur on a real `executor start` run (not just under heavy
|
|
10
|
+
// parallel-test-suite I/O), and Windows is the only platform this executor is qualified for (E-02)
|
|
11
|
+
// — "just retry by hand" is not an acceptable permanent answer for the one supported platform.
|
|
12
|
+
//
|
|
13
|
+
// A short, bounded retry absorbs the transient case without masking a genuinely permanent failure
|
|
14
|
+
// (a real permission problem, a file locked open for a long time) — those still fail after the
|
|
15
|
+
// bound, exactly as before this existed. Every one of this module's 5 call sites (execution-ledger,
|
|
16
|
+
// task-executor's store/ownership, workflow-control, workflow-worker-evidence) already implements
|
|
17
|
+
// the identical temp-write-then-rename atomic protocol; this is the one place the rename step
|
|
18
|
+
// itself gets hardened, instead of duplicating the same retry loop 5 times.
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
|
|
21
|
+
const RETRY_ERROR_CODES = new Set(['EPERM', 'EBUSY']);
|
|
22
|
+
const MAX_ATTEMPTS = 5;
|
|
23
|
+
const BACKOFF_MS = [10, 25, 50, 100, 200];
|
|
24
|
+
|
|
25
|
+
// A true synchronous sleep (every call site here is itself synchronous, mirroring
|
|
26
|
+
// fs.renameSync) — Atomics.wait on a SharedArrayBuffer-backed Int32Array is the standard,
|
|
27
|
+
// correct way to block the current thread without a CPU-burning busy loop; no external
|
|
28
|
+
// dependency, available in every Node version this project supports.
|
|
29
|
+
function _sleepSyncMs(ms) {
|
|
30
|
+
const sab = new SharedArrayBuffer(4);
|
|
31
|
+
Atomics.wait(new Int32Array(sab), 0, 0, ms);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Drop-in replacement for fs.renameSync(src, dest) — identical contract (throws on a genuinely
|
|
35
|
+
// unrecoverable error, including after exhausting the retry bound), just resilient to the
|
|
36
|
+
// specific transient Windows rename race already characterized above.
|
|
37
|
+
function renameSyncWithRetry(src, dest) {
|
|
38
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
fs.renameSync(src, dest);
|
|
41
|
+
return;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!RETRY_ERROR_CODES.has(err.code) || attempt === MAX_ATTEMPTS) throw err;
|
|
44
|
+
_sleepSyncMs(BACKOFF_MS[attempt - 1]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { renameSyncWithRetry };
|
package/lib/execution-ledger.js
CHANGED
|
@@ -8,6 +8,7 @@ const crypto = require('crypto');
|
|
|
8
8
|
// — a destructured reference would capture the original function at require
|
|
9
9
|
// time and bypass any later spy on the module's own export.
|
|
10
10
|
const ownership = require('./task-executor/ownership');
|
|
11
|
+
const { renameSyncWithRetry } = require('./atomic-rename');
|
|
11
12
|
|
|
12
13
|
// ── Internal I/O helpers (stubs — implemented by INFRA-TASK-BE-03) ──────────
|
|
13
14
|
|
|
@@ -73,30 +74,20 @@ function _writeLedger(ledgerPath, entries) {
|
|
|
73
74
|
// silent data loss, worse than the corrupt-ledger path this same module
|
|
74
75
|
// guards carefully via _backupCorruptFile.
|
|
75
76
|
//
|
|
76
|
-
// The EPERM
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// else briefly holds a handle on ledgerPath (antivirus, indexing, a
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// creating a moment where ledgerPath doesn't exist, unlike
|
|
84
|
-
// unlink-first approach.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
fs.renameSync(tmpPath, ledgerPath);
|
|
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) {
|
|
77
|
+
// The EPERM this used to react to is real and transient (confirmed against a genuine, unmocked
|
|
78
|
+
// failure under parallel Windows test load, same "known environmental characteristic"
|
|
79
|
+
// docs/task-executor-bootstrap.md already documents for store.js's own rename, and since
|
|
80
|
+
// confirmed to recur on a real `executor start` run too, not just test-suite load) — something
|
|
81
|
+
// else briefly holds a handle on ledgerPath (antivirus, indexing, a concurrent reader).
|
|
82
|
+
// renameSyncWithRetry (lib/atomic-rename.js, shared by every atomic-write call site in this
|
|
83
|
+
// codebase) retries the SAME rename a bounded number of times with a short backoff, giving that
|
|
84
|
+
// window a chance to close WITHOUT ever creating a moment where ledgerPath doesn't exist, unlike
|
|
85
|
+
// the old unlink-first approach this comment originally warned against.
|
|
86
|
+
try {
|
|
87
|
+
renameSyncWithRetry(tmpPath, ledgerPath);
|
|
88
|
+
} catch (err) {
|
|
98
89
|
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
99
|
-
throw
|
|
90
|
+
throw err;
|
|
100
91
|
}
|
|
101
92
|
_fsyncDirSync(dir);
|
|
102
93
|
}
|
package/lib/task-executor/git.js
CHANGED
|
@@ -578,6 +578,36 @@ function detectChangedPaths(cwd) {
|
|
|
578
578
|
return changedPaths;
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
// ── excludePreExistingPaths ──────────────────────────────────────────────────
|
|
582
|
+
//
|
|
583
|
+
// detectChangedPaths(cwd) reports EVERY uncommitted path in the whole
|
|
584
|
+
// worktree — by design, the run loop dispatches directly against the real
|
|
585
|
+
// project directory (maxConcurrency=1; isolated per-attempt worktrees are
|
|
586
|
+
// US-08 parallel-execution scope only), and Tech-Spec section 8 requires
|
|
587
|
+
// the review to see "exactly what will be checkpointed", which stageTaskFiles
|
|
588
|
+
// then stages unmodified. If anything was ALREADY uncommitted in that
|
|
589
|
+
// directory before this task's own attempt ever ran — most commonly this
|
|
590
|
+
// feature's own Requirements/Tech-Spec/Work-Breakdown docs, left uncommitted
|
|
591
|
+
// by design after Gate 1/Gate 2 approval (see implement-feature/SKILL.md) —
|
|
592
|
+
// it would bleed into every task's review prompt (reproduced on a real run:
|
|
593
|
+
// 400KB+ of unrelated planning docs on a 30-line task's review, "Prompt is
|
|
594
|
+
// too long" on every attempt) and get silently committed into that task's
|
|
595
|
+
// own commit via stageTaskFiles, misattributing unrelated content to it.
|
|
596
|
+
//
|
|
597
|
+
// Filters a "current" detectChangedPaths() result down to only the entries
|
|
598
|
+
// that are NEW or changed relative to a "baseline" snapshot taken before the
|
|
599
|
+
// task started (captured once per task, not per attempt, in
|
|
600
|
+
// _runTaskToResolution — so a later attempt within the same task's retry
|
|
601
|
+
// loop still correctly sees an earlier attempt's own real work, which is not
|
|
602
|
+
// "pre-existing" relative to the TASK as a whole). A path present in both
|
|
603
|
+
// snapshots with the SAME changeType is untouched by this task and excluded;
|
|
604
|
+
// a new path, or one whose changeType differs, is this task's own work and
|
|
605
|
+
// included.
|
|
606
|
+
function excludePreExistingPaths(baselinePaths, currentPaths) {
|
|
607
|
+
const baselineByPath = new Map(baselinePaths.map(function (e) { return [e.path, e.changeType]; }));
|
|
608
|
+
return currentPaths.filter(function (e) { return baselineByPath.get(e.path) !== e.changeType; });
|
|
609
|
+
}
|
|
610
|
+
|
|
581
611
|
// ── createTaskCommit (US-05-TASK-BE-03) ─────────────────────────────────────
|
|
582
612
|
//
|
|
583
613
|
// Creates the task commit for an already-persisted checkpoint intent
|
|
@@ -1358,5 +1388,6 @@ module.exports = {
|
|
|
1358
1388
|
stageTaskFiles,
|
|
1359
1389
|
createTaskCommit,
|
|
1360
1390
|
detectChangedPaths,
|
|
1391
|
+
excludePreExistingPaths,
|
|
1361
1392
|
integrateAttempt,
|
|
1362
1393
|
};
|
|
@@ -2009,6 +2009,13 @@ async function _runTaskToResolution(ctx) {
|
|
|
2009
2009
|
const taskId = task.id;
|
|
2010
2010
|
const agentId = agentRegistry.resolveWorkBreakdownAgentType(task.agentType).canonicalId;
|
|
2011
2011
|
|
|
2012
|
+
// Captured once per TASK (not per attempt, so a later retry still sees an
|
|
2013
|
+
// earlier attempt's own real work as part of THIS task, not as pre-existing
|
|
2014
|
+
// dirt) — see git.excludePreExistingPaths's own doc comment for the real
|
|
2015
|
+
// bug this guards against (unrelated uncommitted content, most commonly
|
|
2016
|
+
// this feature's own planning docs, bleeding into every task's review/commit).
|
|
2017
|
+
const taskBaselineChangedPaths = git.detectChangedPaths(projectDir);
|
|
2018
|
+
|
|
2012
2019
|
for (;;) {
|
|
2013
2020
|
let state = store.readState(executionRoot, runId);
|
|
2014
2021
|
state = state.setTaskStatus(taskId, 'active');
|
|
@@ -2102,7 +2109,7 @@ async function _runTaskToResolution(ctx) {
|
|
|
2102
2109
|
// Detected once per attempt (Gate-1 binding constraint #2's mandatory
|
|
2103
2110
|
// negative test relies on this: a self-reported "success" that wrote
|
|
2104
2111
|
// nothing real is caught here/by verification, never trusted alone).
|
|
2105
|
-
changedPaths = git.detectChangedPaths(projectDir);
|
|
2112
|
+
changedPaths = git.excludePreExistingPaths(taskBaselineChangedPaths, git.detectChangedPaths(projectDir));
|
|
2106
2113
|
const diff = _computeReviewDiff(projectDir, changedPaths);
|
|
2107
2114
|
review = await runReview({
|
|
2108
2115
|
executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
|
|
@@ -2814,19 +2821,7 @@ async function execute(args) {
|
|
|
2814
2821
|
'execute: could not derive a PREFIX (e.g. "FTR-018") from feature directory "' + featureDir + '"'
|
|
2815
2822
|
);
|
|
2816
2823
|
}
|
|
2817
|
-
const
|
|
2818
|
-
const csvPath = path.join(featureDir, prefix + '-Work-Breakdown.csv');
|
|
2819
|
-
let md;
|
|
2820
|
-
let csv;
|
|
2821
|
-
try {
|
|
2822
|
-
md = fs.readFileSync(mdPath, 'utf8');
|
|
2823
|
-
csv = fs.readFileSync(csvPath, 'utf8');
|
|
2824
|
-
} catch (err) {
|
|
2825
|
-
throw _err(
|
|
2826
|
-
'PLAN_NOT_FOUND',
|
|
2827
|
-
'execute: Work Breakdown MD/CSV not found at "' + mdPath + '" / "' + csvPath + '": ' + err.message
|
|
2828
|
-
);
|
|
2829
|
-
}
|
|
2824
|
+
const { md, csv } = _loadPlanDocs(featureDir, prefix);
|
|
2830
2825
|
// Propagates PLAN_PARSE_ERROR unmodified — a malformed/unapproved plan
|
|
2831
2826
|
// never silently starts a run.
|
|
2832
2827
|
const snapshot = plan.createPlanSnapshot(md, csv);
|
|
@@ -3799,6 +3794,50 @@ function _derivePrefix(featureDir) {
|
|
|
3799
3794
|
return m ? m[1] : null;
|
|
3800
3795
|
}
|
|
3801
3796
|
|
|
3797
|
+
// Loads this feature's plan source as MD/CSV text, trying the legacy rendered
|
|
3798
|
+
// view first and falling back to an in-memory render of the split wbs/
|
|
3799
|
+
// directory (schema v3) pm-phase2.js writes today. FTR-025 stopped writing
|
|
3800
|
+
// {PREFIX}-Work-Breakdown.md/.csv automatically — "nothing downstream ever
|
|
3801
|
+
// read" them, per that commit's own comment in pm-phase2.js — but this
|
|
3802
|
+
// executor was never updated to match, so the documented pm-phase1 -> Gate 1
|
|
3803
|
+
// -> pm-phase2 -> Gate 2 -> executor start flow threw PLAN_NOT_FOUND on
|
|
3804
|
+
// every feature using the current format (reported against a real
|
|
3805
|
+
// Fincantieri.ISDM.JobScheduler run).
|
|
3806
|
+
//
|
|
3807
|
+
// The render happens in-process (wb-render.js is required directly, not
|
|
3808
|
+
// shelled out to) and is never written to disk here — this function only
|
|
3809
|
+
// needs the MD/CSV text to feed plan.createPlanSnapshot unchanged; persisting
|
|
3810
|
+
// that view, if ever wanted, stays assemble-docs.js's job, same as today.
|
|
3811
|
+
function _loadPlanDocs(featureDir, prefix, errorCode, callerLabel) {
|
|
3812
|
+
const code = errorCode || 'PLAN_NOT_FOUND';
|
|
3813
|
+
const label = callerLabel || 'execute';
|
|
3814
|
+
const mdPath = path.join(featureDir, prefix + '-Work-Breakdown.md');
|
|
3815
|
+
const csvPath = path.join(featureDir, prefix + '-Work-Breakdown.csv');
|
|
3816
|
+
try {
|
|
3817
|
+
return { md: fs.readFileSync(mdPath, 'utf8'), csv: fs.readFileSync(csvPath, 'utf8') };
|
|
3818
|
+
} catch (legacyErr) {
|
|
3819
|
+
const wbsDir = path.join(featureDir, 'wbs');
|
|
3820
|
+
const indexPath = path.join(wbsDir, 'index.json');
|
|
3821
|
+
if (!fs.existsSync(indexPath)) {
|
|
3822
|
+
throw _err(
|
|
3823
|
+
code,
|
|
3824
|
+
label + ': Work Breakdown not found — tried legacy "' + mdPath + '" / "' + csvPath +
|
|
3825
|
+
'" (' + legacyErr.message + ') and split "' + indexPath + '" (does not exist)'
|
|
3826
|
+
);
|
|
3827
|
+
}
|
|
3828
|
+
const { loadWorkBreakdown } = require('../../src/claude/scripts/wbs-assembler');
|
|
3829
|
+
const { computePhaseDependsOn, renderMarkdown, renderCsv } = require('../../src/claude/scripts/wb-render');
|
|
3830
|
+
let wb;
|
|
3831
|
+
try {
|
|
3832
|
+
wb = loadWorkBreakdown(wbsDir);
|
|
3833
|
+
} catch (err) {
|
|
3834
|
+
throw _err(code, label + ': failed to load split Work Breakdown "' + wbsDir + '": ' + err.message);
|
|
3835
|
+
}
|
|
3836
|
+
const phaseDepsMap = computePhaseDependsOn(wb);
|
|
3837
|
+
return { md: renderMarkdown(wb, prefix, phaseDepsMap), csv: renderCsv(wb, prefix, phaseDepsMap) };
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3802
3841
|
// Minimal, deliberately non-generic Markdown check: does a case-sensitive
|
|
3803
3842
|
// "## Gate 2" heading appear anywhere in the successor's Approvals.md?
|
|
3804
3843
|
//
|
|
@@ -4025,20 +4064,9 @@ async function replan(args) {
|
|
|
4025
4064
|
// 2. Successor plan snapshot — real Work-Breakdown.md/.csv on disk, same
|
|
4026
4065
|
// parser pipeline every other run uses (plan.createPlanSnapshot). Its own
|
|
4027
4066
|
// PLAN_PARSE_ERROR propagates unmodified for a malformed successor plan.
|
|
4028
|
-
const
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
let successorCsv;
|
|
4032
|
-
try {
|
|
4033
|
-
successorMd = fs.readFileSync(successorMdPath, 'utf8');
|
|
4034
|
-
successorCsv = fs.readFileSync(successorCsvPath, 'utf8');
|
|
4035
|
-
} catch (err) {
|
|
4036
|
-
throw _err(
|
|
4037
|
-
'SUCCESSOR_PLAN_NOT_FOUND',
|
|
4038
|
-
'replan: successor Work Breakdown MD/CSV not found at "' + successorMdPath + '" / "' +
|
|
4039
|
-
successorCsvPath + '": ' + err.message
|
|
4040
|
-
);
|
|
4041
|
-
}
|
|
4067
|
+
const { md: successorMd, csv: successorCsv } = _loadPlanDocs(
|
|
4068
|
+
successorFeatureDir, successorPrefix, 'SUCCESSOR_PLAN_NOT_FOUND', 'replan'
|
|
4069
|
+
);
|
|
4042
4070
|
const successorSnapshot = plan.createPlanSnapshot(successorMd, successorCsv);
|
|
4043
4071
|
const successorTaskIds = new Set(successorSnapshot.tasks.map(function (t) { return t.id; }));
|
|
4044
4072
|
|
|
@@ -4060,7 +4088,7 @@ async function replan(args) {
|
|
|
4060
4088
|
throw _err(
|
|
4061
4089
|
'REPLAN_UNKNOWN_TASK_MAPPING',
|
|
4062
4090
|
'replan: taskMapping references newTaskId "' + entry.newTaskId +
|
|
4063
|
-
'", which does not exist in the successor plan snapshot (' +
|
|
4091
|
+
'", which does not exist in the successor plan snapshot (' + successorFeatureDir + ')'
|
|
4064
4092
|
);
|
|
4065
4093
|
}
|
|
4066
4094
|
});
|
|
@@ -44,6 +44,7 @@ const os = require('os');
|
|
|
44
44
|
const path = require('path');
|
|
45
45
|
const crypto = require('crypto');
|
|
46
46
|
const { spawnSync } = require('child_process');
|
|
47
|
+
const { renameSyncWithRetry } = require('../atomic-rename');
|
|
47
48
|
|
|
48
49
|
function _err(code, message) {
|
|
49
50
|
const e = new Error(message);
|
|
@@ -127,8 +128,10 @@ function _atomicReplaceFileSync(filePath, contents) {
|
|
|
127
128
|
throw err;
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// renameSyncWithRetry (lib/atomic-rename.js): absorbs a transient Windows EPERM/EBUSY on rename
|
|
132
|
+
// (docs/task-executor-bootstrap.md section 4b) — a genuinely permanent failure still throws.
|
|
130
133
|
try {
|
|
131
|
-
|
|
134
|
+
renameSyncWithRetry(tmpPath, filePath);
|
|
132
135
|
} catch (err) {
|
|
133
136
|
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
134
137
|
throw err;
|
|
@@ -59,7 +59,7 @@ const _DASH = '—'; // '—' — the placeholder wb-render.js emits for absent
|
|
|
59
59
|
const _FIELD_LINE_RE = /^- \*\*(.+?):\*\*[ \t]?(.*)$/gm;
|
|
60
60
|
const _FENCE_RE = /^(`{3,})\r?\n([\s\S]*?)\r?\n\1[ \t]*$/gm;
|
|
61
61
|
const _NO_VERIF_RE = /_No verification commands\._/;
|
|
62
|
-
const _TASK_ANCHOR_RE = /<a id="task-[^"]*"><\/a>\n### ([^\n]+)\n([\s\S]*?)(?=\n<a id="task-|$)/g;
|
|
62
|
+
const _TASK_ANCHOR_RE = /<a id="task-[^"]*"><\/a>\r?\n### ([^\r\n]+)\r?\n([\s\S]*?)(?=\r?\n<a id="task-|$)/g;
|
|
63
63
|
|
|
64
64
|
function _listOrEmpty(value) {
|
|
65
65
|
if (value === _DASH) return [];
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const crypto = require('crypto');
|
|
23
|
+
const { renameSyncWithRetry } = require('../atomic-rename');
|
|
23
24
|
|
|
24
25
|
// ── Schema vocabulary ────────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -126,8 +127,12 @@ function _atomicWriteFileSync(filePath, contents) {
|
|
|
126
127
|
throw err;
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
// renameSyncWithRetry (lib/atomic-rename.js): Windows can transiently reject this rename with
|
|
131
|
+
// EPERM/EBUSY under concurrent I/O load — confirmed to recur on a real `executor start` run, not
|
|
132
|
+
// just under heavy test-suite load (docs/task-executor-bootstrap.md section 4b). A bounded retry
|
|
133
|
+
// absorbs the transient case; a genuinely permanent failure still throws, unchanged.
|
|
129
134
|
try {
|
|
130
|
-
|
|
135
|
+
renameSyncWithRetry(tmpPath, filePath);
|
|
131
136
|
} catch (err) {
|
|
132
137
|
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
133
138
|
throw err;
|
package/lib/workflow-control.js
CHANGED
|
@@ -10,6 +10,7 @@ const ledger = require('./execution-ledger');
|
|
|
10
10
|
const { spawnClaudeAgent } = require('./task-executor/claude-process');
|
|
11
11
|
const { createEvidence } = require('./workflow-worker-evidence');
|
|
12
12
|
const { resolveOutputPath } = require('./workflow-worker-outputs');
|
|
13
|
+
const { renameSyncWithRetry } = require('./atomic-rename');
|
|
13
14
|
const CLI = path.join(__dirname, '..', 'bin', 'cli.js');
|
|
14
15
|
|
|
15
16
|
|
|
@@ -89,7 +90,8 @@ function createControl(options) {
|
|
|
89
90
|
const dest = full(p);
|
|
90
91
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
91
92
|
const tmp = dest + '.tmp-' + crypto.randomUUID();
|
|
92
|
-
|
|
93
|
+
// renameSyncWithRetry (./atomic-rename): absorbs a transient Windows EPERM/EBUSY on rename.
|
|
94
|
+
try { fs.writeFileSync(tmp, content, 'utf8'); renameSyncWithRetry(tmp, dest); }
|
|
93
95
|
finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }
|
|
94
96
|
};
|
|
95
97
|
// Validates a worker-declared (or, for a dynamic multi-file deliverable like
|
|
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const { renameSyncWithRetry } = require('./atomic-rename');
|
|
7
8
|
|
|
8
9
|
// Do not capture the parent environment or stdin prompt. Diagnostic responses
|
|
9
10
|
// can contain document text; redact common credential forms before persistence.
|
|
@@ -43,7 +44,8 @@ function createEvidence(options, runId, identity, invocation) {
|
|
|
43
44
|
const temp = file + '.tmp';
|
|
44
45
|
try {
|
|
45
46
|
fs.writeFileSync(temp, JSON.stringify(redact(record), null, 2) + '\n', { mode: 0o600 });
|
|
46
|
-
|
|
47
|
+
// renameSyncWithRetry (./atomic-rename): absorbs a transient Windows EPERM/EBUSY on rename.
|
|
48
|
+
renameSyncWithRetry(temp, file);
|
|
47
49
|
} finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
|
|
48
50
|
}
|
|
49
51
|
save({});
|
package/package.json
CHANGED
|
@@ -4,49 +4,6 @@ const fs = require('fs')
|
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const { loadWorkBreakdown } = require('./wbs-assembler')
|
|
6
6
|
|
|
7
|
-
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
const jsonPath = process.argv[2]
|
|
10
|
-
const prefix = process.argv[3]
|
|
11
|
-
|
|
12
|
-
if (!jsonPath || !prefix) {
|
|
13
|
-
process.stderr.write('Usage: node wb-render.js <path-to-work-breakdown.json> <feature-prefix> [destination-dir]\n')
|
|
14
|
-
process.exit(1)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const destDir = process.argv[4]
|
|
18
|
-
? process.argv[4]
|
|
19
|
-
: path.dirname(path.resolve(jsonPath))
|
|
20
|
-
|
|
21
|
-
// ── Read and assemble ──────────────────────────────────────────────────────────
|
|
22
|
-
// Accepts either a single legacy JSON file (schema v2) or a {PREFIX}-wbs/ split
|
|
23
|
-
// directory (schema v3), returning the same in-memory shape either way (FTR-020).
|
|
24
|
-
|
|
25
|
-
let wb
|
|
26
|
-
try {
|
|
27
|
-
wb = loadWorkBreakdown(jsonPath)
|
|
28
|
-
} catch (err) {
|
|
29
|
-
process.stderr.write(`Error: ${err.message}\n`)
|
|
30
|
-
process.exit(1)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── Basic structure check ─────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
if (wb.schemaVersion !== 2) {
|
|
36
|
-
process.stderr.write(`Error: schemaVersion must be 2, got ${JSON.stringify(wb.schemaVersion)}\n`)
|
|
37
|
-
process.exit(2)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
if (!Array.isArray(wb.phases)) {
|
|
41
|
-
process.stderr.write('Error: "phases" must be an array\n')
|
|
42
|
-
process.exit(2)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── Output file paths ─────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
const mdPath = path.join(destDir, `${prefix}-Work-Breakdown.md`)
|
|
48
|
-
const csvPath = path.join(destDir, `${prefix}-Work-Breakdown.csv`)
|
|
49
|
-
|
|
50
7
|
// ── Phase-level depends_on aggregation ───────────────────────────────────────
|
|
51
8
|
|
|
52
9
|
function computePhaseDependsOn(wb) {
|
|
@@ -79,16 +36,6 @@ function computePhaseDependsOn(wb) {
|
|
|
79
36
|
return result
|
|
80
37
|
}
|
|
81
38
|
|
|
82
|
-
// ── Compute phase dependencies ────────────────────────────────────────────────
|
|
83
|
-
|
|
84
|
-
let phaseDepsMap
|
|
85
|
-
try {
|
|
86
|
-
phaseDepsMap = computePhaseDependsOn(wb)
|
|
87
|
-
} catch (err) {
|
|
88
|
-
process.stderr.write(`Error: phase dependency aggregation failed: ${err.message}\n`)
|
|
89
|
-
process.exit(3)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
39
|
// ── Field helpers ─────────────────────────────────────────────────────────────
|
|
93
40
|
|
|
94
41
|
function sanitizeField(str) {
|
|
@@ -393,11 +340,66 @@ function renderCsv(wb, prefix, phaseDepsMap) {
|
|
|
393
340
|
return rows.join('\n') + '\n'
|
|
394
341
|
}
|
|
395
342
|
|
|
396
|
-
|
|
343
|
+
module.exports = {
|
|
344
|
+
computePhaseDependsOn,
|
|
345
|
+
renderMarkdown,
|
|
346
|
+
renderCsv,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── CLI entry point ───────────────────────────────────────────────────────────
|
|
350
|
+
// Same require.main guard pattern as wbs-assembler.js: the functions above are
|
|
351
|
+
// requirable in-process (lib/task-executor/index.js does exactly this, falling
|
|
352
|
+
// back to an in-memory render of the split wbs/ directory when the legacy
|
|
353
|
+
// {PREFIX}-Work-Breakdown.md/.csv files pm-phase2 no longer writes automatically
|
|
354
|
+
// — FTR-025 — are absent), while this block only runs for the standalone CLI use
|
|
355
|
+
// pm-phase2.js's workflow-script runtime (no `require`) and operators still rely on.
|
|
356
|
+
if (require.main === module) {
|
|
357
|
+
const jsonPath = process.argv[2]
|
|
358
|
+
const prefix = process.argv[3]
|
|
359
|
+
|
|
360
|
+
if (!jsonPath || !prefix) {
|
|
361
|
+
process.stderr.write('Usage: node wb-render.js <path-to-work-breakdown.json> <feature-prefix> [destination-dir]\n')
|
|
362
|
+
process.exit(1)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const destDir = process.argv[4]
|
|
366
|
+
? process.argv[4]
|
|
367
|
+
: path.dirname(path.resolve(jsonPath))
|
|
368
|
+
|
|
369
|
+
// Accepts either a single legacy JSON file (schema v2) or a {PREFIX}-wbs/ split
|
|
370
|
+
// directory (schema v3), returning the same in-memory shape either way (FTR-020).
|
|
371
|
+
let wb
|
|
372
|
+
try {
|
|
373
|
+
wb = loadWorkBreakdown(jsonPath)
|
|
374
|
+
} catch (err) {
|
|
375
|
+
process.stderr.write(`Error: ${err.message}\n`)
|
|
376
|
+
process.exit(1)
|
|
377
|
+
}
|
|
397
378
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
process.
|
|
379
|
+
if (wb.schemaVersion !== 2) {
|
|
380
|
+
process.stderr.write(`Error: schemaVersion must be 2, got ${JSON.stringify(wb.schemaVersion)}\n`)
|
|
381
|
+
process.exit(2)
|
|
382
|
+
}
|
|
383
|
+
if (!Array.isArray(wb.phases)) {
|
|
384
|
+
process.stderr.write('Error: "phases" must be an array\n')
|
|
385
|
+
process.exit(2)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const mdPath = path.join(destDir, `${prefix}-Work-Breakdown.md`)
|
|
389
|
+
const csvPath = path.join(destDir, `${prefix}-Work-Breakdown.csv`)
|
|
390
|
+
|
|
391
|
+
let phaseDepsMap
|
|
392
|
+
try {
|
|
393
|
+
phaseDepsMap = computePhaseDependsOn(wb)
|
|
394
|
+
} catch (err) {
|
|
395
|
+
process.stderr.write(`Error: phase dependency aggregation failed: ${err.message}\n`)
|
|
396
|
+
process.exit(3)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const md = renderMarkdown(wb, prefix, phaseDepsMap)
|
|
400
|
+
const csv = renderCsv(wb, prefix, phaseDepsMap)
|
|
401
|
+
fs.writeFileSync(mdPath, md, 'utf8')
|
|
402
|
+
fs.writeFileSync(csvPath, csv, 'utf8')
|
|
403
|
+
process.stdout.write(JSON.stringify({ exitCode: 0, markdownPath: mdPath, csvPath }) + '\n')
|
|
404
|
+
process.exit(0)
|
|
405
|
+
}
|
|
@@ -221,6 +221,27 @@ If the branch already exists, switch to it without recreating:
|
|
|
221
221
|
git checkout feature/{PREFIX}-{short-slug}
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
+
### Step 5a — Commit the approved planning documents
|
|
225
|
+
|
|
226
|
+
The task executor (Step 6) dispatches each task directly against this project directory and
|
|
227
|
+
reviews/commits exactly what `git status` reports changed. If the planning documents this
|
|
228
|
+
pipeline just produced (`<feature-dir>/` — feature.md, req/, spec/, wbs/, coverage-matrix/,
|
|
229
|
+
`{PREFIX}-Approvals.md`, `{PREFIX}-Validation-Report.md`) are still sitting uncommitted at this
|
|
230
|
+
point, every task's own review prompt and commit would be contaminated by them — reproduced on a
|
|
231
|
+
real run (hundreds of KB of unrelated planning docs bleeding into a single task's own small diff,
|
|
232
|
+
"Prompt is too long"). Commit them now, on the feature branch just created above, scoped to the
|
|
233
|
+
feature's own directory only:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
git add <feature-dir>
|
|
237
|
+
git commit -m "docs({PREFIX}): commit approved Requirements/Tech-Spec/Work Breakdown"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Skip this commit if `git status --porcelain <feature-dir>` reports nothing (e.g. a resumed run
|
|
241
|
+
where the docs were already committed in a prior pass) — do not fail or retry, an empty commit is
|
|
242
|
+
never created. `<feature-dir>` is the directory containing feature.md (same directory derived in
|
|
243
|
+
Step 0).
|
|
244
|
+
|
|
224
245
|
---
|
|
225
246
|
|
|
226
247
|
## Step 6 — Launch the task executor (Implementation Phase)
|
|
@@ -308,6 +308,20 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
308
308
|
if (!acIds.has(ref)) findings.push(`Tech-Spec: component ${comp.filePath} references nonexistent ${ref}`)
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
|
+
// FTR-030: a File-Inventory "Addresses" column only fits a positive, per-file deliverable — an
|
|
312
|
+
// AC describing a negative/process constraint (e.g. "no files under _old/ were modified",
|
|
313
|
+
// verified via git diff, never a dedicated component) can never be addressed that way, and was
|
|
314
|
+
// confirmed as a real, permanent false-positive orphan finding (no amount of regeneration could
|
|
315
|
+
// ever resolve it, since no component SHOULD exist for it). Section 12 (Risks & Mitigations) is
|
|
316
|
+
// the Tech-Spec's own existing, real place such constraints are already documented (confirmed
|
|
317
|
+
// against a real Tech-Spec) — an AC-ID mentioned there by its literal ID counts as addressed.
|
|
318
|
+
const specLines = specIndexContent.split('\n')
|
|
319
|
+
const { start: risksStart, end: risksEnd } = findReqSection(specLines, 12)
|
|
320
|
+
const risksText = risksStart === -1 ? '' : specLines.slice(risksStart, risksEnd).join('\n')
|
|
321
|
+
for (const id of acIds) {
|
|
322
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
323
|
+
if (new RegExp(`\\b${escapedId}\\b`).test(risksText)) addressedAcIds.add(id)
|
|
324
|
+
}
|
|
311
325
|
for (const id of acIds) {
|
|
312
326
|
if (!addressedAcIds.has(id)) findings.push(`Tech-Spec: ${id} is not addressed by any Tech-Spec component`)
|
|
313
327
|
}
|
|
@@ -424,6 +438,24 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
424
438
|
(!fresh(specIndexPath) || missingSections(c.read(specIndexPath), 12).length > 0)
|
|
425
439
|
const regenSkeleton = forceAll || skeletonStale || !c.exists(specIndexPath)
|
|
426
440
|
|
|
441
|
+
// FTR-030: capture the OLD component rows (keyed by fileSlug — the stable per-component
|
|
442
|
+
// identity, a Spec-link target) BEFORE the skeleton is overwritten, so a NATURALLY-triggered
|
|
443
|
+
// skeleton regen (staleness/missing, never an explicit forceAll) doesn't force EVERY component
|
|
444
|
+
// to redispatch when only a FEW rows actually changed. Real incident: hand-editing 2 of ~58
|
|
445
|
+
// rows (adding a missing AC reference) triggered a full skeleton regeneration, which — with no
|
|
446
|
+
// per-row comparison — forced ALL ~88 components to redispatch even though the vast majority
|
|
447
|
+
// of rows were byte-identical to before. Deliberately NOT captured when forceAll is true: an
|
|
448
|
+
// explicit full regen (a `--force` run, or a needsTechSpecFix revision cycle) means something
|
|
449
|
+
// was flagged wrong that per-row diffing cannot see (e.g. a missing cross-reference only
|
|
450
|
+
// visible in a component's own body) — that path keeps today's existing "regenerate
|
|
451
|
+
// everything unconditionally" behavior, unchanged.
|
|
452
|
+
const oldComponentsByFileSlug = new Map()
|
|
453
|
+
if (!forceAll && regenSkeleton && c.exists(specIndexPath)) {
|
|
454
|
+
try {
|
|
455
|
+
for (const comp of parseComponentsTable(c.read(specIndexPath))) oldComponentsByFileSlug.set(comp.fileSlug, comp)
|
|
456
|
+
} catch (e) { /* an unparseable old skeleton has nothing worth diffing against — treat as empty */ }
|
|
457
|
+
}
|
|
458
|
+
|
|
427
459
|
// 1. Skeleton (no declared worker() output — host writes it itself)
|
|
428
460
|
if (regenSkeleton) {
|
|
429
461
|
const skeleton = await c.worker(specId, withTemplate(featurePath, 'spec/index.md', 'gaia-tech-spec-index-template.md'))
|
|
@@ -449,7 +481,16 @@ return c.run(meta.name, featureDir, prefix, async () => {
|
|
|
449
481
|
const componentTemplate = components.length ? readTemplate('gaia-component-template.md') : null
|
|
450
482
|
for (const comp of components) {
|
|
451
483
|
const compPath = `${specDir}/components/${comp.fileSlug}`
|
|
452
|
-
|
|
484
|
+
// FTR-030: even when the skeleton itself was regenerated, a component whose OWN row is
|
|
485
|
+
// byte-identical to its prior version doesn't need a new design call — only a row that is
|
|
486
|
+
// NEW or actually CHANGED does. `regenSkeleton` alone used to force every component to
|
|
487
|
+
// redispatch unconditionally the moment the skeleton changed at all; this narrows that to
|
|
488
|
+
// rows that genuinely changed, while still regenerating any row that's new or different.
|
|
489
|
+
const oldComp = oldComponentsByFileSlug.get(comp.fileSlug)
|
|
490
|
+
const rowUnchanged = !!oldComp && oldComp.filePath === comp.filePath &&
|
|
491
|
+
oldComp.changeType === comp.changeType && oldComp.purposeOrChange === comp.purposeOrChange &&
|
|
492
|
+
oldComp.addresses === comp.addresses && oldComp.domain === comp.domain
|
|
493
|
+
if ((!regenSkeleton || rowUnchanged) && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
|
|
453
494
|
// FTR-029/UC-04: validate immediately, before writing to disk and before moving to the next
|
|
454
495
|
// component — a bad result otherwise silently continued through every remaining component,
|
|
455
496
|
// only discovered (expensively) at final assembly or a later invocation's resume check.
|
|
@@ -130,7 +130,13 @@ function renderPhaseDetailBlock(phase, taskTitles, dependsOnPhases) {
|
|
|
130
130
|
? taskTitles.map(t => `- \`${t.id}\` — ${t.title}`).join('\n')
|
|
131
131
|
: '_No tasks._'
|
|
132
132
|
const dependsOn = dependsOnPhases.length ? dependsOnPhases.join(', ') : 'none'
|
|
133
|
-
|
|
133
|
+
// The template lives under the toolkit's own installed scripts/templates/ — outside the project
|
|
134
|
+
// for a globally-installed toolkit, which a plain c.read() has no way to resolve (it only reads
|
|
135
|
+
// project-relative paths). Read via print-asset instead, same as gaia-task-template.md below —
|
|
136
|
+
// this was previously a bare c.read() of a local-install-relative path, which only worked when
|
|
137
|
+
// the toolkit happened to be installed locally into this project's own .claude/ folder (a real
|
|
138
|
+
// incident: a project with only a global install failed with ENOENT on this exact line).
|
|
139
|
+
let text = _stripTemplateComments(c.command(['print-asset', 'scripts/templates/gaia-user-story-template.md', '--project', c.root]).stdout)
|
|
134
140
|
text = text.replace(/\{PHASE_ID\}/g, phase.id)
|
|
135
141
|
text = _fillFieldBlock(text, {
|
|
136
142
|
'Phase ID': phase.id,
|