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
package/src/engine.mjs CHANGED
@@ -2,15 +2,32 @@ import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
2
2
  import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
3
  import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
4
4
  import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
5
- import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation } from "./state.mjs";
5
+ import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked } from "./state.mjs";
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
+ import { recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory } from "./remediation.mjs";
16
+
17
+ export { recordVerifyRun, readVerifyRuns, flakyVerdict, sanitizeUntrustedData, resolveAffectedTests, recordRemediation, queryRemediations, harvestFailureRecord, hydrateMemory, isConcurrencyGroupLocked };
18
+
19
+
20
+ function appendTelemetry(root, kind, fields = {}) {
21
+ try {
22
+ return appendTelemetryUnsafe(root, kind, fields);
23
+ } catch (_) {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function isSafeQueueFileName(fileName) {
29
+ return typeof fileName === "string" && fileName.length > 0 && basename(fileName) === fileName && fileName !== "." && fileName !== "..";
30
+ }
14
31
 
15
32
  /**
16
33
  * Validates whether a file in .agent/jules-queue/ is a task file.
@@ -128,8 +145,11 @@ export async function gate(opts = {}) {
128
145
  trustedScope = normalizeScope(parsed);
129
146
  if (parsed.verify || parsed.test_cmd || parsed.build_cmd) {
130
147
  trustedVerify = {
148
+ setup: parsed.verify?.setup || config.verify.setup,
131
149
  test: parsed.verify?.test || parsed.test_cmd || config.verify.test,
150
+ teardown: parsed.verify?.teardown || config.verify.teardown,
132
151
  build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
152
+ timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
133
153
  };
134
154
  }
135
155
  } catch (_) {}
@@ -201,34 +221,79 @@ export async function gate(opts = {}) {
201
221
  let flakyVerdictResult = null;
202
222
 
203
223
  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 };
224
+ const setupCmd = trustedVerify.setup;
225
+ const teardownCmd = trustedVerify.teardown;
226
+ const verifyTimeout = trustedVerify.timeoutMs;
227
+
228
+ try {
229
+ if (setupCmd) {
230
+ const setupRes = runCmd(setupCmd, { cwd: root, ignoreError: true, env: testEnv, timeout: verifyTimeout });
231
+ if (setupRes.status !== 0) {
232
+ testResult = {
233
+ ok: false,
234
+ status: setupRes.status,
235
+ stdout: redactSecrets(setupRes.stdout || ""),
236
+ stderr: redactSecrets(setupRes.stderr || ""),
237
+ command: setupCmd,
238
+ phase: "setup",
239
+ };
240
+ }
241
+ }
242
+
243
+ if (testResult.ok) {
244
+ const startTime = Date.now();
245
+ const res = runCmd(testCmd, { cwd: root, ignoreError: true, env: testEnv, timeout: verifyTimeout });
246
+ const durationMs = Date.now() - startTime;
247
+ testResult = {
248
+ ok: res.status === 0,
249
+ status: res.status,
250
+ stdout: redactSecrets(res.stdout || ""),
251
+ stderr: redactSecrets(res.stderr || ""),
252
+ command: testCmd,
253
+ };
254
+
255
+ const fingerprint = !testResult.ok ? fingerprintFailureState(testResult, root) : null;
256
+ recordVerifyRun(root, testCmd, testResult.ok, fingerprint, durationMs);
257
+
258
+ if (!testResult.ok) {
259
+ const runs = readVerifyRuns(root, testCmd);
260
+ flakyVerdictResult = flakyVerdict(runs);
261
+ if (flakyVerdictResult.verdict === "QUARANTINED") {
262
+ phases.push({ phase: "verify", ok: false, testResult, buildResult, flakyVerdict: flakyVerdictResult });
263
+ appendTelemetry(root, "gate_phase", { phase: "verify", ok: false, quarantined: true });
264
+ appendTelemetry(root, "gate_finished", { ok: false, code: 8 });
265
+ return { ok: false, code: 8, phases, flakyVerdict: flakyVerdictResult };
266
+ }
267
+ }
268
+ }
269
+ } finally {
270
+ if (teardownCmd) {
271
+ try {
272
+ runCmd(teardownCmd, { cwd: root, ignoreError: true, env: testEnv });
273
+ } catch (_) {}
220
274
  }
221
275
  }
222
276
  }
223
277
 
224
278
  if (testResult.ok && buildCmd) {
225
279
  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 };
280
+ buildResult = {
281
+ ok: res.status === 0,
282
+ status: res.status,
283
+ stdout: redactSecrets(res.stdout || ""),
284
+ stderr: redactSecrets(res.stderr || ""),
285
+ command: buildCmd,
286
+ };
227
287
  }
228
288
 
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 });
289
+ let serverResult = { ok: true, status: 0 };
290
+ if (testResult.ok && buildResult.ok && trustedVerify.server && trustedVerify.server.command) {
291
+ serverResult = await probeDevServer(trustedVerify.server, root);
292
+ }
293
+
294
+ const verifyOk = testResult.ok && buildResult.ok && serverResult.ok;
295
+ const failingCmd = !testResult.ok ? testResult : !buildResult.ok ? buildResult : !serverResult.ok ? serverResult : null;
296
+ phases.push({ phase: "verify", ok: verifyOk, testResult, buildResult, serverResult });
232
297
  appendTelemetry(root, "gate_phase", { phase: "verify", ok: verifyOk });
233
298
  if (progressBus && progressToken) {
234
299
  progressBus.reportProgress(progressToken, 100, 100, "Phase 4/4: Automated Test & Build Verification complete");
@@ -342,7 +407,7 @@ export async function repair(failure, opts = {}) {
342
407
  );
343
408
  } catch (err) {
344
409
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
345
- if (err.reservationId) {
410
+ if (err.reservationId && !err.budgetReservationRolledBack) {
346
411
  rollbackBudgetReservation(root, err.reservationId);
347
412
  }
348
413
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -381,6 +446,12 @@ export async function repair(failure, opts = {}) {
381
446
  const gateRes = await gate({ root, config, fix: false, progressBus, progressToken });
382
447
  if (gateRes.ok) {
383
448
  breaker.reset();
449
+ recordRemediation(root, {
450
+ fingerprint: initialFingerprint,
451
+ symptom: currentFailure.stderr || currentFailure.message || "Failure resolved by OODA repair",
452
+ remediationHint: `Resolved on repair attempt #${n}`,
453
+ targetFiles: currentFailure.targetFiles || [],
454
+ });
384
455
  return { ok: true, attempts, finalStatus: "PASSED" };
385
456
  }
386
457
 
@@ -389,6 +460,12 @@ export async function repair(failure, opts = {}) {
389
460
 
390
461
  const check = breaker.observe(currentFingerprint);
391
462
  if (check.tripped) {
463
+ harvestFailureRecord(root, {
464
+ fingerprint: currentFingerprint,
465
+ symptom: currentFailure.stderr || currentFailure.message || "Deterministic Regression",
466
+ remediationHint: `Identical failure state fingerprint (${currentFingerprint}) observed during attempt #${n}`,
467
+ targetFiles: currentFailure.targetFiles || [],
468
+ });
392
469
  return {
393
470
  ok: false,
394
471
  attempts,
@@ -399,7 +476,15 @@ export async function repair(failure, opts = {}) {
399
476
  }
400
477
  }
401
478
 
479
+ harvestFailureRecord(root, {
480
+ fingerprint: initialFingerprint,
481
+ symptom: currentFailure.stderr || currentFailure.message || "OODA Repair Exhausted",
482
+ remediationHint: "OODA repair attempts exhausted without clean verification pass.",
483
+ targetFiles: currentFailure.targetFiles || [],
484
+ });
485
+
402
486
  return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
487
+
403
488
  }
404
489
 
405
490
  /**
@@ -473,7 +558,10 @@ Stderr:
473
558
  ${cleanStderr}`;
474
559
  }
475
560
 
476
- export async function dispatch(task, opts = {}) {
561
+ export async function dispatch(task = {}, opts = {}) {
562
+ if (!task || typeof task !== "object") {
563
+ throw new TypeError("Dispatch task must be an object");
564
+ }
477
565
  const root = opts.config?._root || opts.root || process.cwd();
478
566
  const config = opts.config || loadConfig(root);
479
567
  const provider = createProvider(config.provider, config);
@@ -487,11 +575,15 @@ export async function dispatch(task, opts = {}) {
487
575
  // Redact secrets in prompt before dispatching
488
576
  const cleanPrompt = redactSecrets(task.prompt || "");
489
577
 
490
- // Wire Prompt Guard & Envelope to wrap raw task arguments
578
+ // Wire Prompt Guard, Memory Hydration & Envelope to wrap raw task arguments
491
579
  const taskInstructions = task.taskInstructions || cleanPrompt || task.title || "Autonomous Task Execution";
492
580
  const untrustedData = Array.isArray(task.untrustedData) ? task.untrustedData : [];
493
581
  const systemPolicy = task.systemPolicy || config.systemPolicy || "";
494
- const envelopedPrompt = buildAgentEnvelope(systemPolicy, taskInstructions, untrustedData);
582
+
583
+ const targetFiles = Array.isArray(task.targetFiles) ? task.targetFiles : (Array.isArray(task.referenced_paths) ? task.referenced_paths : []);
584
+ const learnedRemediations = task.learnedRemediations || hydrateMemory(root, { targetFiles, fingerprint: task.fingerprint || "" });
585
+
586
+ const envelopedPrompt = buildAgentEnvelope(systemPolicy, taskInstructions, untrustedData, { learnedRemediations });
495
587
 
496
588
  const cleanTask = { ...task, prompt: envelopedPrompt };
497
589
 
@@ -503,7 +595,7 @@ export async function dispatch(task, opts = {}) {
503
595
  );
504
596
  } catch (err) {
505
597
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
506
- if (err.reservationId) {
598
+ if (err.reservationId && !err.budgetReservationRolledBack) {
507
599
  rollbackBudgetReservation(root, err.reservationId);
508
600
  }
509
601
  const retryAfterMs = err.retryAfterMs || 60000;
@@ -534,7 +626,8 @@ export async function run(tasksOrOpts = {}, opts = {}) {
534
626
 
535
627
  const root = options.root || process.cwd();
536
628
  const config = options.config || loadConfig(root);
537
- const concurrency = options.concurrency || config.limits.concurrency || 1;
629
+ const requestedConcurrency = Number(options.concurrency ?? config.limits.concurrency ?? 1);
630
+ const concurrency = Number.isInteger(requestedConcurrency) && requestedConcurrency > 0 ? requestedConcurrency : 1;
538
631
  const isDry = options.dryRun !== undefined ? options.dryRun : (process.env.JULES_DRY_RUN === "true" || process.env.JULES_DRY_RUN === "1");
539
632
  const queueDir = getQueueDir(root);
540
633
  const completedDir = join(queueDir, "completed");
@@ -553,15 +646,25 @@ export async function run(tasksOrOpts = {}, opts = {}) {
553
646
  const batch = filesToProcess.slice(i, i + concurrency);
554
647
  await Promise.all(
555
648
  batch.map(async (fileOrItem) => {
556
- const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem.id || fileOrItem.file || fileOrItem.title || "task";
557
- const srcPath = join(queueDir, fileName);
649
+ const fileName = typeof fileOrItem === "string" ? fileOrItem : fileOrItem?.id || fileOrItem?.file || fileOrItem?.title || "task";
558
650
  try {
651
+ if (!isSafeQueueFileName(fileName)) {
652
+ throw new Error(`Unsafe queue task path: ${fileName}`);
653
+ }
654
+ const srcPath = join(queueDir, fileName);
559
655
  let prompt = typeof fileOrItem === "object" && fileOrItem.prompt ? fileOrItem.prompt : "";
560
656
  let title = typeof fileOrItem === "object" && fileOrItem.title ? fileOrItem.title : fileName;
561
657
  if (!prompt && existsSync(srcPath)) {
562
658
  prompt = await fs.promises.readFile(srcPath, "utf-8");
563
659
  }
564
660
  const task = typeof fileOrItem === "object" ? { ...fileOrItem, title, prompt } : { title, prompt };
661
+
662
+ const groupName = task.concurrency_group || task.concurrencyGroup || "";
663
+ if (groupName && isConcurrencyGroupLocked(groupName, root, task.id || fileName)) {
664
+ results.push({ file: fileName, ok: false, status: "SKIPPED_CONCURRENCY_GROUP_LOCKED", reason: `Concurrency group '${groupName}' is active` });
665
+ return;
666
+ }
667
+
565
668
  const session = await dispatch(task, { root, config, dryRun: isDry });
566
669
 
567
670
  if (session && session.ok === false) {
@@ -569,6 +672,9 @@ export async function run(tasksOrOpts = {}, opts = {}) {
569
672
  } else {
570
673
  const dstPath = join(completedDir, fileName);
571
674
  if (existsSync(srcPath)) {
675
+ if (existsSync(dstPath)) {
676
+ throw new Error(`Completed task destination already exists: ${fileName}`);
677
+ }
572
678
  renameSync(srcPath, dstPath);
573
679
  }
574
680
  appendLedger({ event: "task_completed", file: fileName, session }, root);
@@ -581,5 +687,198 @@ export async function run(tasksOrOpts = {}, opts = {}) {
581
687
  );
582
688
  }
583
689
 
690
+
584
691
  return { processed: results.length, results };
585
692
  }
693
+
694
+ /**
695
+ * Synthesizes a structured, evidence-backed PR description body for GitHub PR creation.
696
+ * @param {object} session - Session or dispatch result
697
+ * @param {object} gateResult - Gate evaluation result
698
+ * @param {object} [options]
699
+ * @returns {string} Markdown PR description body
700
+ */
701
+ export function synthesizePrDescription(session = {}, gateResult = {}, options = {}) {
702
+ const sessionId = session.id || session.name || "jules-session";
703
+ const attemptsCount = session.attempts?.length || session._warmAttempts || 1;
704
+ const maxAttempts = options.maxAttempts || 3;
705
+ const durationMs = options.durationMs || session.durationMs || 0;
706
+ const durationSec = (durationMs / 1000).toFixed(1);
707
+ const isWarm = Boolean(session.resumed || session._warmResumed);
708
+
709
+ const phases = gateResult.phases || [];
710
+ const scopePhase = phases.find((p) => p.phase === "scope") || { ok: true, violations: [] };
711
+ const payloadPhase = phases.find((p) => p.phase === "payload") || { ok: true, bytes: 0, limitBytes: 76800 };
712
+ const secretPhase = phases.find((p) => p.phase === "secrets") || { ok: true, findings: [] };
713
+
714
+ const kbDiff = ((payloadPhase.bytes || 0) / 1024).toFixed(1);
715
+ const kbLimit = ((payloadPhase.limitBytes || 76800) / 1024).toFixed(0);
716
+
717
+ const modifiedFiles = options.modifiedFiles || [];
718
+ const affectedTests = resolveAffectedTests(modifiedFiles, options);
719
+
720
+ const prBody = `## 🚀 Autonomous Jules Agent Execution Evidence
721
+
722
+ ### ⏱️ OODA Verification Trace
723
+ - **Session ID:** \`${sessionId}\`
724
+ - **OODA Turns:** \`${attemptsCount}/${maxAttempts}\`
725
+ - **Warm Resumption:** ${isWarm ? "✅ Active Context Stream" : "Cold Start"}
726
+ - **Execution Latency:** \`${durationSec}s\`
727
+
728
+ ### 🛡️ Zero-Trust Security Audit Matrix
729
+ - **Scope Guard:** ${scopePhase.ok ? "✅ PASS (0 protected path violations)" : "❌ FAIL"}
730
+ - **Diff Payload Governor:** ${payloadPhase.ok ? `✅ PASS (${kbDiff} KB / ${kbLimit} KB limit)` : "❌ EXCEEDED"}
731
+ - **Secret Scanner:** ${secretPhase.ok ? "✅ PASS (0 high-entropy leaks detected)" : "❌ LEAKS FOUND"}
732
+
733
+ ### 🧪 Terminal Verification Evidence
734
+ \`\`\`text
735
+ ${options.testOutput || "All verification tests passed with Exit Code 0."}
736
+ \`\`\`
737
+
738
+ ### 📂 AST Impact & Dependency Graph
739
+ ${
740
+ affectedTests === null
741
+ ? "- **Impact Scope:** ⚠️ Global contract change detected (`package.json`/`tsconfig`/`schema`). Full test suite executed."
742
+ : affectedTests.length > 0
743
+ ? `- **Affected Test Suites (${affectedTests.length}):**\n` + affectedTests.map((t) => ` - \`${t}\``).join("\n")
744
+ : "- **Impact Scope:** Isolated leaf implementation change."
745
+ }
746
+
747
+ ---
748
+ *Generated automatically by [jules-orchestrator-kit](https://github.com/FullThrottle83/jules-orchestrator-kit)*
749
+ `;
750
+
751
+ return prBody;
752
+ }
753
+
754
+ /**
755
+ * Live Dev Server & SSR Hydration Smoke Prober.
756
+ * Spawns the server in a detached process group, polls the URL via globalThis.fetch,
757
+ * intercepts SSR hydration errors, and cleanly kills the process group in a finally block.
758
+ * @param {object} serverConfig
759
+ * @param {string} serverConfig.command
760
+ * @param {string} [serverConfig.url="http://localhost:3000"]
761
+ * @param {number} [serverConfig.timeoutMs=15000]
762
+ * @param {string} root
763
+ * @returns {Promise<object>} Probe result { ok: boolean, status: number, error: string, logs: string }
764
+ */
765
+ export async function probeDevServer(serverConfig = {}, root = process.cwd()) {
766
+ if (!serverConfig || !serverConfig.command) {
767
+ return { ok: true, skipped: true, reason: "No server command configured" };
768
+ }
769
+
770
+ const cmd = serverConfig.command;
771
+ const url = serverConfig.url || "http://localhost:3000";
772
+ const timeoutMs = Number(serverConfig.timeoutMs || 15000);
773
+
774
+ const isWin = process.platform === "win32";
775
+ const shellBin = isWin ? (process.env.ComSpec || "cmd.exe") : "/bin/sh";
776
+ const shellArgs = isWin ? ["/d", "/s", "/c", cmd] : ["-c", cmd];
777
+
778
+ let child;
779
+ let stdoutLogs = "";
780
+ let stderrLogs = "";
781
+
782
+ try {
783
+ child = spawn(shellBin, shellArgs, {
784
+ cwd: root,
785
+ detached: true,
786
+ stdio: ["ignore", "pipe", "pipe"],
787
+ env: process.env,
788
+ });
789
+
790
+ if (child.stdout) {
791
+ child.stdout.on("data", (chunk) => {
792
+ stdoutLogs += chunk.toString("utf-8");
793
+ });
794
+ }
795
+ if (child.stderr) {
796
+ child.stderr.on("data", (chunk) => {
797
+ stderrLogs += chunk.toString("utf-8");
798
+ });
799
+ }
800
+
801
+ const startTime = Date.now();
802
+ let pollResponse = null;
803
+ let pollError = null;
804
+
805
+ while (Date.now() - startTime < timeoutMs) {
806
+ if (child.exitCode !== null) {
807
+ return {
808
+ ok: false,
809
+ status: child.exitCode,
810
+ error: `Dev server process exited prematurely with code ${child.exitCode}`,
811
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
812
+ };
813
+ }
814
+
815
+ try {
816
+ const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
817
+ const bodyText = await res.text();
818
+ pollResponse = { status: res.status, bodyText, ok: res.ok };
819
+ break;
820
+ } catch (err) {
821
+ pollError = err;
822
+ }
823
+
824
+ await new Promise((r) => setTimeout(r, 400));
825
+ }
826
+
827
+ if (!pollResponse) {
828
+ return {
829
+ ok: false,
830
+ status: 504,
831
+ error: `Dev server probe timed out after ${timeoutMs}ms attempting to reach ${url}. ${pollError ? pollError.message : ""}`,
832
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
833
+ };
834
+ }
835
+
836
+ const combinedLogs = (stderrLogs + "\n" + pollResponse.bodyText).toLowerCase();
837
+ const hydrationPanics = [
838
+ "hydration failed",
839
+ "text content did not match",
840
+ "unhandled runtime error",
841
+ "minified react error #418",
842
+ "minified react error #423",
843
+ "minified react error #425",
844
+ ];
845
+
846
+ const detectedPanic = hydrationPanics.find((panic) => combinedLogs.includes(panic));
847
+ if (detectedPanic) {
848
+ return {
849
+ ok: false,
850
+ status: 500,
851
+ error: `SSR Hydration Smoke Probe Failure: Detected '${detectedPanic}' error in server response or stderr.`,
852
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
853
+ };
854
+ }
855
+
856
+ if (!pollResponse.ok) {
857
+ return {
858
+ ok: false,
859
+ status: pollResponse.status,
860
+ error: `Dev server responded with HTTP status ${pollResponse.status}`,
861
+ logs: (stderrLogs || stdoutLogs).slice(-2000),
862
+ };
863
+ }
864
+
865
+ return {
866
+ ok: true,
867
+ status: pollResponse.status,
868
+ url,
869
+ logs: stdoutLogs.slice(-500),
870
+ };
871
+ } finally {
872
+ if (child && child.pid) {
873
+ try {
874
+ if (isWin) {
875
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
876
+ } else {
877
+ process.kill(-child.pid, "SIGTERM");
878
+ }
879
+ } catch (_) {}
880
+ }
881
+ }
882
+ }
883
+
884
+
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 });
@@ -96,10 +101,35 @@ export function validateEnvelope(envelope = {}, opts = {}) {
96
101
  }
97
102
  }
98
103
 
104
+ // 6. Concurrency Group Check
105
+ if (envelope.concurrency_group !== undefined) {
106
+ if (typeof envelope.concurrency_group !== "string" || !envelope.concurrency_group.trim()) {
107
+ errors.push("concurrency_group must be a non-empty string when provided.");
108
+ }
109
+ }
110
+
99
111
  return {
112
+
100
113
  ok: errors.length === 0,
101
114
  code: errors.length === 0 ? 0 : 1,
102
115
  errors,
103
116
  warnings,
104
117
  };
105
118
  }
119
+
120
+ /**
121
+ * Parse JSON metadata payload embedded in HTML comment JULES_TASK_ENVELOPE.
122
+ * @param {string} content
123
+ * @returns {Record<string, any> | null}
124
+ */
125
+ export function parseEnvelopeHeader(content) {
126
+ if (!content) return null;
127
+ const match = String(content).match(/<!--\s*JULES_TASK_ENVELOPE:\s*({[\s\S]*?})\s*-->/);
128
+ if (!match) return null;
129
+ try {
130
+ return JSON.parse(match[1]);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { resolveRoot } from "./config.mjs";
3
3
  import { resolveBase } from "./git.mjs";
4
4
  import { classifyRiskTier } from "./risk.mjs";
5
+ import { queryRemediations } from "./remediation.mjs";
5
6
 
6
7
  /**
7
8
  * Generates a SHA-256 hash of the execution envelope payload.
@@ -19,6 +20,7 @@ export function hashExecutionEnvelope(envelope) {
19
20
  riskTier: envelope.riskTier,
20
21
  budgetReservationId: envelope.budgetReservationId,
21
22
  capabilities: envelope.capabilities,
23
+ remediations: envelope.remediations || [],
22
24
  createdAt: envelope.createdAt,
23
25
  };
24
26
  const sortedKeys = Object.keys(rawPayload).sort();
@@ -45,11 +47,12 @@ export function freezeExecutionEnvelope(envelope) {
45
47
  }),
46
48
  verify: Object.freeze({ ...(envelope.verify || {}) }),
47
49
  capabilities: Object.freeze([...(envelope.capabilities || [])]),
50
+ remediations: Object.freeze([...(envelope.remediations || [])]),
48
51
  });
49
52
  }
50
53
 
51
54
  /**
52
- * Creates an immutable Capability-Bounded Execution Envelope (CBEE).
55
+ * Creates an immutable Capability-Bounded Execution Envelope (CBEE) with pre-flight remediation hydration.
53
56
  */
54
57
  export function createExecutionEnvelope(task = {}, opts = {}) {
55
58
  const root = opts.root || resolveRoot();
@@ -66,6 +69,9 @@ export function createExecutionEnvelope(task = {}, opts = {}) {
66
69
  const files = task.files || opts.files || [];
67
70
  const riskMeta = classifyRiskTier(files, { diffLines: opts.diffLines || 0 });
68
71
 
72
+ const taskFingerprint = task.fingerprint || opts.fingerprint || null;
73
+ const remediations = queryRemediations(root, { targetFiles: files, fingerprint: taskFingerprint, limit: 3 });
74
+
69
75
  const rawEnvelope = {
70
76
  id: envelopeId,
71
77
  taskId,
@@ -84,6 +90,7 @@ export function createExecutionEnvelope(task = {}, opts = {}) {
84
90
  riskTier: riskMeta.tier,
85
91
  budgetReservationId: opts.reservationId || null,
86
92
  capabilities: opts.capabilities || ["git:read", "git:diff", "test:run"],
93
+ remediations,
87
94
  createdAt: new Date().toISOString(),
88
95
  };
89
96
 
@@ -101,3 +108,4 @@ export function verifyExecutionEnvelope(envelope) {
101
108
  const expectedHash = hashExecutionEnvelope(envelope);
102
109
  return envelope.hash === expectedHash;
103
110
  }
111
+
package/src/git.mjs CHANGED
@@ -131,6 +131,30 @@ export function git(args = [], opts = {}) {
131
131
  }
132
132
  }
133
133
 
134
+ export function ensureBaseFetched(root = process.cwd(), baseRef = "main") {
135
+ try {
136
+ execFileSync("git", ["fetch", "origin", baseRef, "--depth=100"], {
137
+ cwd: root,
138
+ encoding: "utf-8",
139
+ shell: false,
140
+ stdio: ["ignore", "ignore", "ignore"],
141
+ });
142
+ return true;
143
+ } catch (_) {
144
+ try {
145
+ execFileSync("git", ["fetch", "origin", baseRef, "--unshallow"], {
146
+ cwd: root,
147
+ encoding: "utf-8",
148
+ shell: false,
149
+ stdio: ["ignore", "ignore", "ignore"],
150
+ });
151
+ return true;
152
+ } catch (__) {
153
+ return false;
154
+ }
155
+ }
156
+ }
157
+
134
158
  export function resolveBase(root = process.cwd(), baseRef = "main") {
135
159
  const candidates = [`origin/${baseRef}`, `refs/remotes/origin/${baseRef}`, baseRef];
136
160
  for (const ref of candidates) {
@@ -146,6 +170,23 @@ export function resolveBase(root = process.cwd(), baseRef = "main") {
146
170
  }
147
171
  } catch (_) {}
148
172
  }
173
+
174
+ ensureBaseFetched(root, baseRef);
175
+
176
+ for (const ref of candidates) {
177
+ try {
178
+ const res = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
179
+ cwd: root,
180
+ encoding: "utf-8",
181
+ shell: false,
182
+ stdio: ["ignore", "pipe", "ignore"],
183
+ });
184
+ if (res && res.trim()) {
185
+ return res.trim();
186
+ }
187
+ } catch (_) {}
188
+ }
189
+
149
190
  throw new GateError(
150
191
  `Cannot resolve base reference "${baseRef}". Ensure base branch is fetched (e.g., fetch-depth: 0 in CI).`,
151
192
  { code: 1 }