jules-orchestrator-kit 0.5.1 → 0.6.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/.agent/jules.yml +1 -1
- package/.env.example +49 -3
- package/.github/ISSUE_TEMPLATE/bug_report.md +63 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +34 -0
- package/.github/social-preview.png +0 -0
- package/.github/workflows/agent-scope-guard.yml +3 -2
- package/.github/workflows/jules-audit.yml +8 -2
- package/README.md +99 -204
- package/index.mjs +2 -2
- package/package.json +3 -2
- package/scripts/command-resolver.mjs +2 -2
- package/scripts/jules-create.mjs +22 -2
- package/scripts/jules-dispatch.mjs +55 -45
- package/scripts/jules-queue-runner.mjs +48 -3
- package/scripts/jules-scan-todos.mjs +7 -7
- package/scripts/jules-self-audit.mjs +172 -80
- package/scripts/jules-swarm.mjs +4 -1
- package/scripts/lock-manager.mjs +51 -17
- package/scripts/utils.mjs +139 -0
package/scripts/jules-create.mjs
CHANGED
|
@@ -19,8 +19,28 @@ const queueDir = path.resolve(process.cwd(), ".agent/jules-queue");
|
|
|
19
19
|
ensureDir(queueDir);
|
|
20
20
|
|
|
21
21
|
const safeTitle = taskTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
|
|
23
|
+
let maxId = 0;
|
|
24
|
+
const dirsToCheck = [queueDir, path.join(queueDir, ".processing"), path.join(queueDir, "completed")];
|
|
25
|
+
|
|
26
|
+
for (const dir of dirsToCheck) {
|
|
27
|
+
try {
|
|
28
|
+
const files = fs.readdirSync(dir);
|
|
29
|
+
for (const f of files) {
|
|
30
|
+
if (f.startsWith("TASK-") && f.endsWith(".md")) {
|
|
31
|
+
const match = f.match(/^TASK-(\d+)-/);
|
|
32
|
+
if (match) {
|
|
33
|
+
const id = parseInt(match[1], 10);
|
|
34
|
+
if (id > maxId) maxId = id;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Directory might not exist yet, that's fine
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nextId = (maxId + 1).toString().padStart(3, "0");
|
|
24
44
|
const filename = `TASK-${nextId}-${safeTitle}.md`;
|
|
25
45
|
const filepath = path.join(queueDir, filename);
|
|
26
46
|
|
|
@@ -5,7 +5,7 @@ import crypto from "node:crypto";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { resolveProjectCommands } from "./command-resolver.mjs";
|
|
8
|
-
import { log, logToHistory } from "./utils.mjs";
|
|
8
|
+
import { log, logToHistory, redactSecrets, appendLedger, checkDailyBudget, reserveDailyBudget } from "./utils.mjs";
|
|
9
9
|
|
|
10
10
|
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
11
11
|
|
|
@@ -41,42 +41,18 @@ function loadEnv() {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export function redactSecrets(text) {
|
|
48
|
-
if (!text) return "";
|
|
49
|
-
let sanitized = text;
|
|
50
|
-
|
|
51
|
-
// Redact active env secrets from denylist keys (*_KEY, *_SECRET, *_TOKEN, etc.)
|
|
52
|
-
for (const [envKey, envVal] of Object.entries(process.env)) {
|
|
53
|
-
if (
|
|
54
|
-
envVal &&
|
|
55
|
-
envVal.length >= 6 &&
|
|
56
|
-
/KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH/i.test(envKey)
|
|
57
|
-
) {
|
|
58
|
-
if (sanitized.includes(envVal)) {
|
|
59
|
-
sanitized = sanitized.split(envVal).join("[REDACTED_ENV_SECRET]");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
44
|
+
if (isMainModule) {
|
|
45
|
+
loadEnv();
|
|
46
|
+
}
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
/Bearer\s+[a-zA-Z0-9\-\._~+\/]+=*/g,
|
|
71
|
-
/sk-(?:ant-api03-|proj-|svcacct-)[a-zA-Z0-9\-\_]{32,}/g,
|
|
72
|
-
/xox[baprs]-[a-zA-Z0-9\-]{10,}/g,
|
|
73
|
-
/eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/g,
|
|
74
|
-
/-----BEGIN (?:RSA|OPENSSH|EC|PRIVATE) KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PRIVATE) KEY-----/g,
|
|
75
|
-
];
|
|
76
|
-
for (const pat of patterns) {
|
|
77
|
-
sanitized = sanitized.replace(pat, "[REDACTED_BY_SECURITY_GATE]");
|
|
48
|
+
function parseRetryAfter(res) {
|
|
49
|
+
const raw = res.headers.get("Retry-After");
|
|
50
|
+
let waitSec = Number.parseInt(raw ?? "", 10);
|
|
51
|
+
if (!Number.isFinite(waitSec)) {
|
|
52
|
+
const asDate = raw ? Date.parse(raw) : NaN;
|
|
53
|
+
waitSec = Number.isFinite(asDate) ? Math.ceil((asDate - Date.now()) / 1000) : 5;
|
|
78
54
|
}
|
|
79
|
-
return
|
|
55
|
+
return Math.min(Math.max(waitSec, 0), 60);
|
|
80
56
|
}
|
|
81
57
|
|
|
82
58
|
const ASTRO_TRIGGER_RE = /\b(?:astro|components|pages|src\/.*\.astro)\b/i;
|
|
@@ -127,6 +103,7 @@ let fullPrompt = "";
|
|
|
127
103
|
let historyFile = "";
|
|
128
104
|
let tmpPayloadFile = "";
|
|
129
105
|
let tmpDir = "";
|
|
106
|
+
let taskKey = "";
|
|
130
107
|
|
|
131
108
|
const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
132
109
|
export function getAlphaRange(index, total) {
|
|
@@ -155,7 +132,7 @@ if (isMainModule) {
|
|
|
155
132
|
if (fs.existsSync(possiblePath)) {
|
|
156
133
|
try {
|
|
157
134
|
const verifiedPath = path.resolve(process.cwd(), possiblePath);
|
|
158
|
-
if (
|
|
135
|
+
if (!path.relative(process.cwd(), verifiedPath).startsWith('..')) {
|
|
159
136
|
const fd = fs.openSync(verifiedPath, "r");
|
|
160
137
|
try {
|
|
161
138
|
const stat = fs.fstatSync(fd);
|
|
@@ -212,10 +189,13 @@ if (isMainModule) {
|
|
|
212
189
|
log.info(`Dispatching task to Google Jules: "${taskTitle}"...`);
|
|
213
190
|
log.success(`Logged dispatch history to: ${path.relative(process.cwd(), historyFile)}`);
|
|
214
191
|
|
|
215
|
-
const
|
|
192
|
+
const hashInput = [repo || "repoless", taskTitle, rawPrompt, process.env.BASE_BRANCH || "main", dateStr].join("\u0000");
|
|
193
|
+
taskKey = crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, 12);
|
|
194
|
+
|
|
216
195
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jules-payload-"));
|
|
217
|
-
tmpPayloadFile = path.join(tmpDir, `payload_${
|
|
196
|
+
tmpPayloadFile = path.join(tmpDir, `payload_${taskKey}.json`);
|
|
218
197
|
fs.writeFileSync(tmpPayloadFile, JSON.stringify({ repo, title: taskTitle, prompt: fullPrompt }), { mode: 0o600 });
|
|
198
|
+
|
|
219
199
|
}
|
|
220
200
|
|
|
221
201
|
function cleanupTmp() {
|
|
@@ -237,6 +217,14 @@ async function executeDispatch() {
|
|
|
237
217
|
return;
|
|
238
218
|
}
|
|
239
219
|
|
|
220
|
+
const budgetLimit = process.env.JULES_DAILY_BUDGET ? parseInt(process.env.JULES_DAILY_BUDGET, 10) : 300;
|
|
221
|
+
const budgetCheck = reserveDailyBudget(budgetLimit, taskKey);
|
|
222
|
+
|
|
223
|
+
if (!budgetCheck.ok) {
|
|
224
|
+
log.warn(`Daily budget exhausted or locked (${budgetCheck.used}/${budgetCheck.budget} sessions). Aborting dispatch.`);
|
|
225
|
+
process.exit(7); // Budget exhausted exit code
|
|
226
|
+
}
|
|
227
|
+
|
|
240
228
|
if (apiKey && (repo || isRepoless)) {
|
|
241
229
|
const payload = {
|
|
242
230
|
title: taskTitle,
|
|
@@ -269,10 +257,20 @@ async function executeDispatch() {
|
|
|
269
257
|
"X-Goog-Api-Key": apiKey,
|
|
270
258
|
"Content-Type": "application/json"
|
|
271
259
|
},
|
|
272
|
-
body: JSON.stringify(payload)
|
|
260
|
+
body: JSON.stringify(payload),
|
|
261
|
+
signal: AbortSignal.timeout(30000)
|
|
273
262
|
});
|
|
274
263
|
|
|
275
|
-
if (res.
|
|
264
|
+
if (res.status === 429) {
|
|
265
|
+
const waitSec = parseRetryAfter(res);
|
|
266
|
+
if (attempts < maxAttempts) {
|
|
267
|
+
log.warn(`⚠️ REST API Rate Limit Exceeded (HTTP 429). Retrying after ${waitSec}s...`);
|
|
268
|
+
await new Promise((r) => setTimeout(r, waitSec * 1000));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (res.ok || res.status === 429 || res.status === 400 || res.status === 401) {
|
|
276
274
|
break;
|
|
277
275
|
}
|
|
278
276
|
|
|
@@ -300,8 +298,17 @@ async function executeDispatch() {
|
|
|
300
298
|
}
|
|
301
299
|
|
|
302
300
|
if (res.status === 429) {
|
|
303
|
-
|
|
304
|
-
|
|
301
|
+
const waitSec = parseRetryAfter(res);
|
|
302
|
+
const err = new Error(`❌ Jules REST API Rate Limit Exceeded (HTTP 429). Retry after: ${waitSec}s`);
|
|
303
|
+
err.code = 1;
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (res.status === 400 || res.status === 401) {
|
|
308
|
+
const errText = await res.text().catch(() => "");
|
|
309
|
+
const err = new Error(`❌ Jules REST API returned HTTP ${res.status}: ${errText}`);
|
|
310
|
+
err.code = 1;
|
|
311
|
+
throw err;
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
if (!res.ok) {
|
|
@@ -318,6 +325,7 @@ async function executeDispatch() {
|
|
|
318
325
|
sessionId = sId || data.name || data.id || sessionId;
|
|
319
326
|
log.success(`REST API Dispatch response: Session ${sessionId} created successfully.`);
|
|
320
327
|
fs.appendFileSync(historyFile, `\n## Session\n- ID: \`${sessionId}\`\n`);
|
|
328
|
+
appendLedger("sessions", { event: "session_dispatched", task_key: taskKey, session_id: sessionId, status: "dispatched", task_title: taskTitle, mode: "api" });
|
|
321
329
|
} catch (jsonErr) {
|
|
322
330
|
log.warn("⚠️ Failed to parse REST API JSON response:", jsonErr.message);
|
|
323
331
|
}
|
|
@@ -349,15 +357,17 @@ function dispatchViaCli(targetRepo, payloadFile, title) {
|
|
|
349
357
|
log.step("💻", `Executing: jules ${args.join(" ")} (prompt passed via stdin)...`);
|
|
350
358
|
execFileSync("jules", args, { input: promptToSend, stdio: ["pipe", "inherit", "inherit"] });
|
|
351
359
|
log.success(`Successfully dispatched task "${title}" to Jules CLI.`);
|
|
360
|
+
appendLedger("sessions", { event: "session_dispatched", task_key: taskKey, session_id: "CLI", status: "dispatched", task_title: title, mode: "cli" });
|
|
352
361
|
} catch (error) {
|
|
353
|
-
|
|
354
|
-
|
|
362
|
+
const err = new Error(`Failed to dispatch task to Jules CLI: ${error.message}`);
|
|
363
|
+
err.code = 1;
|
|
364
|
+
throw err;
|
|
355
365
|
}
|
|
356
366
|
}
|
|
357
367
|
|
|
358
368
|
if (isMainModule) {
|
|
359
369
|
executeDispatch().catch((err) => {
|
|
360
370
|
log.error(`❌ Fatal unhandled rejection in dispatch: ${err.message}`);
|
|
361
|
-
process.exit(1);
|
|
371
|
+
process.exit(err.code || 1);
|
|
362
372
|
});
|
|
363
373
|
}
|
|
@@ -13,6 +13,7 @@ const dispatchScript = path.resolve(__dirname, "jules-dispatch.mjs");
|
|
|
13
13
|
|
|
14
14
|
const queueDir = path.resolve(process.cwd(), ".agent/jules-queue");
|
|
15
15
|
const completedDir = path.resolve(process.cwd(), ".agent/jules-queue/completed");
|
|
16
|
+
const failedDir = path.resolve(process.cwd(), ".agent/jules-queue/failed");
|
|
16
17
|
const queueLogFile = path.join(queueDir, "queue.jsonl");
|
|
17
18
|
|
|
18
19
|
const processingDir = path.resolve(process.cwd(), ".agent/jules-queue/.processing");
|
|
@@ -23,8 +24,22 @@ if (!fs.existsSync(queueDir)) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
ensureDir(completedDir);
|
|
27
|
+
ensureDir(failedDir);
|
|
26
28
|
ensureDir(processingDir);
|
|
27
29
|
|
|
30
|
+
// Recover any tasks left in .processing from a previous run or crash
|
|
31
|
+
const STALE_MS = 15 * 60 * 1000;
|
|
32
|
+
const processingFiles = fs.readdirSync(processingDir).filter(f => f.endsWith(".md"));
|
|
33
|
+
for (const f of processingFiles) {
|
|
34
|
+
const procPath = path.join(processingDir, f);
|
|
35
|
+
try {
|
|
36
|
+
if (Date.now() - fs.statSync(procPath).mtimeMs < STALE_MS) continue;
|
|
37
|
+
const backToQueue = path.join(queueDir, f);
|
|
38
|
+
fs.renameSync(procPath, backToQueue);
|
|
39
|
+
log.info(`Recovered stale task ${f} from .processing/`);
|
|
40
|
+
} catch (_) {}
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
function logQueueState(file, status, error = null) {
|
|
29
44
|
const entry = JSON.stringify({
|
|
30
45
|
timestamp: new Date().toISOString(),
|
|
@@ -97,14 +112,21 @@ async function runQueue() {
|
|
|
97
112
|
// Atomic claim: move from queueDir to processingDir before dispatching
|
|
98
113
|
try {
|
|
99
114
|
safeMoveSync(filePath, processingPath);
|
|
115
|
+
const now = new Date();
|
|
116
|
+
fs.utimesSync(processingPath, now, now);
|
|
100
117
|
} catch (claimErr) {
|
|
101
118
|
// Another runner already claimed this task file or file missing
|
|
102
119
|
return;
|
|
103
120
|
}
|
|
104
121
|
|
|
105
|
-
|
|
122
|
+
let title = file.replace(/\.md$/, "").replace(/-/g, " ");
|
|
106
123
|
|
|
107
124
|
const content = fs.readFileSync(processingPath, "utf-8").trim();
|
|
125
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
126
|
+
if (h1Match && h1Match[1].trim()) {
|
|
127
|
+
title = h1Match[1].trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
108
130
|
if (!content || content.length < 10) {
|
|
109
131
|
log.warn(`Task file ${file} is empty or invalid. Skipping dispatch.`);
|
|
110
132
|
const destPath = path.join(completedDir, file);
|
|
@@ -117,7 +139,7 @@ async function runQueue() {
|
|
|
117
139
|
logQueueState(file, "RUNNING");
|
|
118
140
|
|
|
119
141
|
try {
|
|
120
|
-
await execFileAsync("node", [dispatchScript, title, processingPath]);
|
|
142
|
+
await execFileAsync("node", [dispatchScript, title, processingPath], { timeout: 10 * 60 * 1000 });
|
|
121
143
|
|
|
122
144
|
const destPath = path.join(completedDir, file);
|
|
123
145
|
await safeMoveAsync(processingPath, destPath);
|
|
@@ -125,7 +147,30 @@ async function runQueue() {
|
|
|
125
147
|
log.success(`Task dispatched successfully (Moved ${file} to completed/)`);
|
|
126
148
|
} catch (error) {
|
|
127
149
|
logQueueState(file, "FAILED", error);
|
|
128
|
-
|
|
150
|
+
|
|
151
|
+
let attempts = 0;
|
|
152
|
+
const attemptMatch = content.match(/^attempts:\s*(\d+)/m);
|
|
153
|
+
if (attemptMatch) attempts = parseInt(attemptMatch[1], 10);
|
|
154
|
+
|
|
155
|
+
if (attempts >= 2) {
|
|
156
|
+
const destPath = path.join(failedDir, file);
|
|
157
|
+
try { await safeMoveAsync(processingPath, destPath); } catch (_) {}
|
|
158
|
+
log.error(`Task [${title}] failed 3 times. Moved to failed/. Error: ${error.message}`);
|
|
159
|
+
} else {
|
|
160
|
+
let newContent = content;
|
|
161
|
+
if (attemptMatch) {
|
|
162
|
+
newContent = content.replace(/^attempts:\s*\d+/m, `attempts: ${attempts + 1}`);
|
|
163
|
+
} else if (content.startsWith("---\n")) {
|
|
164
|
+
newContent = content.replace(/^---\n/, `---\nattempts: 1\n`);
|
|
165
|
+
} else {
|
|
166
|
+
newContent = `---\nattempts: 1\n---\n\n` + content;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
fs.writeFileSync(processingPath, newContent);
|
|
170
|
+
await safeMoveAsync(processingPath, filePath);
|
|
171
|
+
} catch (_) {}
|
|
172
|
+
log.warn(`Task [${title}] failed. Re-queued (Attempt ${attempts + 1}). Error: ${error.message}`);
|
|
173
|
+
}
|
|
129
174
|
}
|
|
130
175
|
})
|
|
131
176
|
);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { log, ensureDir } from "./utils.mjs";
|
|
12
13
|
|
|
13
14
|
const IGNORE_DIRS = new Set([
|
|
@@ -22,9 +23,8 @@ const IGNORE_DIRS = new Set([
|
|
|
22
23
|
"coverage",
|
|
23
24
|
".next",
|
|
24
25
|
".turbo",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"tmp_repos3"
|
|
26
|
+
"test",
|
|
27
|
+
"scripts/jules-scan-todos.mjs"
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
const IGNORE_EXTS = new Set([
|
|
@@ -61,15 +61,15 @@ export function scanCodebaseForTodos(rootDir = process.cwd(), options = {}) {
|
|
|
61
61
|
walk(fullPath);
|
|
62
62
|
} else if (entry.isFile()) {
|
|
63
63
|
const ext = path.extname(entry.name).toLowerCase();
|
|
64
|
-
if (IGNORE_EXTS.has(ext)) continue;
|
|
64
|
+
if (IGNORE_EXTS.has(ext) || IGNORE_DIRS.has(relPath)) continue;
|
|
65
65
|
|
|
66
66
|
try {
|
|
67
67
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
68
68
|
const lines = content.split("\n");
|
|
69
69
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
70
70
|
const line = lines[lineIdx];
|
|
71
|
-
// Ignore false positives from regex definitions
|
|
72
|
-
if (/TAG_REGEX|
|
|
71
|
+
// Ignore false positives from regex definitions or scanner string literals
|
|
72
|
+
if (/TAG_REGEX|tasks\.some|log\.info\("Zero TODO|\/FIXME comments/i.test(line)) {
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
const match = line.match(TAG_REGEX);
|
|
@@ -137,6 +137,6 @@ export function runScanner() {
|
|
|
137
137
|
log.info(`Run \`npx jules-swarm ${path.relative(process.cwd(), outputPath)}\` to execute all tasks in parallel.`);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.url
|
|
140
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
141
141
|
runScanner();
|
|
142
142
|
}
|