@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 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 };
@@ -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/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 {
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 lastErr;
90
+ throw err;
100
91
  }
101
92
  _fsyncDirSync(dir);
102
93
  }
@@ -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 mdPath = path.join(featureDir, prefix + '-Work-Breakdown.md');
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 successorMdPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.md');
4029
- const successorCsvPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.csv');
4030
- let successorMd;
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 (' + successorMdPath + ')'
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
- fs.renameSync(tmpPath, filePath);
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
- fs.renameSync(tmpPath, filePath);
135
+ renameSyncWithRetry(tmpPath, filePath);
131
136
  } catch (err) {
132
137
  try { fs.unlinkSync(tmpPath); } catch (_) {}
133
138
  throw err;
@@ -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
- try { fs.writeFileSync(tmp, content, 'utf8'); fs.renameSync(tmp, dest); }
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
- fs.renameSync(temp, file);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dtlabs/ai-toolkit",
3
- "version": "0.21.1",
3
+ "version": "0.22.0",
4
4
  "description": "AI Toolkit — reusable agents and procedures for developers to build AI-powered applications.",
5
5
  "files": [
6
6
  "docs/",
@@ -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
- // ── Write files and exit ──────────────────────────────────────────────────────
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
- const md = renderMarkdown(wb, prefix, phaseDepsMap)
399
- const csv = renderCsv(wb, prefix, phaseDepsMap)
400
- fs.writeFileSync(mdPath, md, 'utf8')
401
- fs.writeFileSync(csvPath, csv, 'utf8')
402
- process.stdout.write(JSON.stringify({ exitCode: 0, markdownPath: mdPath, csvPath }) + '\n')
403
- process.exit(0)
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
- if (!regenSkeleton && c.exists(compPath) && isValidComponentDetail(c.read(compPath))) continue // resume: skip, no dispatch
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
- let text = _stripTemplateComments(c.read('.claude/scripts/templates/gaia-user-story-template.md'))
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,