jules-orchestrator-kit 0.29.0 → 0.31.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/src/engine.mjs CHANGED
@@ -6,11 +6,26 @@ import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReserva
6
6
  import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
7
7
  import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
8
8
  import fs, { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { join, basename } from "node:path";
10
10
  import { createHash } from "node:crypto";
11
- import { appendTelemetry } from "./telemetry.mjs";
11
+ import { appendTelemetry as appendTelemetryUnsafe } from "./telemetry.mjs";
12
12
 
13
- export { recordVerifyRun, readVerifyRuns, flakyVerdict, sanitizeUntrustedData };
13
+ import { spawn } from "node:child_process";
14
+ import { resolveAffectedTests } from "./dag-engine.mjs";
15
+
16
+ export { recordVerifyRun, readVerifyRuns, flakyVerdict, sanitizeUntrustedData, resolveAffectedTests };
17
+
18
+ function appendTelemetry(root, kind, fields = {}) {
19
+ try {
20
+ return appendTelemetryUnsafe(root, kind, fields);
21
+ } catch (_) {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function isSafeQueueFileName(fileName) {
27
+ return typeof fileName === "string" && fileName.length > 0 && basename(fileName) === fileName && fileName !== "." && fileName !== "..";
28
+ }
14
29
 
15
30
  /**
16
31
  * Validates whether a file in .agent/jules-queue/ is a task file.
@@ -46,14 +61,8 @@ export function isTaskFile(fileName, queueDirOrContent = null) {
46
61
  }
47
62
  if (typeof content === "string") {
48
63
  const trimmed = content.trimStart();
49
- if (trimmed.startsWith("---")) {
50
- const endFm = trimmed.indexOf("\n---", 3);
51
- if (endFm !== -1) {
52
- const frontMatter = trimmed.slice(3, endFm);
53
- if (/(?:taskId|envelopeId|baseRef|baseSha|task|title|id|version|capabilities|scope|verify):/i.test(frontMatter)) {
54
- return true;
55
- }
56
- }
64
+ if (trimmed.includes("JULES_TASK_ENVELOPE") || trimmed.includes("# Task ID:") || trimmed.startsWith("---")) {
65
+ return true;
57
66
  }
58
67
  }
59
68
  } catch (_) {}
@@ -134,8 +143,11 @@ export async function gate(opts = {}) {
134
143
  trustedScope = normalizeScope(parsed);
135
144
  if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
136
145
  trustedVerify = {
146
+ setup: parsed.verify?.setup || config.verify.setup,
137
147
  test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
148
+ teardown: parsed.verify?.teardown || config.verify.teardown,
138
149
  build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
150
+ timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
139
151
  };
140
152
  }
141
153
  } catch (_) {}
@@ -207,34 +219,79 @@ export async function gate(opts = {}) {
207
219
  let flakyVerdictResult = null;
208
220
 
209
221
  if (testCmd) {
210
- const startTime = Date.now();
211
- const res = runCmd(testCmd, { cwd: root, ignoreError: true, env: testEnv });
212
- const durationMs = Date.now() - startTime;
213
- testResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: testCmd };
214
-
215
- const fingerprint = !testResult.ok ? fingerprintFailureState(testResult, root) : null;
216
- recordVerifyRun(root, testCmd, testResult.ok, fingerprint, durationMs);
217
-
218
- if (!testResult.ok) {
219
- const runs = readVerifyRuns(root, testCmd);
220
- flakyVerdictResult = flakyVerdict(runs);
221
- if (flakyVerdictResult.verdict === "QUARANTINED") {
222
- phases.push({ phase: "verify", ok: false, testResult, buildResult, flakyVerdict: flakyVerdictResult });
223
- appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
224
- appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
225
- return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
222
+ const setupCmd = trustedVerify.setup;
223
+ const teardownCmd = trustedVerify.teardown;
224
+ const verifyTimeout = trustedVerify.timeoutMs;
225
+
226
+ try {
227
+ if (setupCmd) {
228
+ const setupRes = runCmd(setupCmd, { cwd: root, ignoreError: true, env: testEnv, timeout: verifyTimeout });
229
+ if (setupRes.status !== 0) {
230
+ testResult = {
231
+ ok: false,
232
+ status: setupRes.status,
233
+ stdout: redactSecrets(setupRes.stdout || ""),
234
+ stderr: redactSecrets(setupRes.stderr || ""),
235
+ command: setupCmd,
236
+ phase: "setup",
237
+ };
238
+ }
239
+ }
240
+
241
+ if (testResult.ok) {
242
+ const startTime = Date.now();
243
+ const res = runCmd(testCmd, { cwd: root, ignoreError: true, env: testEnv, timeout: verifyTimeout });
244
+ const durationMs = Date.now() - startTime;
245
+ testResult = {
246
+ ok: res.status === 0,
247
+ status: res.status,
248
+ stdout: redactSecrets(res.stdout || ""),
249
+ stderr: redactSecrets(res.stderr || ""),
250
+ command: testCmd,
251
+ };
252
+
253
+ const fingerprint = !testResult.ok ? fingerprintFailureState(testResult, root) : null;
254
+ recordVerifyRun(root, testCmd, testResult.ok, fingerprint, durationMs);
255
+
256
+ if (!testResult.ok) {
257
+ const runs = readVerifyRuns(root, testCmd);
258
+ flakyVerdictResult = flakyVerdict(runs);
259
+ if (flakyVerdictResult.verdict === "QUARANTINED") {
260
+ phases.push({ phase: "verify", ok: false, testResult, buildResult, flakyVerdict: flakyVerdictResult });
261
+ appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
262
+ appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
263
+ return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
264
+ }
265
+ }
266
+ }
267
+ } finally {
268
+ if (teardownCmd) {
269
+ try {
270
+ runCmd(teardownCmd, { cwd: root, ignoreError: true, env: testEnv });
271
+ } catch (_) {}
226
272
  }
227
273
  }
228
274
  }
229
275
 
230
276
  if (testResult.ok && buildCmd) {
231
277
  const res = runCmd(buildCmd, { cwd: root, ignoreError: true, env: testEnv });
232
- buildResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: buildCmd };
278
+ buildResult = {
279
+ ok: res.status === 0,
280
+ status: res.status,
281
+ stdout: redactSecrets(res.stdout || ""),
282
+ stderr: redactSecrets(res.stderr || ""),
283
+ command: buildCmd,
284
+ };
233
285
  }
234
286
 
235
- const verifyOk = testResult.ok && buildResult.ok;
236
- const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : null;
237
- phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult });
287
+ let serverResult = { ok: true, status: 0 };
288
+ if (testResult.ok && buildResult.ok && trustedVerify.server && trustedVerify.server.command) {
289
+ serverResult = await probeDevServer(trustedVerify.server, root);
290
+ }
291
+
292
+ const verifyOk = testResult.ok && buildResult.ok && serverResult.ok;
293
+ const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : !serverResult.ok ? serverResult : null;
294
+ phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult, serverResult });
238
295
  appendTelemetry(root, "gate_phase", { phase: "verify", ok: verifyOk });
239
296
  if (progressBus && progressToken) {
240
297
  progressBus.reportProgress(progressToken, 100, 100, "Phase 4/4: Automated Test & Build Verification complete");
@@ -348,7 +405,7 @@ export async function repair(failure, opts = {}) {
348
405
  );
349
406
  } catch (err) {
350
407
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
351
- if (err.reservationId) {
408
+ if (err.reservationId && !err.budgetReservationRolledBack) {
352
409
  rollbackBudgetReservation(root, err.reservationId);
353
410
  }
354
411
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -479,7 +536,10 @@ Stderr:
479
536
  ${cleanStderr}`;
480
537
  }
481
538
 
482
- export async function dispatch(task, opts = {}) {
539
+ export async function dispatch(task = {}, opts = {}) {
540
+ if (!task || typeof task !== "object") {
541
+ throw new TypeError("Dispatch task must be an object");
542
+ }
483
543
  const root = opts.config?._root || opts.root || process.cwd();
484
544
  const config = opts.config || loadConfig(root);
485
545
  const provider = createProvider(config.provider, config);
@@ -509,7 +569,7 @@ export async function dispatch(task, opts = {}) {
509
569
  );
510
570
  } catch (err) {
511
571
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
512
- if (err.reservationId) {
572
+ if (err.reservationId && !err.budgetReservationRolledBack) {
513
573
  rollbackBudgetReservation(root, err.reservationId);
514
574
  }
515
575
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -540,7 +600,8 @@ export async function run(tasksOrOpts = {}, opts = {}) {
540
600
 
541
601
  const root = options.root || process.cwd();
542
602
  const config = options.config || loadConfig(root);
543
- const concurrency = options.concurrency || config.limits.concurrency || 1;
603
+ const requestedConcurrency = Number(options.concurrency ?? config.limits.concurrency ?? 1);
604
+ const concurrency = Number.isInteger(requestedConcurrency) && requestedConcurrency > 0 ? requestedConcurrency : 1;
544
605
  const isDry = options.dryRun !== undefined ? options.dryRun : (process.env.JULES_DRY_RUN === "true" || process.env.JULES_DRY_RUN === "1");
545
606
  const queueDir = getQueueDir(root);
546
607
  const completedDir = join(queueDir, "completed");
@@ -559,9 +620,12 @@ export async function run(tasksOrOpts = {}, opts = {}) {
559
620
  const batch = filesToProcess.slice(i, i + concurrency);
560
621
  await Promise.all(
561
622
  batch.map(async (fileOrItem) => {
562
- const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem.id || fileOrItem.file || fileOrItem.title || "task";
563
- const srcPath = join(queueDir, fileName);
623
+ const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem?.id || fileOrItem?.file || fileOrItem?.title || "task";
564
624
  try {
625
+ if (!isSafeQueueFileName(fileName)) {
626
+ throw new Error(`Unsafe queue task path: ${fileName}`);
627
+ }
628
+ const srcPath = join(queueDir, fileName);
565
629
  let prompt = typeof fileOrItem === "object" && fileOrItem.prompt ? fileOrItem.prompt : "";
566
630
  let title = typeof fileOrItem === "object" && fileOrItem.title ? fileOrItem.title : fileName;
567
631
  if (!prompt && existsSync(srcPath)) {
@@ -575,6 +639,9 @@ export async function run(tasksOrOpts = {}, opts = {}) {
575
639
  } else {
576
640
  const dstPath = join(completedDir, fileName);
577
641
  if (existsSync(srcPath)) {
642
+ if (existsSync(dstPath)) {
643
+ throw new Error(`Completed task destination already exists: ${fileName}`);
644
+ }
578
645
  renameSync(srcPath, dstPath);
579
646
  }
580
647
  appendLedger({ event: "task_completed", file: fileName, session }, root);
@@ -589,3 +656,195 @@ export async function run(tasksOrOpts = {}, opts = {}) {
589
656
 
590
657
  return { processed: results.length, results };
591
658
  }
659
+
660
+ /**
661
+ * Synthesizes a structured, evidence-backed PR description body for GitHub PR creation.
662
+ * @param {object} session - Session or dispatch result
663
+ * @param {object} gateResult - Gate evaluation result
664
+ * @param {object} [options]
665
+ * @returns {string} Markdown PR description body
666
+ */
667
+ export function synthesizePrDescription(session = {}, gateResult = {}, options = {}) {
668
+ const sessionId = session.id || session.name || "jules-session";
669
+ const attemptsCount = session.attempts?.length || session._warmAttempts || 1;
670
+ const maxAttempts = options.maxAttempts || 3;
671
+ const durationMs = options.durationMs || session.durationMs || 0;
672
+ const durationSec = (durationMs / 1000).toFixed(1);
673
+ const isWarm = Boolean(session.resumed || session._warmResumed);
674
+
675
+ const phases = gateResult.phases || [];
676
+ const scopePhase = phases.find((p) => p.phase === "scope") || { ok: true, violations: [] };
677
+ const payloadPhase = phases.find((p) => p.phase === "payload") || { ok: true, bytes: 0, limitBytes: 76800 };
678
+ const secretPhase = phases.find((p) => p.phase === "secrets") || { ok: true, findings: [] };
679
+
680
+ const kbDiff = ((payloadPhase.bytes || 0) / 1024).toFixed(1);
681
+ const kbLimit = ((payloadPhase.limitBytes || 76800) / 1024).toFixed(0);
682
+
683
+ const modifiedFiles = options.modifiedFiles || [];
684
+ const affectedTests = resolveAffectedTests(modifiedFiles, options);
685
+
686
+ const prBody = `## 🚀 Autonomous Jules Agent Execution Evidence
687
+
688
+ ### ⏱️ OODA Verification Trace
689
+ - **Session ID:** \`${sessionId}\`
690
+ - **OODA Turns:** \`${attemptsCount}/${maxAttempts}\`
691
+ - **Warm Resumption:** ${isWarm ? "✅ Active Context Stream" : "Cold Start"}
692
+ - **Execution Latency:** \`${durationSec}s\`
693
+
694
+ ### 🛡️ Zero-Trust Security Audit Matrix
695
+ - **Scope Guard:** ${scopePhase.ok ? "✅ PASS (0 protected path violations)" : "❌ FAIL"}
696
+ - **Diff Payload Governor:** ${payloadPhase.ok ? `✅ PASS (${kbDiff} KB / ${kbLimit} KB limit)` : "❌ EXCEEDED"}
697
+ - **Secret Scanner:** ${secretPhase.ok ? "✅ PASS (0 high-entropy leaks detected)" : "❌ LEAKS FOUND"}
698
+
699
+ ### 🧪 Terminal Verification Evidence
700
+ \`\`\`text
701
+ ${options.testOutput || "All verification tests passed with Exit Code 0."}
702
+ \`\`\`
703
+
704
+ ### 📂 AST Impact & Dependency Graph
705
+ ${
706
+ affectedTests === null
707
+ ? "- **Impact Scope:** ⚠️ Global contract change detected (`package.json`/`tsconfig`/`schema`). Full test suite executed."
708
+ : affectedTests.length > 0
709
+ ? `- **Affected Test Suites (${affectedTests.length}):**\n` + affectedTests.map((t) => ` - \`${t}\``).join("\n")
710
+ : "- **Impact Scope:** Isolated leaf implementation change."
711
+ }
712
+
713
+ ---
714
+ *Generated automatically by [jules-orchestrator-kit](https://github.com/FullThrottle83/jules-orchestrator-kit)*
715
+ `;
716
+
717
+ return prBody;
718
+ }
719
+
720
+ /**
721
+ * Live Dev Server & SSR Hydration Smoke Prober.
722
+ * Spawns the server in a detached process group, polls the URL via globalThis.fetch,
723
+ * intercepts SSR hydration errors, and cleanly kills the process group in a finally block.
724
+ * @param {object} serverConfig
725
+ * @param {string} serverConfig.command
726
+ * @param {string} [serverConfig.url="http://localhost:3000"]
727
+ * @param {number} [serverConfig.timeoutMs=15000]
728
+ * @param {string} root
729
+ * @returns {Promise<object>} Probe result { ok: boolean, status: number, error: string, logs: string }
730
+ */
731
+ export async function probeDevServer(serverConfig = {}, root = process.cwd()) {
732
+ if (!serverConfig || !serverConfig.command) {
733
+ return { ok: true, skipped: true, reason: "No server command configured" };
734
+ }
735
+
736
+ const cmd = serverConfig.command;
737
+ const url = serverConfig.url || "http://localhost:3000";
738
+ const timeoutMs = Number(serverConfig.timeoutMs || 15000);
739
+
740
+ const isWin = process.platform === "win32";
741
+ const shellBin = isWin ? (process.env.ComSpec || "cmd.exe") : "/bin/sh";
742
+ const shellArgs = isWin ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
743
+
744
+ let child;
745
+ let stdoutLogs = "";
746
+ let stderrLogs = "";
747
+
748
+ try {
749
+ child = spawn(shellBin, shellArgs, {
750
+ cwd: root,
751
+ detached: true,
752
+ stdio: ["ignore", "pipe", "pipe"],
753
+ env: process.env,
754
+ });
755
+
756
+ if (child.stdout) {
757
+ child.stdout.on("data", (chunk) => {
758
+ stdoutLogs += chunk.toString("utf-8");
759
+ });
760
+ }
761
+ if (child.stderr) {
762
+ child.stderr.on("data", (chunk) => {
763
+ stderrLogs += chunk.toString("utf-8");
764
+ });
765
+ }
766
+
767
+ const startTime = Date.now();
768
+ let pollResponse = null;
769
+ let pollError = null;
770
+
771
+ while (Date.now() - startTime < timeoutMs) {
772
+ if (child.exitCode !== null) {
773
+ return {
774
+ ok: false,
775
+ status: child.exitCode,
776
+ error: `Dev server process exited prematurely with code ${child.exitCode}`,
777
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
778
+ };
779
+ }
780
+
781
+ try {
782
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
783
+ const bodyText = await res.text();
784
+ pollResponse = { status: res.status, bodyText, ok: res.ok };
785
+ break;
786
+ } catch (err) {
787
+ pollError = err;
788
+ }
789
+
790
+ await new Promise((r) => setTimeout(r, 400));
791
+ }
792
+
793
+ if (!pollResponse) {
794
+ return {
795
+ ok: false,
796
+ status: 504,
797
+ error: `Dev server probe timed out after ${timeoutMs}ms attempting to reach ${url}. ${pollError ? pollError.message : ""}`,
798
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
799
+ };
800
+ }
801
+
802
+ const combinedLogs = (stderrLogs + "\n" + pollResponse.bodyText).toLowerCase();
803
+ const hydrationPanics = [
804
+ "hydration failed",
805
+ "text content did not match",
806
+ "unhandled runtime error",
807
+ "minified react error #418",
808
+ "minified react error #423",
809
+ "minified react error #425",
810
+ ];
811
+
812
+ const detectedPanic = hydrationPanics.find((panic) => combinedLogs.includes(panic));
813
+ if (detectedPanic) {
814
+ return {
815
+ ok: false,
816
+ status: 500,
817
+ error: `SSR Hydration Smoke Probe Failure: Detected '${detectedPanic}' error in server response or stderr.`,
818
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
819
+ };
820
+ }
821
+
822
+ if (!pollResponse.ok) {
823
+ return {
824
+ ok: false,
825
+ status: pollResponse.status,
826
+ error: `Dev server responded with HTTP status ${pollResponse.status}`,
827
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
828
+ };
829
+ }
830
+
831
+ return {
832
+ ok: true,
833
+ status: pollResponse.status,
834
+ url,
835
+ logs: stdoutLogs.slice(-500),
836
+ };
837
+ } finally {
838
+ if (child && child.pid) {
839
+ try {
840
+ if (isWin) {
841
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
842
+ } else {
843
+ process.kill(-child.pid, "SIGTERM");
844
+ }
845
+ } catch (_) {}
846
+ }
847
+ }
848
+ }
849
+
850
+
package/src/envelope.mjs CHANGED
@@ -2,7 +2,7 @@ import { git, runCmd, resolveBase } from "./git.mjs";
2
2
  import { checkScope } from "./security.mjs";
3
3
  import { normalizeScope } from "./config.mjs";
4
4
  import { existsSync } from "node:fs";
5
- import { join } from "node:path";
5
+ import { resolve, relative, isAbsolute } from "node:path";
6
6
 
7
7
  /**
8
8
  * Validates a Task Envelope before dispatch or during CI triage.
@@ -67,7 +67,12 @@ export function validateEnvelope(envelope = {}, opts = {}) {
67
67
  if (Array.isArray(envelope.referenced_paths)) {
68
68
  for (const relPath of envelope.referenced_paths) {
69
69
  if (typeof relPath !== "string") continue;
70
- const absPath = join(root, relPath);
70
+ const absPath = resolve(root, relPath);
71
+ const rootRelative = relative(resolve(root), absPath);
72
+ if (isAbsolute(rootRelative) || rootRelative === ".." || rootRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
73
+ errors.push(`Premise failure: referenced path '${relPath}' escapes repository root.`);
74
+ continue;
75
+ }
71
76
  let existsInGit = false;
72
77
  if (resolvedBase) {
73
78
  const catRes = runCmd(["git", "cat-file", "-e", `${resolvedBase}:${relPath}`], { cwd: root, ignoreError: true });
@@ -103,3 +108,20 @@ export function validateEnvelope(envelope = {}, opts = {}) {
103
108
  warnings,
104
109
  };
105
110
  }
111
+
112
+ /**
113
+ * Parse JSON metadata payload embedded in HTML comment JULES_TASK_ENVELOPE.
114
+ * @param {string} content
115
+ * @returns {Record<string, any> | null}
116
+ */
117
+ export function parseEnvelopeHeader(content) {
118
+ if (!content) return null;
119
+ const match = String(content).match(/<!--\s*JULES_TASK_ENVELOPE:\s*({[\s\S]*?})\s*-->/);
120
+ if (!match) return null;
121
+ try {
122
+ return JSON.parse(match[1]);
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
package/src/mcp.mjs CHANGED
@@ -9,7 +9,7 @@ import { ProgressBus } from "./mcp-progress.mjs";
9
9
 
10
10
  export const MCP_SERVER_INFO = {
11
11
  name: "jules-orchestrator-kit",
12
- version: "0.29.0",
12
+ version: "0.29.1",
13
13
  };
14
14
 
15
15
  export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
@@ -148,6 +148,19 @@ export const MCP_TOOLS = [
148
148
  },
149
149
  },
150
150
  },
151
+ {
152
+ name: "optimize_jules_prompt",
153
+ description: "Evaluate task prompt falsifiability, check static path validity, and synthesize optimized task envelope.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ prompt: { type: "string", description: "Raw task prompt instructions to analyze or optimize" },
158
+ fix: { type: "boolean", description: "If true, synthesizes and returns an optimized task envelope" },
159
+ verifyCmd: { type: "string", description: "Optional explicit verification command" },
160
+ },
161
+ required: ["prompt"],
162
+ },
163
+ },
151
164
  ];
152
165
 
153
166
  export async function handleMcpRequest(request, opts = {}) {
@@ -291,6 +304,27 @@ export async function handleMcpRequest(request, opts = {}) {
291
304
  };
292
305
  }
293
306
 
307
+ if (toolName === "optimize_jules_prompt") {
308
+ if (!args || typeof args.prompt !== "string") {
309
+ return {
310
+ jsonrpc: "2.0",
311
+ id,
312
+ error: { code: -32602, message: "Invalid parameters: 'prompt' must be a string" },
313
+ };
314
+ }
315
+ const { scorePromptFalsifiability, optimizeTaskPrompt } = await import("./task-optimizer.mjs");
316
+ const res = args.fix
317
+ ? optimizeTaskPrompt(args.prompt, { rootDir: root, verifyCmd: args.verifyCmd })
318
+ : scorePromptFalsifiability(args.prompt, { rootDir: root, verifyCmd: args.verifyCmd });
319
+ return {
320
+ jsonrpc: "2.0",
321
+ id,
322
+ result: {
323
+ content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
324
+ },
325
+ };
326
+ }
327
+
294
328
  return {
295
329
  jsonrpc: "2.0",
296
330
  id,
@@ -0,0 +1,167 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { git, changedFiles, diffText } from "../git.mjs";
4
+ import { resolveRoot } from "../config.mjs";
5
+
6
+ export class CheckpointError extends Error {
7
+ constructor(message, opts = {}) {
8
+ super(message);
9
+ this.name = "CheckpointError";
10
+ this.code = opts.code || 1;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Ensures checkpoint storage directory exists.
16
+ */
17
+ export function getCheckpointDir(root = resolveRoot()) {
18
+ const dir = join(root, ".agent", "state", "checkpoints");
19
+ try {
20
+ mkdirSync(dir, { recursive: true });
21
+ } catch (_) {}
22
+ return dir;
23
+ }
24
+
25
+ /**
26
+ * Creates an atomic pre-flight snapshot of the repository state before an agent task runs.
27
+ * @param {string} sessionId
28
+ * @param {object} options
29
+ * @returns {object} Checkpoint metadata
30
+ */
31
+ export function createCheckpoint(sessionId = `session-${Date.now()}`, options = {}) {
32
+ const root = options.root || resolveRoot();
33
+ const dir = getCheckpointDir(root);
34
+
35
+ let headSha = "";
36
+ try {
37
+ headSha = git(["rev-parse", "HEAD"], { cwd: root, ignoreError: true }).trim();
38
+ } catch (_) {}
39
+
40
+ let branch = "";
41
+ try {
42
+ branch = git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, ignoreError: true }).trim();
43
+ } catch (_) {}
44
+
45
+ const uncommittedFiles = changedFiles(root, branch || "main", "working-tree");
46
+ const diffContent = diffText(root, branch || "main", "working-tree");
47
+
48
+ const snapshot = {
49
+ version: 1,
50
+ id: sessionId,
51
+ timestamp: new Date().toISOString(),
52
+ headSha,
53
+ branch,
54
+ uncommittedFiles,
55
+ diffContent,
56
+ };
57
+
58
+ const snapshotPath = join(dir, `${sessionId}.json`);
59
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
60
+
61
+ // Keep last 10 checkpoints
62
+ pruneCheckpoints(root, 10);
63
+
64
+ return snapshot;
65
+ }
66
+
67
+ /**
68
+ * Restores working tree and git state to a previously saved checkpoint.
69
+ * @param {string} sessionId or '--latest'
70
+ * @param {object} options
71
+ * @returns {object} Restore result summary
72
+ */
73
+ export function restoreCheckpoint(sessionId = "--latest", options = {}) {
74
+ const root = options.root || resolveRoot();
75
+ const dir = getCheckpointDir(root);
76
+
77
+ let targetId = sessionId;
78
+ if (!targetId || targetId === "--latest" || targetId === "latest") {
79
+ const list = listCheckpoints(root);
80
+ if (list.length === 0) {
81
+ throw new CheckpointError("No checkpoints found to restore.");
82
+ }
83
+ targetId = list[0].id;
84
+ }
85
+
86
+ const snapshotFile = join(dir, `${targetId}.json`);
87
+ if (!existsSync(snapshotFile)) {
88
+ throw new CheckpointError(`Checkpoint snapshot file not found for session '${targetId}'.`);
89
+ }
90
+
91
+ let snapshot;
92
+ try {
93
+ snapshot = JSON.parse(readFileSync(snapshotFile, "utf-8"));
94
+ } catch (err) {
95
+ throw new CheckpointError(`Corrupted checkpoint file '${targetId}.json': ${err.message}`);
96
+ }
97
+
98
+ if (snapshot.headSha) {
99
+ try {
100
+ git(["reset", "--hard", snapshot.headSha], { cwd: root, ignoreError: true });
101
+ } catch (err) {
102
+ throw new CheckpointError(`Failed git reset --hard ${snapshot.headSha}: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ // Clean untracked files generated during session
107
+ try {
108
+ git(["clean", "-fd"], { cwd: root, ignoreError: true });
109
+ } catch (_) {}
110
+
111
+ return {
112
+ ok: true,
113
+ id: snapshot.id,
114
+ headSha: snapshot.headSha,
115
+ restoredAt: new Date().toISOString(),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Lists all active checkpoints ordered by timestamp descending.
121
+ */
122
+ export function listCheckpoints(root = resolveRoot()) {
123
+ const dir = getCheckpointDir(root);
124
+ if (!existsSync(dir)) return [];
125
+
126
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
127
+ const items = [];
128
+
129
+ for (const file of files) {
130
+ const filePath = join(dir, file);
131
+ try {
132
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
133
+ items.push({
134
+ id: data.id || file.replace(/\.json$/, ""),
135
+ timestamp: data.timestamp || new Date(statSync(filePath).mtimeMs).toISOString(),
136
+ headSha: data.headSha || "",
137
+ branch: data.branch || "",
138
+ filePath,
139
+ });
140
+ } catch (_) {}
141
+ }
142
+
143
+ items.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
144
+ return items;
145
+ }
146
+
147
+ /**
148
+ * Prunes checkpoints keeping only the most recent N sessions.
149
+ */
150
+ export function pruneCheckpoints(root = resolveRoot(), maxRetention = 10) {
151
+ const list = listCheckpoints(root);
152
+ if (list.length <= maxRetention) return 0;
153
+
154
+ const toRemove = list.slice(maxRetention);
155
+ let prunedCount = 0;
156
+
157
+ for (const item of toRemove) {
158
+ try {
159
+ if (existsSync(item.filePath)) {
160
+ rmSync(item.filePath, { force: true });
161
+ prunedCount++;
162
+ }
163
+ } catch (_) {}
164
+ }
165
+
166
+ return prunedCount;
167
+ }