jules-orchestrator-kit 0.29.1 → 0.32.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.
Files changed (46) hide show
  1. package/README.md +169 -86
  2. package/bin/agentctl.mjs +267 -4
  3. package/bin/mcp-server.mjs +0 -0
  4. package/index.mjs +21 -1
  5. package/package.json +1 -1
  6. package/scripts/stale-base-check.mjs +2 -2
  7. package/src/config.mjs +18 -2
  8. package/src/dag-engine.mjs +76 -0
  9. package/src/engine.mjs +331 -32
  10. package/src/envelope.mjs +32 -2
  11. package/src/execution_envelope.mjs +9 -1
  12. package/src/git.mjs +41 -0
  13. package/src/mcp.mjs +34 -0
  14. package/src/memory.mjs +148 -0
  15. package/src/ops/checkpoint.mjs +167 -0
  16. package/src/ops/command-registry.mjs +274 -0
  17. package/src/ops/doctor-planner.mjs +214 -0
  18. package/src/ops/doctor-registry.mjs +442 -0
  19. package/src/ops/ide-scaffold.mjs +139 -0
  20. package/src/ops/receipts.mjs +171 -0
  21. package/src/ops/swarm-actions.mjs +156 -0
  22. package/src/ops/task-actions.mjs +214 -0
  23. package/src/ops/tdd-generator.mjs +115 -0
  24. package/src/ops/transaction.mjs +278 -0
  25. package/src/prompt-guard.mjs +8 -1
  26. package/src/provider.mjs +164 -8
  27. package/src/remediation.mjs +164 -0
  28. package/src/security.mjs +14 -6
  29. package/src/stack-detector.mjs +15 -5
  30. package/src/state.mjs +71 -7
  31. package/src/task-optimizer.mjs +306 -0
  32. package/src/telemetry.mjs +35 -2
  33. package/src/tui.mjs +2 -6
  34. package/src/ux/capabilities.mjs +180 -0
  35. package/src/ux/diff-viewer.mjs +289 -0
  36. package/src/ux/key-decoder.mjs +281 -0
  37. package/src/ux/layout.mjs +195 -0
  38. package/src/ux/log-viewer.mjs +110 -0
  39. package/src/ux/palette.mjs +179 -0
  40. package/src/ux/queue-model.mjs +206 -0
  41. package/src/ux/renderer.mjs +188 -0
  42. package/src/ux/swarm-model.mjs +146 -0
  43. package/src/ux/terminal-session.mjs +215 -0
  44. package/src/ux/widgets.mjs +323 -0
  45. package/src/webhook.mjs +120 -0
  46. package/src/wizard-task.mjs +6 -0
@@ -0,0 +1,164 @@
1
+ import { readFileSync, existsSync, appendFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { getStateDir, ensureDir } from "./state.mjs";
5
+ import { redactSecrets } from "./security.mjs";
6
+
7
+ /**
8
+ * Appends a structured remediation pattern entry to .agent/state/remediations.jsonl
9
+ * when an OODA repair loop successfully resolves a failure.
10
+ *
11
+ * @param {string} root - Project root directory
12
+ * @param {Object} entry - Remediation payload
13
+ * @param {string} [entry.fingerprint] - SHA-256 failure fingerprint from fingerprintFailureState()
14
+ * @param {Array<string>} [entry.targetFiles] - Array of affected file paths
15
+ * @param {string} [entry.symptom] - Error output or failure description
16
+ * @param {string} [entry.remediationHint] - Actionable fix description or diff summary
17
+ * @returns {Object} Saved remediation entry
18
+ */
19
+ export function recordRemediation(root, entry = {}) {
20
+ const stateDir = getStateDir(root);
21
+ ensureDir(stateDir);
22
+ const filePath = join(stateDir, "remediations.jsonl");
23
+
24
+ const targetFiles = Array.isArray(entry.targetFiles)
25
+ ? entry.targetFiles.map((f) => String(f).replace(/\\/g, "/"))
26
+ : entry.targetFile
27
+ ? [String(entry.targetFile).replace(/\\/g, "/")]
28
+ : [];
29
+
30
+ const record = {
31
+ id: entry.id || `rem-${Date.now()}-${randomUUID().substring(0, 8)}`,
32
+ timestamp: new Date().toISOString(),
33
+ fingerprint: String(entry.fingerprint || "").trim(),
34
+ targetFiles,
35
+ symptom: redactSecrets(String(entry.symptom || "").trim()),
36
+ remediationHint: redactSecrets(String(entry.remediationHint || "").trim()),
37
+ };
38
+
39
+ appendFileSync(filePath, JSON.stringify(record) + "\n", "utf-8");
40
+ return record;
41
+ }
42
+
43
+ /**
44
+ * Reads and queries remediation records from .agent/state/remediations.jsonl.
45
+ * Matches by targetFiles (path token overlap) or failure fingerprint.
46
+ *
47
+ * @param {string} root - Project root directory
48
+ * @param {Object} [query] - Query parameters
49
+ * @param {Array<string>} [query.targetFiles] - Files to match against remediations
50
+ * @param {string} [query.fingerprint] - Fingerprint to match against remediations
51
+ * @param {number} [query.limit=5] - Maximum number of matching remediations to return
52
+ * @returns {Array<Object>} List of matching remediation records
53
+ */
54
+ export function queryRemediations(root, query = {}) {
55
+ const stateDir = getStateDir(root);
56
+ const filePath = join(stateDir, "remediations.jsonl");
57
+
58
+ if (!existsSync(filePath)) {
59
+ return [];
60
+ }
61
+
62
+ const targetFiles = (query.targetFiles || []).map((f) => String(f).replace(/\\/g, "/").toLowerCase());
63
+ const fingerprint = String(query.fingerprint || "").trim();
64
+ const limit = Math.max(1, Number(query.limit) || 5);
65
+
66
+ try {
67
+ const raw = readFileSync(filePath, "utf-8");
68
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
69
+ const matches = [];
70
+
71
+ for (let i = lines.length - 1; i >= 0; i--) {
72
+ if (matches.length >= limit) break;
73
+ try {
74
+ const record = JSON.parse(lines[i]);
75
+ if (!record || typeof record !== "object") continue;
76
+
77
+ let isMatch = false;
78
+
79
+ // Exact fingerprint match
80
+ if (fingerprint && record.fingerprint === fingerprint) {
81
+ isMatch = true;
82
+ }
83
+
84
+ // File path match
85
+ if (!isMatch && targetFiles.length > 0 && Array.isArray(record.targetFiles)) {
86
+ const recordFiles = record.targetFiles.map((f) => String(f).toLowerCase());
87
+ for (const tf of targetFiles) {
88
+ if (recordFiles.some((rf) => rf === tf || rf.endsWith(tf) || tf.endsWith(rf))) {
89
+ isMatch = true;
90
+ break;
91
+ }
92
+ }
93
+ }
94
+
95
+ // If no filter query was specified, match all records
96
+ if (!fingerprint && targetFiles.length === 0) {
97
+ isMatch = true;
98
+ }
99
+
100
+ if (isMatch) {
101
+ matches.push(record);
102
+ }
103
+ } catch (_) {}
104
+ }
105
+
106
+ return matches;
107
+ } catch (_) {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Harvests a failure state or OODA repair outcome into remediations.jsonl.
114
+ *
115
+ * @param {string} root - Project root directory
116
+ * @param {Object} details - Failure details
117
+ * @param {string} [details.symptom] - Error message or failure trace
118
+ * @param {string} [details.remediationHint] - Fix description or diff snippet
119
+ * @param {Array<string>} [details.targetFiles] - Affected target files
120
+ * @param {string} [details.fingerprint] - Optional fingerprint
121
+ * @returns {Object|null} Harvested remediation record
122
+ */
123
+ export function harvestFailureRecord(root, details = {}) {
124
+ if (!details || (!details.symptom && !details.remediationHint)) {
125
+ return null;
126
+ }
127
+ return recordRemediation(root, {
128
+ symptom: details.symptom || details.error || "OODA Failure Detected",
129
+ remediationHint: details.remediationHint || details.solution || details.diff || "Verify contract and run test suite.",
130
+ targetFiles: details.targetFiles || details.referencedPaths || [],
131
+ fingerprint: details.fingerprint || "",
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Queries relevant past failure remediations and returns a formatted prompt block
137
+ * for memory hydration.
138
+ *
139
+ * @param {string} root - Project root directory
140
+ * @param {Object} [options]
141
+ * @param {Array<string>} [options.targetFiles=[]] - Files associated with current task
142
+ * @param {string} [options.fingerprint=""] - Failure fingerprint
143
+ * @param {number} [options.limit=3] - Maximum memory records to hydrate
144
+ * @returns {string} Formatted [LEARNED_REMEDIATIONS_CONTEXT] section string (or empty string if none match)
145
+ */
146
+ export function hydrateMemory(root, options = {}) {
147
+ const matches = queryRemediations(root, {
148
+ targetFiles: options.targetFiles || [],
149
+ fingerprint: options.fingerprint || "",
150
+ limit: options.limit || 3,
151
+ });
152
+
153
+ if (!matches || matches.length === 0) {
154
+ return "";
155
+ }
156
+
157
+ const items = matches.map((m, idx) => {
158
+ const filesStr = Array.isArray(m.targetFiles) && m.targetFiles.length > 0 ? ` (Files: ${m.targetFiles.join(", ")})` : "";
159
+ return `${idx + 1}. [Symptom${filesStr}]: ${m.symptom}\n [Proven Fix]: ${m.remediationHint}`;
160
+ });
161
+
162
+ return `[LEARNED_REMEDIATIONS_CONTEXT]\nThe following historical failure-and-fix patterns were harvested from previous OODA runs in this repository. Avoid repeating these failure modes:\n\n${items.join("\n\n")}`;
163
+ }
164
+
package/src/security.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync } from "node:fs";
1
+ import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join, basename } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { normalizePath } from "./config.mjs";
@@ -61,18 +61,26 @@ export function safeAtomicWrite(filePath, content, options = {}) {
61
61
  const dir = dirname(filePath);
62
62
  const tempFile = join(dir, `.tmp-${basename(filePath)}-${randomBytes(6).toString("hex")}`);
63
63
 
64
- const fd = openSync(tempFile, "wx", mode);
64
+ let fd;
65
65
  try {
66
+ fd = openSync(tempFile, "wx", mode);
66
67
  writeSync(fd, content, null, encoding);
67
68
  if (options.sync !== false) {
68
69
  fsyncSync(fd);
69
70
  }
70
- } finally {
71
71
  closeSync(fd);
72
+ fd = undefined;
73
+ renameSync(tempFile, filePath);
74
+ return true;
75
+ } catch (err) {
76
+ if (fd !== undefined) {
77
+ try { closeSync(fd); } catch (_) {}
78
+ }
79
+ try {
80
+ if (existsSync(tempFile)) unlinkSync(tempFile);
81
+ } catch (_) {}
82
+ throw err;
72
83
  }
73
-
74
- renameSync(tempFile, filePath);
75
- return true;
76
84
  }
77
85
 
78
86
  export function shannonEntropy(str) {
@@ -119,7 +119,10 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
119
119
  return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
120
120
  }
121
121
 
122
- // 5. Python / Elixir / Ruby / Java
122
+ // 5. Python / Django / Elixir / Ruby / Java
123
+ if (existsSync(join(projectRoot, "manage.py"))) {
124
+ return { ...container, stack: "django", testCmd: "python manage.py test --keepdb", buildCmd: "python manage.py check", triggerFile: "manage.py" };
125
+ }
123
126
  if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
124
127
  const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
125
128
  return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile };
@@ -138,14 +141,21 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
138
141
  return { ...container, stack: "gradle", testCmd: "./gradlew test", buildCmd: "./gradlew assemble", triggerFile };
139
142
  }
140
143
 
141
- // 6. JS / TS Runtimes
144
+ // 6. JS / TS Runtimes & ORMs (Prisma, Drizzle)
145
+ let setupCmd = "";
146
+ if (existsSync(join(projectRoot, "prisma", "schema.prisma"))) {
147
+ setupCmd = "npx prisma db push --schema=prisma/schema.prisma";
148
+ } else if (existsSync(join(projectRoot, "drizzle.config.ts")) || existsSync(join(projectRoot, "drizzle.config.js"))) {
149
+ setupCmd = "npx drizzle-kit push";
150
+ }
151
+
142
152
  if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb"))) {
143
153
  const triggerFile = existsSync(join(projectRoot, "bunfig.toml")) ? "bunfig.toml" : "bun.lockb";
144
- return { ...container, stack: "bun", testCmd: "bun test", buildCmd: "bun run build", triggerFile };
154
+ return { ...container, stack: "bun", setupCmd, testCmd: "bun test", buildCmd: "bun run build", triggerFile };
145
155
  }
146
156
  if (existsSync(join(projectRoot, "deno.json")) || existsSync(join(projectRoot, "deno.jsonc"))) {
147
157
  const triggerFile = existsSync(join(projectRoot, "deno.json")) ? "deno.json" : "deno.jsonc";
148
- return { ...container, stack: "deno", testCmd: "deno test", buildCmd: "deno task build", triggerFile };
158
+ return { ...container, stack: "deno", setupCmd, testCmd: "deno test", buildCmd: "deno task build", triggerFile };
149
159
  }
150
160
  if (existsSync(join(projectRoot, "package.json"))) {
151
161
  let testScript = "npm test";
@@ -161,7 +171,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
161
171
  testScript = "npm test";
162
172
  buildScript = "npm run build";
163
173
  }
164
- return { ...container, stack: "node", testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
174
+ return { ...container, stack: "node", setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
165
175
  }
166
176
 
167
177
  // 7. .NET / C# / F# / PHP / Python root file extension fallback
package/src/state.mjs CHANGED
@@ -10,10 +10,20 @@ import {
10
10
  fsyncSync,
11
11
  unlinkSync,
12
12
  } from "node:fs";
13
- import { join } from "node:path";
13
+ import { join, basename } from "node:path";
14
14
  import { createHash, randomUUID } from "node:crypto";
15
15
  import { hostname } from "node:os";
16
16
  import { resolveRoot } from "./config.mjs";
17
+ import { redactSecrets } from "./security.mjs";
18
+
19
+ function scrubStateValue(value) {
20
+ if (typeof value === "string") return redactSecrets(value);
21
+ if (Array.isArray(value)) return value.map(scrubStateValue);
22
+ if (value && typeof value === "object") {
23
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubStateValue(child)]));
24
+ }
25
+ return value;
26
+ }
17
27
 
18
28
  export function ensureDir(dirPath) {
19
29
  if (!dirPath || typeof dirPath !== "string" || dirPath.includes("\0")) {
@@ -109,7 +119,7 @@ export function appendLedger(entry, rootOrOpts = resolveRoot()) {
109
119
  }
110
120
 
111
121
  const timestamp = new Date().toISOString();
112
- const rawPayload = { timestamp, ...entry, prevHash };
122
+ const rawPayload = { timestamp, ...scrubStateValue(entry), prevHash };
113
123
  const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
114
124
  const payload = { ...rawPayload, hash };
115
125
 
@@ -321,7 +331,17 @@ export async function withBudget(fn, root = resolveRoot(), limit = 300) {
321
331
  if (err && typeof err === "object") {
322
332
  err.reservationId = reservation.reservationId;
323
333
  }
324
- appendLedger({ event: "budget_reservation_failed", reservationId: reservation.reservationId, error: err.message }, root);
334
+ try {
335
+ rollbackBudgetReservation(root, reservation.reservationId);
336
+ if (err && typeof err === "object") err.budgetReservationRolledBack = true;
337
+ } catch (_) {}
338
+ try {
339
+ appendLedger({
340
+ event: "budget_reservation_failed",
341
+ reservationId: reservation.reservationId,
342
+ error: err?.message || String(err),
343
+ }, root);
344
+ } catch (_) {}
325
345
  throw err;
326
346
  }
327
347
  }
@@ -363,8 +383,8 @@ export function isPidAlive(pid, expectedStartTime = null) {
363
383
  if (!pid || typeof pid !== "number") return false;
364
384
  try {
365
385
  process.kill(pid, 0);
366
- } catch (_) {
367
- return false;
386
+ } catch (err) {
387
+ if (err?.code !== "EPERM") return false;
368
388
  }
369
389
 
370
390
  if (expectedStartTime !== null && expectedStartTime !== undefined && process.platform === "linux") {
@@ -381,7 +401,14 @@ export function isPidAlive(pid, expectedStartTime = null) {
381
401
  return true;
382
402
  }
383
403
 
404
+ function assertSafeTaskId(taskId) {
405
+ if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || basename(taskId) !== taskId) {
406
+ throw new Error("Invalid task id for lock path");
407
+ }
408
+ }
409
+
384
410
  export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot(), opts = {}) {
411
+ assertSafeTaskId(taskId);
385
412
  const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
386
413
  const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
387
414
  const lockDir = getLockDir(root);
@@ -405,11 +432,13 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
405
432
  }
406
433
 
407
434
  const startTime = getProcessStartTime(process.pid);
435
+ const concurrencyGroup = String(opts?.concurrencyGroup || opts?.concurrency_group || "").trim();
408
436
  const payload = {
409
437
  agent: agentName,
410
438
  taskId,
411
439
  branch,
412
440
  files,
441
+ concurrencyGroup,
413
442
  pid: process.pid,
414
443
  processStartTime: startTime,
415
444
  starttime: startTime,
@@ -418,11 +447,11 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
418
447
  acquiredAt: new Date().toISOString(),
419
448
  };
420
449
 
450
+ let fd;
421
451
  try {
422
- const fd = openSync(lockFile, "wx");
452
+ fd = openSync(lockFile, "wx");
423
453
  writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
424
454
  fsyncSync(fd);
425
- closeSync(fd);
426
455
  return { ok: true, lockFile };
427
456
  } catch (err) {
428
457
  if (err.code === "EEXIST") {
@@ -433,11 +462,21 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
433
462
  return { ok: false, holder: "unknown", taskId };
434
463
  }
435
464
  }
465
+ try { unlinkSync(lockFile); } catch (_) {}
436
466
  throw err;
467
+ } finally {
468
+ if (fd !== undefined) {
469
+ try { closeSync(fd); } catch (_) {}
470
+ }
437
471
  }
438
472
  }
439
473
 
440
474
  export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
475
+ try {
476
+ assertSafeTaskId(taskId);
477
+ } catch (_) {
478
+ return false;
479
+ }
441
480
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
442
481
  const lockDir = getLockDir(root);
443
482
  const lockFile = join(lockDir, `${taskId}.json`);
@@ -469,3 +508,28 @@ export function lockStatus(rootOrOpts = resolveRoot()) {
469
508
  return locks;
470
509
  }
471
510
 
511
+ /**
512
+ * Checks if a specific concurrency group is currently locked by an active task session.
513
+ *
514
+ * @param {string} groupName - Concurrency group name
515
+ * @param {string|Object} [rootOrOpts=resolveRoot()] - Project root path or options
516
+ * @param {string} [excludeTaskId=""] - Optional task ID to exclude from check
517
+ * @returns {boolean} True if group is active/locked
518
+ */
519
+ export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(), excludeTaskId = "") {
520
+ if (!groupName || typeof groupName !== "string" || !groupName.trim()) {
521
+ return false;
522
+ }
523
+ const targetGroup = groupName.trim().toLowerCase();
524
+ const locks = lockStatus(rootOrOpts);
525
+ for (const lock of locks) {
526
+ if (excludeTaskId && lock.taskId === excludeTaskId) continue;
527
+ const lockGroup = String(lock.concurrencyGroup || lock.concurrency_group || "").trim().toLowerCase();
528
+ if (lockGroup === targetGroup) {
529
+ return true;
530
+ }
531
+ }
532
+ return false;
533
+ }
534
+
535
+