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.
- package/JULES_RULES_TEMPLATE.md +2 -1
- package/README.md +48 -12
- package/bin/agentctl.mjs +122 -13
- package/index.mjs +19 -4
- package/package.json +2 -1
- package/scripts/command-resolver.mjs +15 -1
- package/scripts/doc-sync-check.mjs +234 -0
- package/scripts/release.mjs +23 -2
- package/src/config.mjs +66 -0
- package/src/dag-engine.mjs +118 -0
- package/src/engine.mjs +237 -71
- package/src/evidence.mjs +435 -0
- package/src/mcp.mjs +39 -1
- package/src/ops/evidence-actions.mjs +74 -0
- package/src/provider.mjs +37 -7
- package/src/router.mjs +205 -0
- package/src/security.mjs +66 -13
- package/src/stack-detector.mjs +28 -0
- package/src/wizard-task.mjs +66 -5
|
@@ -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
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -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: "
|
|
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,
|
package/src/dag-engine.mjs
CHANGED
|
@@ -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
|
+
|