jules-orchestrator-kit 0.29.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.29.1",
3
+ "version": "0.31.0",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.mjs CHANGED
@@ -196,9 +196,19 @@ export function detectStack(projectRoot = process.cwd()) {
196
196
  return detectPolyglotStack(projectRoot);
197
197
  }
198
198
 
199
- export function resolveVerify(root = process.cwd()) {
199
+ export function resolveVerify(root = process.cwd(), userVerify = {}) {
200
200
  const s = detectStack(root);
201
- return { test: s.testCmd || "", build: s.buildCmd || "" };
201
+ return {
202
+ setup: userVerify.setup ?? s.setupCmd ?? "",
203
+ test: userVerify.test ?? s.testCmd ?? "",
204
+ teardown: userVerify.teardown ?? s.teardownCmd ?? "",
205
+ build: userVerify.build ?? s.buildCmd ?? "",
206
+ server: userVerify.server ? {
207
+ command: userVerify.server.command || "",
208
+ url: userVerify.server.url || "http://localhost:3000",
209
+ timeoutMs: Number(userVerify.server.timeoutMs || 15000),
210
+ } : null,
211
+ };
202
212
  }
203
213
 
204
214
  export const TIER_PRESETS = {
@@ -247,8 +257,11 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
247
257
  }
248
258
  }
249
259
 
260
+ const setupCmd = parsed.setup_cmd || parsed.verify?.setup || "";
250
261
  const testCmd = parsed.test_cmd || parsed.verify?.test || "";
262
+ const teardownCmd = parsed.teardown_cmd || parsed.verify?.teardown || "";
251
263
  const buildCmd = parsed.build_cmd || parsed.verify?.build || "";
264
+ const verifyTimeoutMs = parsed.verify?.timeoutMs ?? parsed.verify?.timeout_ms ?? 60000;
252
265
  const autoVerify = resolveVerify(root);
253
266
 
254
267
  const activeTier = String(process.env.JULES_TIER || parsed.tier || "ultra").toLowerCase();
@@ -275,8 +288,11 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
275
288
  provider: parsed.provider || DEFAULTS.provider,
276
289
  tier: activeTier,
277
290
  verify: {
291
+ setup: setupCmd || autoVerify.setup || "",
278
292
  test: testCmd || autoVerify.test,
293
+ teardown: teardownCmd || autoVerify.teardown || "",
279
294
  build: buildCmd || autoVerify.build,
295
+ timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 60000,
280
296
  },
281
297
  scope: normalizeScope(parsed),
282
298
  limits: {
@@ -330,3 +330,79 @@ export class DagExecutor {
330
330
  });
331
331
  }
332
332
  }
333
+
334
+ /**
335
+ * Global Contract Trigger List:
336
+ * Files matching these patterns are considered global structural contracts.
337
+ * If ANY modified file matches these patterns, selective test execution MUST yield to full test suite execution (returns null).
338
+ */
339
+ export const GLOBAL_CONTRACT_PATTERNS = [
340
+ /^package(-lock)?\.json$/,
341
+ /^pnpm-lock\.yaml$/,
342
+ /^yarn\.lock$/,
343
+ /^tsconfig.*\.json$/,
344
+ /^schema\./,
345
+ /\.d\.ts$/,
346
+ /^\.env/,
347
+ /^\.agent\//,
348
+ /^Cargo\.(toml|lock)$/,
349
+ /^go\.(mod|sum)$/,
350
+ /^composer\.(json|lock)$/,
351
+ ];
352
+
353
+ /**
354
+ * Checks if a relative file path matches global contract triggers.
355
+ */
356
+ export function isGlobalContractFile(filePath = "") {
357
+ if (typeof filePath !== "string") return false;
358
+ const normalized = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
359
+ const baseName = normalized.split("/").pop();
360
+ return GLOBAL_CONTRACT_PATTERNS.some((pattern) => pattern.test(normalized) || pattern.test(baseName));
361
+ }
362
+
363
+ /**
364
+ * Resolves affected test files based on modified files and static import analysis.
365
+ * Returns null if full test suite should be executed (due to global contract change or unknown dependency),
366
+ * or an array of affected test file paths.
367
+ */
368
+ export function resolveAffectedTests(modifiedFiles = [], options = {}) {
369
+ if (!Array.isArray(modifiedFiles) || modifiedFiles.length === 0) {
370
+ return [];
371
+ }
372
+
373
+ // 1. Check Global Contract Trigger List
374
+ for (const file of modifiedFiles) {
375
+ if (isGlobalContractFile(file)) {
376
+ return null; // Force full test suite execution
377
+ }
378
+ }
379
+
380
+ const knownTestFiles = Array.isArray(options.knownTestFiles) ? options.knownTestFiles : [];
381
+ const testSuffixRegex = options.testSuffixRegex || /\.(test|spec)\.(m?[jt]sx?|py|rs|go)$/i;
382
+
383
+ const affected = new Set();
384
+
385
+ for (const file of modifiedFiles) {
386
+ if (typeof file !== "string") continue;
387
+ const normalized = file.replace(/\\/g, "/").replace(/^\.\//, "");
388
+
389
+ // If the modified file itself is a test file, add it directly
390
+ if (testSuffixRegex.test(normalized)) {
391
+ affected.add(normalized);
392
+ continue;
393
+ }
394
+
395
+ // Match known test files that import or reference this file/module
396
+ const baseNameNoExt = normalized.split("/").pop().replace(/\.[^/.]+$/, "");
397
+ for (const testFile of knownTestFiles) {
398
+ if (typeof testFile !== "string") continue;
399
+ const normTest = testFile.replace(/\\/g, "/").replace(/^\.\//, "");
400
+ if (normTest.includes(baseNameNoExt)) {
401
+ affected.add(normTest);
402
+ }
403
+ }
404
+ }
405
+
406
+ return affected.size > 0 ? Array.from(affected) : null;
407
+ }
408
+
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.
@@ -128,8 +143,11 @@ export async function gate(opts = {}) {
128
143
  trustedScope = normalizeScope(parsed);
129
144
  if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
130
145
  trustedVerify = {
146
+ setup: parsed.verify?.setup || config.verify.setup,
131
147
  test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
148
+ teardown: parsed.verify?.teardown || config.verify.teardown,
132
149
  build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
150
+ timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
133
151
  };
134
152
  }
135
153
  } catch (_) {}
@@ -201,34 +219,79 @@ export async function gate(opts = {}) {
201
219
  let flakyVerdictResult = null;
202
220
 
203
221
  if (testCmd) {
204
- const startTime = Date.now();
205
- const res = runCmd(testCmd, { cwd: root, ignoreError: true, env: testEnv });
206
- const durationMs = Date.now() - startTime;
207
- testResult = { ok: res.status === 0, status: res.status, stdout: res.stdout, stderr: res.stderr, command: testCmd };
208
-
209
- const fingerprint = !testResult.ok ? fingerprintFailureState(testResult, root) : null;
210
- recordVerifyRun(root, testCmd, testResult.ok, fingerprint, durationMs);
211
-
212
- if (!testResult.ok) {
213
- const runs = readVerifyRuns(root, testCmd);
214
- flakyVerdictResult = flakyVerdict(runs);
215
- if (flakyVerdictResult.verdict === "QUARANTINED") {
216
- phases.push({ phase: "verify", ok: false, testResult, buildResult, flakyVerdict: flakyVerdictResult });
217
- appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
218
- appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
219
- 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 (_) {}
220
272
  }
221
273
  }
222
274
  }
223
275
 
224
276
  if (testResult.ok && buildCmd) {
225
277
  const res = runCmd(buildCmd, { cwd: root, ignoreError: true, env: testEnv });
226
- 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
+ };
285
+ }
286
+
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);
227
290
  }
228
291
 
229
- const verifyOk = testResult.ok && buildResult.ok;
230
- const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : null;
231
- phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult });
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 });
232
295
  appendTelemetry(root, "gate_phase", { phase: "verify", ok: verifyOk });
233
296
  if (progressBus && progressToken) {
234
297
  progressBus.reportProgress(progressToken, 100, 100, "Phase 4/4: Automated Test & Build Verification complete");
@@ -342,7 +405,7 @@ export async function repair(failure, opts = {}) {
342
405
  );
343
406
  } catch (err) {
344
407
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
345
- if (err.reservationId) {
408
+ if (err.reservationId && !err.budgetReservationRolledBack) {
346
409
  rollbackBudgetReservation(root, err.reservationId);
347
410
  }
348
411
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -473,7 +536,10 @@ Stderr:
473
536
  ${cleanStderr}`;
474
537
  }
475
538
 
476
- 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
+ }
477
543
  const root = opts.config?._root || opts.root || process.cwd();
478
544
  const config = opts.config || loadConfig(root);
479
545
  const provider = createProvider(config.provider, config);
@@ -503,7 +569,7 @@ export async function dispatch(task, opts = {}) {
503
569
  );
504
570
  } catch (err) {
505
571
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
506
- if (err.reservationId) {
572
+ if (err.reservationId && !err.budgetReservationRolledBack) {
507
573
  rollbackBudgetReservation(root, err.reservationId);
508
574
  }
509
575
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -534,7 +600,8 @@ export async function run(tasksOrOpts = {}, opts = {}) {
534
600
 
535
601
  const root = options.root || process.cwd();
536
602
  const config = options.config || loadConfig(root);
537
- 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;
538
605
  const isDry = options.dryRun !== undefined ? options.dryRun : (process.env.JULES_DRY_RUN === "true" || process.env.JULES_DRY_RUN === "1");
539
606
  const queueDir = getQueueDir(root);
540
607
  const completedDir = join(queueDir, "completed");
@@ -553,9 +620,12 @@ export async function run(tasksOrOpts = {}, opts = {}) {
553
620
  const batch = filesToProcess.slice(i, i + concurrency);
554
621
  await Promise.all(
555
622
  batch.map(async (fileOrItem) => {
556
- const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem.id || fileOrItem.file || fileOrItem.title || "task";
557
- const srcPath = join(queueDir, fileName);
623
+ const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem?.id || fileOrItem?.file || fileOrItem?.title || "task";
558
624
  try {
625
+ if (!isSafeQueueFileName(fileName)) {
626
+ throw new Error(`Unsafe queue task path: ${fileName}`);
627
+ }
628
+ const srcPath = join(queueDir, fileName);
559
629
  let prompt = typeof fileOrItem === "object" && fileOrItem.prompt ? fileOrItem.prompt : "";
560
630
  let title = typeof fileOrItem === "object" && fileOrItem.title ? fileOrItem.title : fileName;
561
631
  if (!prompt && existsSync(srcPath)) {
@@ -569,6 +639,9 @@ export async function run(tasksOrOpts = {}, opts = {}) {
569
639
  } else {
570
640
  const dstPath = join(completedDir, fileName);
571
641
  if (existsSync(srcPath)) {
642
+ if (existsSync(dstPath)) {
643
+ throw new Error(`Completed task destination already exists: ${fileName}`);
644
+ }
572
645
  renameSync(srcPath, dstPath);
573
646
  }
574
647
  appendLedger({ event: "task_completed", file: fileName, session }, root);
@@ -583,3 +656,195 @@ export async function run(tasksOrOpts = {}, opts = {}) {
583
656
 
584
657
  return { processed: results.length, results };
585
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
@@ -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,