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.
@@ -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
- const filesInQueue = fs.readdirSync(queueDir).filter(f => f.endsWith(".md") && f !== "README.md");
23
- const nextId = (filesInQueue.length + 1).toString().padStart(3, "0");
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
- loadEnv();
45
-
46
- // 0.2 Pre-Flight Secret Redaction Gate (RegEx + Env Denylist)
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
- const patterns = [
65
- /gh[pusr]_[a-zA-Z0-9]{36}/g,
66
- /github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}/g,
67
- /AKIA[0-9A-Z]{16}/g,
68
- /AIzaSy[a-zA-Z0-9_\-]{33}/g,
69
- /ya29\.[a-zA-Z0-9_\-]{20,}/g,
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 sanitized;
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 (verifiedPath.startsWith(process.cwd())) {
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 payloadHash = crypto.createHash("sha256").update(taskTitle + Date.now()).digest("hex").slice(0, 12);
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_${payloadHash}.json`);
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.ok || res.status === 429) {
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
- log.error(`❌ Jules REST API Rate Limit Exceeded (HTTP 429). Will NOT fallback to CLI.`);
304
- process.exit(1);
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
- log.error(`Failed to dispatch task to Jules CLI: ${error.message}`);
354
- process.exit(1);
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
- const title = file.replace(/\.md$/, "").replace(/-/g, " ");
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
- log.error(`Failed to dispatch task [${title}]: ${error.message}`);
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
- "tmp_repos",
26
- "tmp_repos2",
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, test assertions, or scanner string literals
72
- if (/TAG_REGEX|assert\.|tasks\.some|log\.info\("Zero TODO|\/FIXME comments/i.test(line)) {
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.slice(7))) {
140
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
141
141
  runScanner();
142
142
  }