jules-orchestrator-kit 0.32.4 → 0.32.6

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.
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Documentation / version consistency gate.
5
+ *
6
+ * Implements the `doc-sync-sentinel` preset advertised in src/wizard-init.mjs:
7
+ * asserts that package.json, bin/agentctl.mjs, README.md, ROADMAP_V1.md and
8
+ * CHANGELOG.md all agree on the current version, and that README's advertised
9
+ * test counts match what the suite actually reports.
10
+ *
11
+ * Runs as a blocking step in scripts/release.mjs. Standalone usage:
12
+ * node scripts/doc-sync-check.mjs # runs the suite for counts
13
+ * node scripts/doc-sync-check.mjs --tests 429 --suites 59
14
+ * node scripts/doc-sync-check.mjs --json
15
+ *
16
+ * Exit codes: 0 = in sync, 1 = drift detected.
17
+ */
18
+
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { execSync } from "node:child_process";
22
+ import { resolveRoot } from "../src/config.mjs";
23
+
24
+ /**
25
+ * Runs the unit suite and extracts the authoritative counts.
26
+ *
27
+ * README advertises *passing* tests, so `pass` — not `tests` — is the number
28
+ * the documentation claim is compared against. The two diverge as soon as the
29
+ * adversarial suite records a `todo` probe for a known gap.
30
+ *
31
+ * @param {string} root
32
+ * @returns {{ tests: number|null, pass: number|null, suites: number|null, todo: number|null }}
33
+ */
34
+ export function measureTestCounts(root = process.cwd()) {
35
+ let out = "";
36
+ try {
37
+ out = execSync("npm test", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
38
+ } catch (err) {
39
+ // A failing suite still prints its summary; parse whatever we got.
40
+ out = `${err.stdout || ""}${err.stderr || ""}`;
41
+ }
42
+ return parseTestCounts(out);
43
+ }
44
+
45
+ /**
46
+ * Parses node:test summary counters out of an already-captured run.
47
+ * Accepts both the spec reporter ("ℹ tests 452") and tap ("# tests 452").
48
+ * @param {string} out
49
+ * @returns {{ tests: number|null, pass: number|null, suites: number|null, todo: number|null }}
50
+ */
51
+ export function parseTestCounts(out = "") {
52
+ const num = (key) => {
53
+ const m = String(out).match(new RegExp(`^[^\\n]*?(?:ℹ|#)\\s*${key}\\s+(\\d+)\\s*$`, "m"));
54
+ return m ? Number(m[1]) : null;
55
+ };
56
+ return { tests: num("tests"), pass: num("pass"), suites: num("suites"), todo: num("todo") };
57
+ }
58
+
59
+ function readIfExists(path) {
60
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
61
+ }
62
+
63
+ /**
64
+ * Verifies documentation is in sync with package.json and the real test counts.
65
+ * @param {string} [root]
66
+ * @param {object} [opts]
67
+ * @param {number} [opts.tests] Actual passing test count (measured if omitted).
68
+ * @param {number} [opts.suites] Actual suite count (measured if omitted).
69
+ * @returns {{ ok: boolean, version: string, checks: Array<{name: string, ok: boolean, detail: string}> }}
70
+ */
71
+ export function checkDocSync(root = process.cwd(), opts = {}) {
72
+ const checks = [];
73
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
74
+
75
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
76
+ const version = pkg.version;
77
+
78
+ // 1. bin/agentctl.mjs — VERSION const, help banner, `version` command output.
79
+ const cli = readIfExists(join(root, "bin", "agentctl.mjs"));
80
+ if (cli === null) {
81
+ add("agentctl present", false, "bin/agentctl.mjs not found");
82
+ } else {
83
+ const constMatch = cli.match(/export const VERSION\s*=\s*"([^"]+)"/);
84
+ add(
85
+ "agentctl VERSION const",
86
+ constMatch?.[1] === version,
87
+ constMatch ? `found "${constMatch[1]}", expected "${version}"` : "no `export const VERSION` found"
88
+ );
89
+
90
+ const stale = [...cli.matchAll(/agentctl v(\d+\.\d+\.\d+)/g)].map((m) => m[1]).filter((v) => v !== version);
91
+ add(
92
+ "agentctl banner/version strings",
93
+ stale.length === 0,
94
+ stale.length ? `stale version string(s): ${[...new Set(stale)].join(", ")}` : `all reference v${version}`
95
+ );
96
+ }
97
+
98
+ // 2. ROADMAP_V1.md — milestone header must name the shipped version, and no
99
+ // already-released version may still be labelled "(Unreleased)".
100
+ const roadmap = readIfExists(join(root, "ROADMAP_V1.md"));
101
+ if (roadmap === null) {
102
+ add("ROADMAP present", false, "ROADMAP_V1.md not found");
103
+ } else {
104
+ const current = roadmap.match(/v(\d+\.\d+\.\d+)\s*\(Current Stable\)/);
105
+ add(
106
+ "ROADMAP Current Stable",
107
+ current?.[1] === version,
108
+ current ? `found v${current[1]}, expected v${version}` : "no `(Current Stable)` marker found"
109
+ );
110
+
111
+ const shipped = roadmap.match(/Shipped Milestones\s*\(v[\d.]+\s*[–-]\s*v(\d+\.\d+\.\d+)\)/);
112
+ add(
113
+ "ROADMAP Shipped range",
114
+ shipped?.[1] === version,
115
+ shipped ? `ends at v${shipped[1]}, expected v${version}` : "no `Shipped Milestones (…)` range found"
116
+ );
117
+
118
+ const unreleased = [...roadmap.matchAll(/v(\d+\.\d+\.\d+)\s*\(Unreleased\)/g)]
119
+ .map((m) => m[1])
120
+ .filter((v) => compareSemver(v, version) <= 0);
121
+ add(
122
+ "ROADMAP no stale (Unreleased)",
123
+ unreleased.length === 0,
124
+ unreleased.length
125
+ ? `v${unreleased.join(", v")} marked (Unreleased) but <= shipped v${version}`
126
+ : "no released version left marked (Unreleased)"
127
+ );
128
+ }
129
+
130
+ // 3. CHANGELOG.md — a released version needs a matching entry (release.mjs
131
+ // extracts its notes from here, and silently falls back if it is missing).
132
+ const changelog = readIfExists(join(root, "CHANGELOG.md"));
133
+ if (changelog === null) {
134
+ add("CHANGELOG present", false, "CHANGELOG.md not found");
135
+ } else {
136
+ add(
137
+ "CHANGELOG entry",
138
+ changelog.includes(`## [${version}]`),
139
+ changelog.includes(`## [${version}]`) ? `## [${version}] found` : `no "## [${version}]" section`
140
+ );
141
+ }
142
+
143
+ // 4. README.md — advertised test counts and roadmap-table release labels.
144
+ const readme = readIfExists(join(root, "README.md"));
145
+ if (readme === null) {
146
+ add("README present", false, "README.md not found");
147
+ } else {
148
+ const claim = readme.match(/(\d+)\s+unit tests across\s+(\d+)\s+suites/);
149
+ const claimedTests = claim ? Number(claim[1]) : null;
150
+ const claimedSuites = claim ? Number(claim[2]) : null;
151
+
152
+ let { tests, suites } = opts;
153
+ if (tests === undefined || suites === undefined) {
154
+ const measured = measureTestCounts(root);
155
+ tests = tests ?? measured.pass;
156
+ suites = suites ?? measured.suites;
157
+ }
158
+
159
+ if (claimedTests === null) {
160
+ add("README test count", false, "no `N unit tests across M suites` claim found");
161
+ } else if (tests === null || suites === null) {
162
+ add("README test count", false, "could not measure actual test counts");
163
+ } else {
164
+ add(
165
+ "README test count",
166
+ claimedTests === tests && claimedSuites === suites,
167
+ `README claims ${claimedTests}/${claimedSuites} passing, suite reports ${tests}/${suites}`
168
+ );
169
+ }
170
+
171
+ const unreleasedRows = (readme.match(/\|\s*\*\*Unreleased\*\*\s*\*\(main\)\*\s*\|/g) || []).length;
172
+ add(
173
+ "README roadmap table labels",
174
+ unreleasedRows === 0,
175
+ unreleasedRows ? `${unreleasedRows} row(s) still labelled "Unreleased (main)"` : "no stale Unreleased rows"
176
+ );
177
+ }
178
+
179
+ // 5. Agent rule-file budgets. rules-lint has existed unwired, which is how
180
+ // AGENTS.md silently drifted past its 10k character budget — agent rule
181
+ // files are truncated by the model host, so an over-budget file loses
182
+ // directives from the end without any error surfacing.
183
+ if (opts.skipRulesLint !== true) {
184
+ try {
185
+ execSync("node scripts/rules-lint.mjs", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
186
+ add("agent rule budgets", true, "AGENTS.md and .agent/rules/ within char/line budgets");
187
+ } catch (err) {
188
+ const detail = `${err.stdout || ""}${err.stderr || ""}`
189
+ .split("\n")
190
+ .filter((l) => l.trim().startsWith("-"))
191
+ .map((l) => l.trim().replace(/^-\s*/, ""))
192
+ .join("; ");
193
+ add("agent rule budgets", false, detail || "rules-lint reported violations");
194
+ }
195
+ }
196
+
197
+ return { ok: checks.every((c) => c.ok), version, checks };
198
+ }
199
+
200
+ function compareSemver(a, b) {
201
+ const pa = a.split(".").map(Number);
202
+ const pb = b.split(".").map(Number);
203
+ for (let i = 0; i < 3; i++) {
204
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
205
+ }
206
+ return 0;
207
+ }
208
+
209
+ // ---- CLI ----
210
+ const isMain = process.argv[1] && process.argv[1].endsWith("doc-sync-check.mjs");
211
+ if (isMain) {
212
+ const argv = process.argv.slice(2);
213
+ const flag = (name) => {
214
+ const i = argv.indexOf(`--${name}`);
215
+ return i !== -1 && argv[i + 1] ? Number(argv[i + 1]) : undefined;
216
+ };
217
+
218
+ const root = resolveRoot();
219
+ const res = checkDocSync(root, { tests: flag("tests"), suites: flag("suites") });
220
+
221
+ if (argv.includes("--json")) {
222
+ console.log(JSON.stringify(res, null, 2));
223
+ } else {
224
+ console.log(`\n📐 Documentation Sync Gate (v${res.version})`);
225
+ console.log("-------------------------------------------------------");
226
+ for (const c of res.checks) {
227
+ console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(32)} ${c.detail}`);
228
+ }
229
+ console.log("-------------------------------------------------------");
230
+ console.log(res.ok ? "✅ Documentation is in sync.\n" : "❌ DOC SYNC GATE FAIL: documentation has drifted from package.json / test suite.\n");
231
+ }
232
+
233
+ process.exit(res.ok ? 0 : 1);
234
+ }
@@ -26,16 +26,37 @@ const tagName = `v${version}`;
26
26
  console.log(`🚀 Automated Release Pipeline for ${pkg.name} (${tagName})`);
27
27
  console.log("-------------------------------------------------------");
28
28
 
29
- // 1. Verify test suite
29
+ // 1. Verify test suite. Output is captured (not inherited) so the doc-sync gate
30
+ // below can reuse the counts instead of running the suite a second time.
30
31
  console.log("1. Running unit test verification suite...");
32
+ let testOutput = "";
31
33
  try {
32
- execSync("npm test", { cwd: root, stdio: "inherit" });
34
+ testOutput = execSync("npm test", { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
35
+ const summary = testOutput.match(/^[^\n]*?ℹ\s*(?:tests|suites|pass|fail|todo)\s+\d+\s*$/gm) || [];
36
+ summary.forEach((l) => console.log(` ${l.trim()}`));
33
37
  console.log(" ✅ Test suite passed cleanly.\n");
34
38
  } catch (err) {
39
+ console.error(`${err.stdout || ""}${err.stderr || ""}`);
35
40
  console.error("❌ Release Aborted: Test suite failed.");
36
41
  process.exit(1);
37
42
  }
38
43
 
44
+ // 1b. Documentation / version consistency gate (blocking).
45
+ console.log("1b. Verifying documentation is in sync with package.json & test suite...");
46
+ {
47
+ const { checkDocSync, parseTestCounts } = await import("./doc-sync-check.mjs");
48
+ const counts = parseTestCounts(testOutput);
49
+ const docRes = checkDocSync(root, { tests: counts.pass, suites: counts.suites });
50
+ for (const c of docRes.checks) {
51
+ console.log(` ${c.ok ? "✅" : "❌"} ${c.name.padEnd(32)} ${c.detail}`);
52
+ }
53
+ if (!docRes.ok) {
54
+ console.error("\n❌ Release Aborted: documentation has drifted. Fix the ❌ rows above and re-run.");
55
+ process.exit(1);
56
+ }
57
+ console.log(" ✅ Documentation is in sync.\n");
58
+ }
59
+
39
60
  // 2. Extract release notes from CHANGELOG.md
40
61
  console.log(`2. Extracting release notes for ${tagName} from CHANGELOG.md...`);
41
62
  let notes = "";
package/src/config.mjs CHANGED
@@ -68,6 +68,41 @@ export function normalizePath(p) {
68
68
  return p.split(sep).join("/").replace(/\\/g, "/");
69
69
  }
70
70
 
71
+ /**
72
+ * Reduces a repo-relative path to a canonical form for pattern matching:
73
+ * separators normalised, duplicate slashes collapsed, `.` segments dropped,
74
+ * `..` segments resolved, and any leading `./` or trailing `/` removed.
75
+ *
76
+ * Purely lexical — it never touches the filesystem, because the paths being
77
+ * matched may not exist locally (they can come from a diff or a task envelope).
78
+ * Leading `..` segments that would escape the repo root are preserved so the
79
+ * caller can still recognise and reject them.
80
+ *
81
+ * @param {string} p
82
+ * @returns {string}
83
+ */
84
+ export function canonicalizePath(p) {
85
+ const normalized = normalizePath(p).replace(/\/+/g, "/");
86
+ if (!normalized) return "";
87
+
88
+ const isAbsolutePosix = normalized.startsWith("/");
89
+ const out = [];
90
+ for (const segment of normalized.split("/")) {
91
+ if (segment === "" || segment === ".") continue;
92
+ if (segment === "..") {
93
+ if (out.length > 0 && out[out.length - 1] !== "..") {
94
+ out.pop();
95
+ } else if (!isAbsolutePosix) {
96
+ out.push("..");
97
+ }
98
+ continue;
99
+ }
100
+ out.push(segment);
101
+ }
102
+
103
+ return (isAbsolutePosix ? "/" : "") + out.join("/");
104
+ }
105
+
71
106
  export function resolveRoot(cwd = process.cwd()) {
72
107
  try {
73
108
  return execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -214,9 +249,19 @@ export function resolveVerify(root = process.cwd(), userVerify = {}) {
214
249
  const s = detectStack(root);
215
250
  return {
216
251
  setup: userVerify.setup ?? s.setupCmd ?? "",
252
+ lint: userVerify.lint ?? s.fmtCmd ?? "",
217
253
  test: userVerify.test ?? s.testCmd ?? "",
254
+ unit: userVerify.unit ?? userVerify.test ?? s.testCmd ?? "",
255
+ fuzz: userVerify.fuzz ?? s.fuzzCmd ?? "",
256
+ invariant: userVerify.invariant ?? s.invariantCmd ?? "",
257
+ e2e: userVerify.e2e ?? s.e2eCmd ?? "",
218
258
  teardown: userVerify.teardown ?? s.teardownCmd ?? "",
219
259
  build: userVerify.build ?? s.buildCmd ?? "",
260
+ policy: {
261
+ networkAccess: userVerify.policy?.networkAccess || (s.stack === "foundry" ? "forbidden" : "allow"),
262
+ offline: userVerify.policy?.offline ?? (s.stack === "foundry"),
263
+ },
264
+ stages: Array.isArray(userVerify.stages) ? userVerify.stages : null,
220
265
  server: userVerify.server ? {
221
266
  command: userVerify.server.command || "",
222
267
  url: userVerify.server.url || "http://localhost:3000",
@@ -273,6 +318,10 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
273
318
 
274
319
  const setupCmd = parsed.setup_cmd || parsed.verify?.setup || "";
275
320
  const testCmd = parsed.test_cmd || parsed.verify?.test || "";
321
+ const lintCmd = parsed.lint_cmd || parsed.verify?.lint || "";
322
+ const fuzzCmd = parsed.fuzz_cmd || parsed.verify?.fuzz || "";
323
+ const invariantCmd = parsed.invariant_cmd || parsed.verify?.invariant || "";
324
+ const e2eCmd = parsed.e2e_cmd || parsed.verify?.e2e || "";
276
325
  const teardownCmd = parsed.teardown_cmd || parsed.verify?.teardown || "";
277
326
  const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
278
327
  const verifyTimeoutMs = parsed.verify?.timeoutMs ?? parsed.verify?.timeout_ms ?? 60000;
@@ -303,11 +352,28 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
303
352
  tier: activeTier,
304
353
  verify: {
305
354
  setup: setupCmd || autoVerify.setup || "",
355
+ lint: lintCmd || autoVerify.lint || "",
306
356
  test: testCmd || autoVerify.test,
357
+ unit: parsed.verify?.unit || testCmd || autoVerify.unit || autoVerify.test,
358
+ fuzz: fuzzCmd || autoVerify.fuzz || "",
359
+ invariant: invariantCmd || autoVerify.invariant || "",
360
+ e2e: e2eCmd || autoVerify.e2e || "",
307
361
  teardown: teardownCmd || autoVerify.teardown || "",
308
362
  build: buildCmd || autoVerify.build,
363
+ stages: parsed.verify?.stages || autoVerify.stages || null,
364
+ policy: parsed.verify?.policy || autoVerify.policy,
309
365
  timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 60000,
310
366
  },
367
+ evidence: {
368
+ enabled: parsed.evidence?.enabled ?? true,
369
+ strictTestLock: parsed.evidence?.strict_test_lock ?? parsed.evidence?.strictTestLock ?? true,
370
+ },
371
+ router: {
372
+ enabled: parsed.router?.enabled ?? false,
373
+ fast: parsed.router?.fast || "gemini-flash",
374
+ complex: parsed.router?.complex || "",
375
+ threshold: Number.isFinite(Number(parsed.router?.threshold)) ? Number(parsed.router.threshold) : 0,
376
+ },
311
377
  scope: normalizeScope(parsed),
312
378
  limits: {
313
379
  ...DEFAULTS.limits,
@@ -406,3 +406,121 @@ export function resolveAffectedTests(modifiedFiles = [], options = {}) {
406
406
  return affected.size > 0 ? Array.from(affected) : null;
407
407
  }
408
408
 
409
+ /**
410
+ * Loads tasks from the queue directory and executes them via DagExecutor.
411
+ * Supports task files with metadata (JSON or Markdown envelopes with dependsOn).
412
+ * @param {string} [root=process.cwd()]
413
+ * @param {Object} [options]
414
+ * @param {number} [options.concurrency=1]
415
+ * @param {boolean} [options.dryRun=false]
416
+ * @param {Function} [options.dispatchFn]
417
+ * @param {Object} [options.config]
418
+ * @returns {Promise<{ processed: number, results: Array<any> }>}
419
+ */
420
+ export async function executeQueueDag(root = process.cwd(), options = {}) {
421
+ const { readdirSync, renameSync, mkdirSync } = await import("node:fs");
422
+ const { join, basename } = await import("node:path");
423
+
424
+ const queueDir = join(root, ".agent", "jules-queue");
425
+ const fallbackQueueDir = join(root, ".agent", "queue");
426
+ const actualQueueDir = existsSync(queueDir) ? queueDir : (existsSync(fallbackQueueDir) ? fallbackQueueDir : queueDir);
427
+ const completedDir = join(actualQueueDir, "completed");
428
+
429
+ if (!existsSync(actualQueueDir)) {
430
+ return { processed: 0, results: [] };
431
+ }
432
+ if (!existsSync(completedDir)) {
433
+ try {
434
+ mkdirSync(completedDir, { recursive: true });
435
+ } catch (_) {}
436
+ }
437
+
438
+ const files = readdirSync(actualQueueDir).filter((f) => {
439
+ if (f === "completed" || f.startsWith(".")) return false;
440
+ return f.endsWith(".md") || f.endsWith(".json") || f.endsWith(".task");
441
+ });
442
+
443
+ if (files.length === 0) {
444
+ return { processed: 0, results: [] };
445
+ }
446
+
447
+ const executor = new DagExecutor({ concurrency: options.concurrency || 1 });
448
+ const taskMap = new Map();
449
+ const results = [];
450
+
451
+ for (const file of files) {
452
+ if (basename(file) !== file) continue;
453
+ const fullPath = join(actualQueueDir, file);
454
+ const content = readFileSync(fullPath, "utf-8");
455
+ let taskId = file.replace(/\.(md|json|task)$/, "");
456
+ let dependsOn = [];
457
+ let title = taskId;
458
+ let prompt = content;
459
+ let role = undefined;
460
+ let tier = undefined;
461
+
462
+ // Check envelope header
463
+ const match = content.match(/<!--\s*JULES_TASK_ENVELOPE:\s*({[\s\S]*?})\s*-->/);
464
+ if (match) {
465
+ try {
466
+ const meta = JSON.parse(match[1]);
467
+ if (meta.id) taskId = meta.id;
468
+ if (meta.title) title = meta.title;
469
+ if (meta.role) role = meta.role;
470
+ if (meta.tier) tier = meta.tier;
471
+ if (Array.isArray(meta.dependsOn)) dependsOn = meta.dependsOn;
472
+ else if (typeof meta.dependsOn === "string") dependsOn = meta.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
473
+ } catch (_) {}
474
+ } else if (file.endsWith(".json")) {
475
+ try {
476
+ const parsed = JSON.parse(content);
477
+ if (parsed.id) taskId = parsed.id;
478
+ if (parsed.title) title = parsed.title;
479
+ if (parsed.prompt) prompt = parsed.prompt;
480
+ if (parsed.role) role = parsed.role;
481
+ if (parsed.tier) tier = parsed.tier;
482
+ if (Array.isArray(parsed.dependsOn)) dependsOn = parsed.dependsOn;
483
+ else if (typeof parsed.dependsOn === "string") dependsOn = parsed.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
484
+ } catch (_) {}
485
+ }
486
+
487
+ taskMap.set(taskId, { file, fullPath, taskId, title, prompt, role, tier, dependsOn });
488
+ }
489
+
490
+ // Register each task with its dependencies that are part of this queue run
491
+ for (const [taskId, t] of taskMap.entries()) {
492
+ const validDeps = t.dependsOn.filter((dep) => taskMap.has(dep));
493
+ executor.addTask({
494
+ id: taskId,
495
+ dependsOn: validDeps,
496
+ runner: async () => {
497
+ const srcPath = t.fullPath;
498
+ const taskObj = { id: t.taskId, title: t.title, prompt: t.prompt, role: t.role, tier: t.tier };
499
+ let session;
500
+ if (typeof options.dispatchFn === "function") {
501
+ session = await options.dispatchFn(taskObj, { root, config: options.config, dryRun: options.dryRun });
502
+ } else {
503
+ const { dispatch } = await import("./engine.mjs");
504
+ session = await dispatch(taskObj, { root, config: options.config, dryRun: options.dryRun });
505
+ }
506
+
507
+ if (session && session.ok === false) {
508
+ results.push({ file: t.file, taskId: t.taskId, ok: false, status: session.status, error: session.error, session });
509
+ throw new Error(`DAG Task '${t.taskId}' failed: ${session.error || session.status || "Unknown error"}`);
510
+ } else {
511
+ const dstPath = join(completedDir, t.file);
512
+ if (existsSync(srcPath)) {
513
+ if (!existsSync(dstPath)) {
514
+ renameSync(srcPath, dstPath);
515
+ }
516
+ }
517
+ results.push({ file: t.file, taskId: t.taskId, ok: true, session });
518
+ }
519
+ },
520
+ });
521
+ }
522
+
523
+ await executor.execute({ concurrency: options.concurrency || 1, root });
524
+ return { processed: results.length, results };
525
+ }
526
+