jules-orchestrator-kit 0.3.0 → 0.5.1

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.
@@ -2,26 +2,27 @@ import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { log, sleep } from "./utils.mjs";
5
6
 
6
7
  const execFileAsync = promisify(execFile);
7
8
 
8
9
  const tasksFile = process.argv[2];
9
10
 
10
11
  if (!tasksFile) {
11
- console.error("Usage: node scripts/jules-swarm.mjs <path-to-tasks.json>");
12
- console.error('Format of tasks.json: [ { "id": "t1", "title": "Task 1", "prompt": "Description 1", "scope": ["src/moduleA/**"] } ]');
12
+ log.error("Usage: node scripts/jules-swarm.mjs <path-to-tasks.json>");
13
+ log.error('Format of tasks.json: [ { "id": "t1", "title": "Task 1", "prompt": "Description 1", "scope": ["src/moduleA/**"] } ]');
13
14
  process.exit(1);
14
15
  }
15
16
 
16
17
  const resolvedPath = path.resolve(process.cwd(), tasksFile);
17
18
  if (!fs.existsSync(resolvedPath)) {
18
- console.error(`❌ Tasks file not found: ${resolvedPath}`);
19
+ log.error(`Tasks file not found: ${resolvedPath}`);
19
20
  process.exit(1);
20
21
  }
21
22
 
22
23
  const tasks = JSON.parse(fs.readFileSync(resolvedPath, "utf-8"));
23
24
  if (!Array.isArray(tasks)) {
24
- console.error("❌ tasks.json must contain a JSON array of task objects.");
25
+ log.error("tasks.json must contain a JSON array of task objects.");
25
26
  process.exit(1);
26
27
  }
27
28
 
@@ -36,7 +37,7 @@ const USE_WORKTREES = process.env.JULES_USE_WORKTREES === "true";
36
37
  // Ensure root project path is preserved for history logs even when running in worktrees
37
38
  process.env.JULES_PROJECT_ROOT = process.cwd();
38
39
 
39
- console.log(`🐝 Launching Jules Swarm Orchestrator (${tasks.length} tasks, Concurrency: ${MAX_CONCURRENT}, Worktrees: ${USE_WORKTREES ? "ENABLED" : "DISABLED"})...`);
40
+ log.info(`Launching Jules Swarm Orchestrator (${tasks.length} tasks, Concurrency: ${MAX_CONCURRENT}, Worktrees: ${USE_WORKTREES ? "ENABLED" : "DISABLED"})...`);
40
41
 
41
42
  const activeWorktrees = new Set();
42
43
 
@@ -59,14 +60,35 @@ function cleanupAllWorktreesSync() {
59
60
  activeWorktrees.clear();
60
61
  }
61
62
 
63
+ function reapOrphanedWorktrees() {
64
+ const worktreesBase = path.resolve(process.cwd(), ".agent/worktrees");
65
+ if (!fs.existsSync(worktreesBase)) return;
66
+ try {
67
+ const entries = fs.readdirSync(worktreesBase);
68
+ for (const entry of entries) {
69
+ const wtPath = path.join(worktreesBase, entry);
70
+ try {
71
+ execFileSync("git", ["worktree", "remove", "--force", wtPath], { stdio: "ignore" });
72
+ } catch (_) {
73
+ try {
74
+ fs.rmSync(wtPath, { recursive: true, force: true });
75
+ } catch (_) {}
76
+ }
77
+ }
78
+ } catch (_) {}
79
+ try {
80
+ execFileSync("git", ["worktree", "prune"], { stdio: "ignore" });
81
+ } catch (_) {}
82
+ }
83
+
62
84
  process.on("SIGINT", () => {
63
- console.warn("\n⚠️ SIGINT received. Cleaning up active Git worktrees...");
85
+ log.dim("SIGINT received. Cleaning up active Git worktrees...");
64
86
  cleanupAllWorktreesSync();
65
87
  process.exit(130);
66
88
  });
67
89
 
68
90
  process.on("SIGTERM", () => {
69
- console.warn("\n⚠️ SIGTERM received. Cleaning up active Git worktrees...");
91
+ log.dim("SIGTERM received. Cleaning up active Git worktrees...");
70
92
  cleanupAllWorktreesSync();
71
93
  process.exit(143);
72
94
  });
@@ -76,19 +98,27 @@ async function createWorktree(taskId) {
76
98
  const wtDir = path.resolve(process.cwd(), `.agent/worktrees/${slug}`);
77
99
  const branchName = `jules/${slug}`;
78
100
  let item = null;
79
- try {
80
- if (!fs.existsSync(path.dirname(wtDir))) {
81
- fs.mkdirSync(path.dirname(wtDir), { recursive: true });
82
- }
83
- await execFileAsync("git", ["worktree", "add", "-b", branchName, wtDir, "HEAD"]);
84
- item = { wtDir, branchName };
85
- } catch (err) {
101
+ await fs.promises.mkdir(path.dirname(wtDir), { recursive: true });
102
+
103
+ const maxRetries = 3;
104
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
86
105
  try {
87
- await execFileAsync("git", ["worktree", "add", "--force", wtDir, "HEAD"]);
88
- item = { wtDir, branchName: null };
89
- } catch (fallbackErr) {
90
- console.warn(`⚠️ Failed to create worktree for ${taskId}:`, fallbackErr.message);
91
- return null;
106
+ await execFileAsync("git", ["worktree", "add", "-b", branchName, wtDir, "HEAD"]);
107
+ item = { wtDir, branchName };
108
+ break;
109
+ } catch (err) {
110
+ if (attempt < maxRetries) {
111
+ const jitter = Math.floor(Math.random() * 200) + 100 * Math.pow(2, attempt);
112
+ await sleep(jitter);
113
+ continue;
114
+ }
115
+ try {
116
+ await execFileAsync("git", ["worktree", "add", "--force", wtDir, "HEAD"]);
117
+ item = { wtDir, branchName: null };
118
+ } catch (fallbackErr) {
119
+ log.error(`Failed to create worktree for ${taskId}: ${fallbackErr.message}`);
120
+ return null;
121
+ }
92
122
  }
93
123
  }
94
124
  if (item) activeWorktrees.add(item);
@@ -105,18 +135,21 @@ async function removeWorktree(wtDir, branchName) {
105
135
  try {
106
136
  await execFileAsync("git", ["worktree", "remove", "--force", wtDir]);
107
137
  } catch (err) {
108
- console.warn(`⚠️ Failed to remove worktree at ${wtDir}:`, err.message);
138
+ log.error(`Failed to remove worktree at ${wtDir}: ${err.message}`);
109
139
  }
110
140
  if (branchName) {
111
141
  try {
112
142
  await execFileAsync("git", ["branch", "-D", branchName]);
113
143
  } catch (err) {
114
- console.warn(`⚠️ Failed to delete swarm branch ${branchName}:`, err.message);
144
+ log.error(`Failed to delete swarm branch ${branchName}: ${err.message}`);
115
145
  }
116
146
  }
117
147
  }
118
148
 
119
149
  async function runSwarm() {
150
+ if (USE_WORKTREES) {
151
+ reapOrphanedWorktrees();
152
+ }
120
153
  const results = [];
121
154
  let hasFailure = false;
122
155
 
@@ -131,8 +164,7 @@ async function runSwarm() {
131
164
  await sleep(STAGGER_MS * bIdx);
132
165
  }
133
166
 
134
- console.log(`\n----------------------------------------`);
135
- console.log(`[${taskNum}/${tasks.length}] Dispatching Swarm Task: ${task.title} (${taskId})`);
167
+ log.step("======", `[${taskNum}/${tasks.length}] Dispatching Swarm Task: ${task.title} (${taskId})`);
136
168
 
137
169
  let wtInfo = null;
138
170
  let execCwd = process.cwd();
@@ -141,7 +173,7 @@ async function runSwarm() {
141
173
  wtInfo = await createWorktree(taskId);
142
174
  if (wtInfo?.wtDir) {
143
175
  execCwd = wtInfo.wtDir;
144
- console.log(`🌲 Isolated Task ${taskId} in Git Worktree: ${wtInfo.wtDir}`);
176
+ log.info(`Isolated Task ${taskId} in Git Worktree: ${wtInfo.wtDir}`);
145
177
  }
146
178
  }
147
179
 
@@ -156,12 +188,17 @@ async function runSwarm() {
156
188
  const { stdout } = await execFileAsync("node", [dispatchScript, task.title, effectivePrompt], {
157
189
  cwd: execCwd,
158
190
  timeout: 15 * 60 * 1000,
159
- env: { ...process.env, JULES_PROJECT_ROOT: process.env.JULES_PROJECT_ROOT },
191
+ env: {
192
+ ...process.env,
193
+ JULES_PROJECT_ROOT: process.env.JULES_PROJECT_ROOT,
194
+ JULES_SLOT_INDEX: String(bIdx + 1),
195
+ JULES_SLOT_TOTAL: String(batch.length)
196
+ },
160
197
  });
161
- if (stdout) console.log(stdout.trim());
198
+ if (stdout) log.dim(stdout.trim());
162
199
  return { taskId, title: task.title, status: "SUCCESS" };
163
200
  } catch (error) {
164
- console.error(`⚠️ Failed task [${task.title}]:`, error.message);
201
+ log.error(`Failed task [${task.title}]: ${error.message}`);
165
202
  return { taskId, title: task.title, status: "FAILED", error: error.message };
166
203
  } finally {
167
204
  if (wtInfo?.wtDir) {
@@ -182,15 +219,14 @@ async function runSwarm() {
182
219
  }
183
220
 
184
221
  if (i + MAX_CONCURRENT < tasks.length) {
185
- console.log(`\n⏳ Batch finished. Cooling down for 2s before next batch...`);
222
+ log.info(`Batch finished. Cooling down for 2s before next batch...`);
186
223
  await sleep(2000);
187
224
  }
188
225
  }
189
226
 
190
- console.log(`\n========================================`);
191
- console.log(`🎉 Swarm Dispatch Summary (${results.length} tasks processed):`);
227
+ log.header(`Swarm Dispatch Summary (${results.length} tasks processed):`);
192
228
  results.forEach((res) => {
193
- console.log(` - [${res.status}] ${res.title}`);
229
+ log.info(`[${res.status}] ${res.title}`);
194
230
  });
195
231
 
196
232
  if (hasFailure) {
@@ -199,6 +235,6 @@ async function runSwarm() {
199
235
  }
200
236
 
201
237
  runSwarm().catch((err) => {
202
- console.error("❌ Unhandled swarm rejection:", err);
238
+ log.error(`Unhandled swarm rejection: ${err.message}`);
203
239
  process.exit(1);
204
240
  });
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * lock-manager.mjs
5
+ *
6
+ * Manages multi-agent file locks to prevent concurrent modification collisions.
7
+ * Uses .agent/sync-manifest.json as the lock registry.
8
+ *
9
+ * Usage:
10
+ * node scripts/lock-manager.mjs acquire <agent_name> <task_id> <file_path1> <file_path2> [--unattended]
11
+ * node scripts/lock-manager.mjs release <task_id>
12
+ */
13
+
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ const MANIFEST_PATH = path.join(process.cwd(), '.agent', 'sync-manifest.json');
18
+ const TTL_MS = 20 * 60 * 1000; // 20 minutes
19
+
20
+ async function readManifest() {
21
+ try {
22
+ const data = await fs.readFile(MANIFEST_PATH, 'utf-8');
23
+ return JSON.parse(data);
24
+ } catch (err) {
25
+ if (err.code === 'ENOENT') {
26
+ return {};
27
+ }
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ async function writeManifest(manifest) {
33
+ await fs.mkdir(path.dirname(MANIFEST_PATH), { recursive: true });
34
+ await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2), 'utf-8');
35
+ }
36
+
37
+ function cleanExpiredLocks(manifest) {
38
+ const now = Date.now();
39
+ let changed = false;
40
+ for (const [file, lock] of Object.entries(manifest)) {
41
+ if (lock.expiresAt < now) {
42
+ delete manifest[file];
43
+ changed = true;
44
+ }
45
+ }
46
+ return changed;
47
+ }
48
+
49
+ async function acquireLocks(agentName, taskId, files) {
50
+ const manifest = await readManifest();
51
+ cleanExpiredLocks(manifest);
52
+
53
+ const now = Date.now();
54
+ const expiresAt = now + TTL_MS;
55
+
56
+ const conflicts = [];
57
+ for (const file of files) {
58
+ const normalizedFile = path.normalize(file);
59
+ if (manifest[normalizedFile] && manifest[normalizedFile].taskId !== taskId) {
60
+ conflicts.push({
61
+ file: normalizedFile,
62
+ lockedBy: manifest[normalizedFile].agentName,
63
+ task: manifest[normalizedFile].taskId
64
+ });
65
+ }
66
+ }
67
+
68
+ if (conflicts.length > 0) {
69
+ console.error(`ERROR: Cannot acquire locks for task ${taskId}. Conflicts detected:`);
70
+ for (const conflict of conflicts) {
71
+ console.error(` - ${conflict.file} is locked by ${conflict.lockedBy} (Task: ${conflict.task})`);
72
+ }
73
+ process.exit(1);
74
+ }
75
+
76
+ for (const file of files) {
77
+ const normalizedFile = path.normalize(file);
78
+ manifest[normalizedFile] = {
79
+ agentName,
80
+ taskId,
81
+ acquiredAt: now,
82
+ expiresAt
83
+ };
84
+ }
85
+
86
+ await writeManifest(manifest);
87
+ console.log(`SUCCESS: Acquired locks for task ${taskId} on ${files.length} files.`);
88
+ }
89
+
90
+ async function releaseLocks(taskId) {
91
+ const manifest = await readManifest();
92
+ let releasedCount = 0;
93
+
94
+ for (const [file, lock] of Object.entries(manifest)) {
95
+ if (lock.taskId === taskId) {
96
+ delete manifest[file];
97
+ releasedCount++;
98
+ }
99
+ }
100
+
101
+ if (releasedCount > 0) {
102
+ await writeManifest(manifest);
103
+ console.log(`SUCCESS: Released ${releasedCount} locks for task ${taskId}.`);
104
+ } else {
105
+ console.log(`INFO: No locks found for task ${taskId}.`);
106
+ }
107
+ }
108
+
109
+ async function main() {
110
+ const args = process.argv.slice(2);
111
+ const command = args[0];
112
+
113
+ if (command === 'acquire') {
114
+ const agentName = args[1];
115
+ const taskId = args[2];
116
+ const unattendedIndex = args.indexOf('--unattended');
117
+
118
+ let files = [];
119
+ if (unattendedIndex !== -1) {
120
+ files = args.slice(3, unattendedIndex).concat(args.slice(unattendedIndex + 1));
121
+ } else {
122
+ files = args.slice(3);
123
+ }
124
+
125
+ if (!agentName || !taskId || files.length === 0) {
126
+ console.error('Usage: acquire <agent_name> <task_id> <file_paths...>');
127
+ process.exit(1);
128
+ }
129
+
130
+ await acquireLocks(agentName, taskId, files);
131
+ } else if (command === 'release') {
132
+ const taskId = args[1];
133
+ if (!taskId) {
134
+ console.error('Usage: release <task_id>');
135
+ process.exit(1);
136
+ }
137
+ await releaseLocks(taskId);
138
+ } else {
139
+ console.error('Unknown command. Use "acquire" or "release".');
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ main().catch(err => {
145
+ console.error('Unhandled error:', err);
146
+ process.exit(1);
147
+ });
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+
6
+ // ANSI colors for standardizing CLI DX
7
+ const isCI = !!process.env.CI;
8
+ const noColor = (!process.stdout.isTTY && !isCI) || process.env.NO_COLOR;
9
+ const c = (color, text) => noColor ? text : `\x1b[${color}m${text}\x1b[0m`;
10
+
11
+ export const log = {
12
+ info: (msg) => console.log(c(36, `ℹ️ ${msg}`)),
13
+ success: (msg) => console.log(c(32, `✅ ${msg}`)),
14
+ warn: (msg) => console.warn(c(33, `⚠️ ${msg}`)),
15
+ error: (msg) => {
16
+ if (isCI) console.log(`::error::${msg}`);
17
+ console.error(c(31, `❌ ${msg}`));
18
+ },
19
+ step: (stepStr, msg) => console.log(`${c(90, stepStr)} ${msg}`),
20
+ dim: (msg) => console.log(c(90, msg)),
21
+ header: (msg) => {
22
+ if (isCI) console.log(`::group::${msg}`);
23
+ console.log(`\n${c("1;35", `=== ${msg} ===`)}\n`);
24
+ },
25
+ groupEnd: () => {
26
+ if (isCI) console.log("::endgroup::");
27
+ }
28
+ };
29
+
30
+ export const sleep = promisify(setTimeout);
31
+
32
+ export function ensureDir(dirPath) {
33
+ try {
34
+ if (!fs.existsSync(dirPath)) {
35
+ fs.mkdirSync(dirPath, { recursive: true });
36
+ }
37
+ } catch (error) {
38
+ log.error(`EACCES: Insufficient permissions or failed to create directory: ${dirPath}`);
39
+ log.error(error.message);
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ export function logToHistory(filename, content, type = "audit") {
45
+ const dateStr = new Date().toISOString().split("T")[0];
46
+ const historyDir = path.resolve(process.env.JULES_PROJECT_ROOT || process.cwd(), ".agent/history");
47
+ ensureDir(historyDir);
48
+ const filePath = path.join(historyDir, `${dateStr}-${filename}`);
49
+ fs.writeFileSync(filePath, content, "utf-8");
50
+ return filePath;
51
+ }
52
+
53
+ export function resolveMarkdownConflict(content) {
54
+ if (!content || typeof content !== "string") return "";
55
+ if (!content.includes("<<<<<<<")) return content;
56
+
57
+ const lines = content.split("\n");
58
+ const result = [];
59
+ let inConflict = false;
60
+ let headBuffer = [];
61
+ let devBuffer = [];
62
+ let section = null;
63
+
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i];
66
+ if (line.startsWith("<<<<<<<")) {
67
+ inConflict = true;
68
+ section = "head";
69
+ continue;
70
+ }
71
+ if (line.startsWith("=======")) {
72
+ section = "dev";
73
+ continue;
74
+ }
75
+ if (line.startsWith(">>>>>>>")) {
76
+ result.push(...headBuffer);
77
+ result.push(...devBuffer);
78
+ headBuffer = [];
79
+ devBuffer = [];
80
+ inConflict = false;
81
+ section = null;
82
+ continue;
83
+ }
84
+
85
+ if (inConflict) {
86
+ if (section === "head") headBuffer.push(line);
87
+ else if (section === "dev") devBuffer.push(line);
88
+ } else {
89
+ result.push(line);
90
+ }
91
+ }
92
+
93
+ return result.join("\n");
94
+ }