jules-orchestrator-kit 0.38.0 → 0.38.2

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 (44) hide show
  1. package/.agent/rules/jules-protocol.md +3 -0
  2. package/JULES_RULES_TEMPLATE.md +3 -1
  3. package/README.md +163 -307
  4. package/bin/agentctl.mjs +177 -7
  5. package/bin/init.js +8 -8
  6. package/index.mjs +14 -2
  7. package/package.json +1 -1
  8. package/scripts/command-resolver.mjs +1 -2
  9. package/scripts/doc-sync-check.mjs +36 -1
  10. package/scripts/jules-create.mjs +1 -1
  11. package/scripts/jules-dispatch.mjs +1 -2
  12. package/scripts/jules-nightly.mjs +2 -2
  13. package/scripts/jules-patch.mjs +2 -2
  14. package/scripts/jules-queue-runner.mjs +1 -1
  15. package/scripts/jules-scan-todos.mjs +1 -1
  16. package/scripts/jules-self-audit.mjs +1 -2
  17. package/scripts/jules-status.mjs +1 -1
  18. package/scripts/release.mjs +55 -0
  19. package/scripts/run-tests.mjs +15 -1
  20. package/scripts/utils.mjs +14 -9
  21. package/src/asset-integrity.mjs +76 -0
  22. package/src/asset_integrity.mjs +1 -76
  23. package/src/config.mjs +42 -23
  24. package/src/dag-engine.mjs +44 -6
  25. package/src/engine.mjs +18 -11
  26. package/src/execution-envelope.mjs +111 -0
  27. package/src/execution_envelope.mjs +1 -111
  28. package/src/git.mjs +6 -3
  29. package/src/merge-blocks.mjs +178 -1
  30. package/src/ops/command-registry.mjs +25 -0
  31. package/src/ops/handover.mjs +411 -0
  32. package/src/ops/ide-scaffold.mjs +3 -3
  33. package/src/ops/next-step.mjs +6 -2
  34. package/src/prompt-guard.mjs +34 -3
  35. package/src/provider.mjs +191 -86
  36. package/src/role-resolver.mjs +31 -0
  37. package/src/rules-budget.mjs +179 -0
  38. package/src/rules_budget.mjs +1 -179
  39. package/src/security.mjs +98 -56
  40. package/src/state.mjs +11 -6
  41. package/src/task-optimizer.mjs +10 -2
  42. package/src/tui.mjs +1 -1
  43. package/src/webhook.mjs +2 -2
  44. package/src/wizard-task.mjs +4 -30
package/bin/agentctl.mjs CHANGED
@@ -46,7 +46,7 @@ Commands:
46
46
  swarm Run parallel task swarm
47
47
  mcp Start stdio Model Context Protocol (MCP) server
48
48
  clean Clean stale branches, worktrees, locks, and ledgers
49
- lock <action> Manage mutex locks (acquire | release | status | cleanup)
49
+ lock <action> Manage mutex locks (acquire | release | status)
50
50
  doctor Run system diagnostics and stack resolution checks
51
51
  bootstrap Bootstrap zero-test repository with verification oracle
52
52
  review-repair Parse PR review comments and synthesize OODA repair tasks
@@ -58,6 +58,7 @@ Commands:
58
58
  test-gen Scaffold & run automated TDD Red-to-Green test cycle (--run)
59
59
  mcp init Scaffold IDE integration config (cursor | vscode | claude | all)
60
60
  rollback Restore git state & working tree to atomic pre-flight checkpoint
61
+ handover Inspect or generate Baton Pass session handover envelopes (list | show | create | prune)
61
62
  resume Resume warm session with human response (--response "<text>")
62
63
  escalate Dispatch or manage webhook escalation incidents (--flush, --status, --clear)
63
64
  flaky Manage Wilson-quarantined tests and dispatch healing swarm (status | heal | reset)
@@ -259,6 +260,25 @@ async function main() {
259
260
  }
260
261
  console.log(`-----------------------------------------------------`);
261
262
  console.log(`Overall Result: ${res.ok ? "APPROVED (Exit 0)" : `REJECTED (Exit ${res.code})`}\n`);
263
+ if (!res.ok) {
264
+ if (res.code === 3) {
265
+ console.log(`šŸ’” Remediation Hint (Exit 3 Scope Violation):`);
266
+ console.log(` • To allow protected files in this run, pass: agentctl gate --allow-protected`);
267
+ console.log(` • Or remove protected/denied paths from the diff before dispatching.\n`);
268
+ } else if (res.code === 5) {
269
+ console.log(`šŸ’” Remediation Hint (Exit 5 Diff Payload Overflow):`);
270
+ console.log(` • Total diff exceeds ${config.limits?.diffKb || 75} KB limit.`);
271
+ console.log(` • Split the task into smaller atomic tasks using: agentctl task create\n`);
272
+ } else if (res.code === 6) {
273
+ console.log(`šŸ’” Remediation Hint (Exit 6 Secret Leak Prevented):`);
274
+ console.log(` • High-entropy credential or secret detected in patch.`);
275
+ console.log(` • Scrub credential from source and rotate any exposed keys immediately.\n`);
276
+ } else if (res.code === 4) {
277
+ console.log(`šŸ’” Remediation Hint (Exit 4 OODA Repair Exhausted):`);
278
+ console.log(` • Automated self-repair could not pass tests cleanly.`);
279
+ console.log(` • Review error fingerprints via: agentctl doctor\n`);
280
+ }
281
+ }
262
282
  }
263
283
 
264
284
  process.exit(typeof res.code === "number" ? res.code : 0);
@@ -279,7 +299,7 @@ async function main() {
279
299
 
280
300
  const queueDir = getQueueDir(root);
281
301
  const files = readdirSync(queueDir).filter((f) => isTaskFile(f, queueDir));
282
- console.log(`Found ${files.length} queued task(s) in .agent/queue/`);
302
+ console.log(`Found ${files.length} queued task(s) in .agent/jules-queue/`);
283
303
  if (files.length > 0) {
284
304
  const concurrency = values.concurrency ? Number(values.concurrency) : undefined;
285
305
  const results = await run(null, {
@@ -750,13 +770,47 @@ async function main() {
750
770
 
751
771
  case "rollback": {
752
772
  const { restoreCheckpoint } = await import("../src/ops/checkpoint.mjs");
753
- const targetId = args[1] || "--latest";
773
+ const { createHandover } = await import("../src/ops/handover.mjs");
774
+ const { values, positionals } = parseArgs({
775
+ args: args.slice(1),
776
+ options: {
777
+ reason: { type: "string", short: "r" },
778
+ intent: { type: "string", short: "i" },
779
+ handover: { type: "boolean", default: true },
780
+ json: { type: "boolean", short: "j" },
781
+ },
782
+ allowPositionals: true,
783
+ });
784
+
785
+ const targetId = positionals[0] || "--latest";
754
786
  try {
755
787
  const res = restoreCheckpoint(targetId, { root });
756
- console.log(`\nāœ… Git Checkpoint Restored Successfully!`);
757
- console.log(` Session ID : ${res.id}`);
758
- console.log(` HEAD SHA : ${res.headSha || "N/A"}`);
759
- console.log(` RestoredAt : ${res.restoredAt}\n`);
788
+ let hoResult = null;
789
+ if (values.handover !== false) {
790
+ try {
791
+ hoResult = createHandover(root, {
792
+ sessionId: res.id,
793
+ status: "rolled-back",
794
+ intent: values.intent || "Restored working tree to pre-flight checkpoint",
795
+ landmines: values.reason ? [values.reason] : ["Session cancelled or rolled back by operator"],
796
+ branch: res.branch,
797
+ headSha: res.headSha,
798
+ });
799
+ } catch (_) {}
800
+ }
801
+
802
+ if (values.json) {
803
+ console.log(JSON.stringify({ ok: true, ...res, handover: hoResult?.filePath }, null, 2));
804
+ } else {
805
+ console.log(`\nāœ… Git Checkpoint Restored Successfully!`);
806
+ console.log(` Session ID : ${res.id}`);
807
+ console.log(` HEAD SHA : ${res.headSha || "N/A"}`);
808
+ console.log(` RestoredAt : ${res.restoredAt}`);
809
+ if (hoResult) {
810
+ console.log(` Handover : ${hoResult.filePath}`);
811
+ }
812
+ console.log("");
813
+ }
760
814
  process.exit(0);
761
815
  } catch (err) {
762
816
  console.error(`āŒ Rollback Failed: ${err.message}`);
@@ -765,6 +819,122 @@ async function main() {
765
819
  break;
766
820
  }
767
821
 
822
+ case "handover": {
823
+ const {
824
+ createHandover,
825
+ loadHandover,
826
+ listHandovers,
827
+ pruneHandovers,
828
+ formatHandoverPromptContext,
829
+ } = await import("../src/ops/handover.mjs");
830
+
831
+ const subAction = args[1] || "list";
832
+ const { values, positionals } = parseArgs({
833
+ args: args.slice(2),
834
+ options: {
835
+ intent: { type: "string", short: "i" },
836
+ status: { type: "string", short: "s", default: "aborted" },
837
+ completed: { type: "string", short: "c" },
838
+ assumptions: { type: "string", short: "a" },
839
+ landmines: { type: "string", short: "l" },
840
+ "next-steps": { type: "string", short: "n" },
841
+ limit: { type: "string" },
842
+ json: { type: "boolean", short: "j" },
843
+ context: { type: "boolean" },
844
+ },
845
+ allowPositionals: true,
846
+ });
847
+
848
+ if (subAction === "list") {
849
+ let items = listHandovers(root);
850
+ const limitNum = parseInt(values.limit || "20", 10);
851
+ if (!isNaN(limitNum) && limitNum > 0) {
852
+ items = items.slice(0, limitNum);
853
+ }
854
+ if (values.json) {
855
+ console.log(JSON.stringify({ ok: true, count: items.length, handovers: items }, null, 2));
856
+ } else {
857
+ console.log(`\nšŸ“‹ Baton Pass Handover Envelopes (${items.length})`);
858
+ console.log(`--------------------------------------------------`);
859
+ if (items.length === 0) {
860
+ console.log(` (No handovers found in .agent/handovers/)`);
861
+ } else {
862
+ items.forEach((h, idx) => {
863
+ console.log(` ${idx + 1}. [${h.status.toUpperCase()}] ${h.sessionId} (${h.createdAt.substring(0, 16)})`);
864
+ if (h.intent) console.log(` Intent: ${h.intent.substring(0, 80)}`);
865
+ console.log(` File : ${h.filePath}\n`);
866
+ });
867
+ }
868
+ console.log(`--------------------------------------------------\n`);
869
+ }
870
+ process.exit(0);
871
+ }
872
+
873
+ if (subAction === "show") {
874
+ const targetId = positionals[0];
875
+ if (!targetId) {
876
+ console.error("Error: Session ID or file path is required for agentctl handover show <sessionId>");
877
+ process.exit(1);
878
+ }
879
+ try {
880
+ const ho = loadHandover(root, targetId);
881
+ if (values.context) {
882
+ console.log(formatHandoverPromptContext(ho));
883
+ } else if (values.json) {
884
+ console.log(JSON.stringify({ ok: true, handover: ho }, null, 2));
885
+ } else {
886
+ console.log(ho.rawMarkdown);
887
+ }
888
+ process.exit(0);
889
+ } catch (err) {
890
+ console.error(`āŒ Error loading handover: ${err.message}`);
891
+ process.exit(1);
892
+ }
893
+ }
894
+
895
+ if (subAction === "create") {
896
+ const targetId = positionals[0] || `session-${Date.now()}`;
897
+ try {
898
+ const res = createHandover(root, {
899
+ sessionId: targetId,
900
+ status: values.status,
901
+ intent: values.intent,
902
+ completed: values.completed,
903
+ assumptions: values.assumptions,
904
+ landmines: values.landmines,
905
+ nextSteps: values["next-steps"],
906
+ });
907
+ if (values.json) {
908
+ console.log(JSON.stringify({ ok: true, ...res }, null, 2));
909
+ } else {
910
+ console.log(`\nāœ… Handover Envelope Created Successfully!`);
911
+ console.log(` Session ID : ${res.sessionId}`);
912
+ console.log(` Status : ${res.status}`);
913
+ console.log(` File Path : ${res.filePath}\n`);
914
+ }
915
+ process.exit(0);
916
+ } catch (err) {
917
+ console.error(`āŒ Error creating handover: ${err.message}`);
918
+ process.exit(1);
919
+ }
920
+ }
921
+
922
+ if (subAction === "prune") {
923
+ const limitNum = parseInt(values.limit || "20", 10);
924
+ const count = pruneHandovers(root, limitNum);
925
+ if (values.json) {
926
+ console.log(JSON.stringify({ ok: true, pruned: count }, null, 2));
927
+ } else {
928
+ console.log(`āœ… Pruned ${count} old handover manifest(s).`);
929
+ }
930
+ process.exit(0);
931
+ }
932
+
933
+ console.error(`Error: Unknown handover subcommand '${subAction}'. Use list, show, create, or prune.`);
934
+ process.exit(1);
935
+ break;
936
+ }
937
+
768
938
  case "resume": {
769
939
  const { createProvider } = await import("../src/provider.mjs");
770
940
  const sessionId = args[1];
package/bin/init.js CHANGED
@@ -249,14 +249,14 @@ if (fs.existsSync(targetPkgPath) && targetDir !== kitRoot) {
249
249
  pkg.scripts = pkg.scripts || {};
250
250
  let updated = false;
251
251
  const julesScripts = {
252
- "jules:dispatch": "npx agentctl dispatch",
253
- "jules:queue": "npx agentctl queue",
254
- "jules:create": "npx agentctl create",
255
- "jules:status": "npx agentctl status",
256
- "jules:audit": "npx agentctl gate",
257
- "jules:scan": "npx agentctl scan",
258
- "jules:swarm": "npx agentctl swarm",
259
- "jules:nightly": "npx agentctl clean"
252
+ "jules:dispatch": "agentctl dispatch",
253
+ "jules:queue": "agentctl queue",
254
+ "jules:create": "agentctl task create",
255
+ "jules:status": "agentctl status",
256
+ "jules:audit": "agentctl gate",
257
+ "jules:scan": "agentctl scan",
258
+ "jules:swarm": "agentctl swarm",
259
+ "jules:nightly": "agentctl clean"
260
260
  };
261
261
 
262
262
  for (const [key, val] of Object.entries(julesScripts)) {
package/index.mjs CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  ProviderSchemaError,
33
33
  parseRetryAfter,
34
34
  } from "./src/provider.mjs";
35
+ export { resolveRoutedProvider } from "./src/router.mjs";
35
36
  export {
36
37
  detectPolyglotStack,
37
38
  resolveWorkspaceBoundary,
@@ -71,8 +72,8 @@ export {
71
72
  verifyExecutionEnvelope,
72
73
  freezeExecutionEnvelope,
73
74
  hashExecutionEnvelope,
74
- } from "./src/execution_envelope.mjs";
75
- export { checkAssetIntegrity } from "./src/asset_integrity.mjs";
75
+ } from "./src/execution-envelope.mjs";
76
+ export { checkAssetIntegrity } from "./src/asset-integrity.mjs";
76
77
  export { classifyRiskTier, RISK_TIERS } from "./src/risk.mjs";
77
78
  export { recordRemediation, queryRemediations } from "./src/remediation.mjs";
78
79
  export { DagExecutor, DagCycleError } from "./src/dag-engine.mjs";
@@ -178,3 +179,14 @@ export {
178
179
  synthesizeFlakyHealingTask,
179
180
  runFlakyHealingSwarm,
180
181
  } from "./src/flaky-ledger.mjs";
182
+
183
+ // Baton Pass Session Handover Engine
184
+ export {
185
+ createHandover,
186
+ loadHandover,
187
+ listHandovers,
188
+ pruneHandovers,
189
+ formatHandoverPromptContext,
190
+ getHandoverDir,
191
+ HandoverError,
192
+ } from "./src/ops/handover.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for Google Jules (jules) autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Backward compatibility shim for command-resolver.mjs in v0.9.0.
3
- * Re-exports detectStack from src/config.mjs.
2
+ * Project command resolver: infers build and test commands across stacks.
4
3
  */
5
4
 
6
5
  import { detectStack, parseYaml } from "../src/config.mjs";
@@ -53,7 +53,23 @@ export function parseTestCounts(out = "") {
53
53
  const m = String(out).match(new RegExp(`^[^\\n]*?(?:ℹ|#)\\s*${key}\\s+(\\d+)\\s*$`, "m"));
54
54
  return m ? Number(m[1]) : null;
55
55
  };
56
- return { tests: num("tests"), pass: num("pass"), suites: num("suites"), todo: num("todo") };
56
+ // Names, not just a count. This gate runs where nobody is watching the
57
+ // output, and "2 failing" costs a round-trip through CI to turn into
58
+ // something actionable. The spec reporter prints each failure twice (inline
59
+ // and again in its summary), hence the dedupe.
60
+ const failures = [
61
+ ...String(out).matchAll(/^not ok \d+ - (.+?)$/gm),
62
+ ...String(out).matchAll(/^\s*āœ–\s+(.+?)\s+\(\d/gm),
63
+ ].map((m) => m[1].trim());
64
+
65
+ return {
66
+ tests: num("tests"),
67
+ pass: num("pass"),
68
+ suites: num("suites"),
69
+ todo: num("todo"),
70
+ fail: num("fail"),
71
+ failures: [...new Set(failures)],
72
+ };
57
73
  }
58
74
 
59
75
  function readIfExists(path) {
@@ -238,10 +254,29 @@ export function checkDocSync(root = process.cwd(), opts = {}) {
238
254
  const claimedSuites = claim ? Number(claim[2]) : null;
239
255
 
240
256
  let { tests, suites } = opts;
257
+ let measuredFail = null;
258
+ let measuredFailures = [];
241
259
  if (tests === undefined || suites === undefined) {
242
260
  const measured = measureTestCounts(root);
243
261
  tests = tests ?? measured.pass;
244
262
  suites = suites ?? measured.suites;
263
+ measuredFail = measured.fail;
264
+ measuredFailures = measured.failures || [];
265
+ }
266
+
267
+ // A failing suite still prints a summary, so the pass count silently drops
268
+ // and the README check reports a number mismatch — which reads as stale
269
+ // documentation and sends the reader looking for skipped tests. Say what
270
+ // actually happened instead. Only meaningful when this measured the suite
271
+ // itself; release.mjs passes counts in from a run it already verified.
272
+ if (measuredFail !== null) {
273
+ add(
274
+ "test suite passes",
275
+ measuredFail === 0,
276
+ measuredFail === 0
277
+ ? "no failing tests"
278
+ : `${measuredFail} failing: ${measuredFailures.slice(0, 3).join("; ") || "(names unparsed)"}${measuredFailures.length > 3 ? ` …+${measuredFailures.length - 3}` : ""}`
279
+ );
245
280
  }
246
281
 
247
282
  if (claimedTests === null) {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-create.mjs in v0.9.0.
4
+ * Quick task file creator for .agent/jules-queue/.
5
5
  */
6
6
 
7
7
  import { writeFileSync } from "node:fs";
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-dispatch.mjs in v0.9.1.
5
- * Delegates execution to bin/agentctl.mjs dispatch.
4
+ * Task dispatch entrypoint: delegates execution to engine dispatch pipeline.
6
5
  */
7
6
 
8
7
  import { dispatch } from "../src/engine.mjs";
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-nightly.mjs in v0.9.0.
4
+ * Nightly repository maintenance and worktree pruning.
5
5
  */
6
6
 
7
7
  import { worktreePrune } from "../src/git.mjs";
8
8
 
9
9
  if (process.argv[1] && process.argv[1].endsWith("jules-nightly.mjs")) {
10
10
  worktreePrune();
11
- console.log("[Shim] Nightly maintenance complete.");
11
+ console.log("Nightly maintenance complete.");
12
12
  process.exit(0);
13
13
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-patch.mjs in v0.9.0.
4
+ * Session patch fetching utility.
5
5
  */
6
6
 
7
7
  export async function fetchSessionPatch(sessionId, _options = {}) {
@@ -18,6 +18,6 @@ export async function fetchSessionPatch(sessionId, _options = {}) {
18
18
  }
19
19
 
20
20
  if (process.argv[1] && process.argv[1].endsWith("jules-patch.mjs")) {
21
- console.log("[Shim] Patch fetch completed.");
21
+ console.log("Patch fetch completed.");
22
22
  process.exit(0);
23
23
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-queue-runner.mjs in v0.9.0.
4
+ * Queue processing runner: processes envelopes in .agent/jules-queue/.
5
5
  */
6
6
 
7
7
  import { run } from "../src/engine.mjs";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-scan-todos.mjs in v0.9.0.
4
+ * Codebase scanner for TODO and FIXME comments.
5
5
  */
6
6
 
7
7
  import { readdirSync, readFileSync, statSync } from "node:fs";
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-self-audit.mjs in v0.9.0.
5
- * Delegates execution to src/engine.mjs gate().
4
+ * Self-audit gatekeeper: runs 4-phase safety gate against working tree or base branch.
6
5
  */
7
6
 
8
7
  import { existsSync } from "node:fs";
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Backward compatibility shim for jules-status.mjs in v0.9.0.
4
+ * Session status and budget status summarizer.
5
5
  */
6
6
 
7
7
  import { checkDailyBudget } from "../src/state.mjs";
@@ -57,6 +57,61 @@ console.log("1b. Verifying documentation is in sync with package.json & test sui
57
57
  console.log(" āœ… Documentation is in sync.\n");
58
58
  }
59
59
 
60
+ // 1c. Continuous-integration gate (blocking). Step 1 ran the suite on this one
61
+ // machine. Every cross-platform break this repository has shipped was green
62
+ // locally and red on the matrix — a forward-slash path assertion, a
63
+ // case-folded deny rule — so a local pass says nothing about Windows or
64
+ // macOS. `gh` resolves the repository from the git remote, so this is not
65
+ // specific to any one fork.
66
+ console.log("1c. Verifying CI is green for HEAD...");
67
+ if (process.argv.includes("--skip-ci-check")) {
68
+ console.log(" āš ļø Skipped via --skip-ci-check.\n");
69
+ } else {
70
+ const headSha = execSync("git rev-parse HEAD", { cwd: root, encoding: "utf-8" }).trim();
71
+ let runs;
72
+ try {
73
+ const raw = execSync("gh run list --limit 40 --json headSha,status,conclusion,workflowName,url", {
74
+ cwd: root,
75
+ encoding: "utf-8",
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ });
78
+ runs = JSON.parse(raw).filter((r) => r.headSha === headSha);
79
+ } catch {
80
+ console.error("āŒ Release Aborted: could not query CI status via `gh`.");
81
+ console.error(" Run `gh auth login`, or re-run with --skip-ci-check to release without it.");
82
+ process.exit(1);
83
+ }
84
+
85
+ if (runs.length === 0) {
86
+ console.error(`āŒ Release Aborted: no CI run found for HEAD (${headSha.slice(0, 7)}).`);
87
+ console.error(" Push the commit and let CI finish first, or re-run with --skip-ci-check.");
88
+ process.exit(1);
89
+ }
90
+
91
+ // A run that is still going is not a pass. Releasing on a pending matrix is
92
+ // exactly how a red Windows job gets published.
93
+ const pending = runs.filter((r) => r.status !== "completed");
94
+ const failed = runs.filter((r) => !["success", "skipped", "neutral"].includes(r.conclusion));
95
+
96
+ for (const r of runs) {
97
+ const state = r.status !== "completed" ? r.status : r.conclusion;
98
+ const mark = pending.includes(r) ? "ā³" : failed.includes(r) ? "āŒ" : "āœ…";
99
+ console.log(` ${mark} ${r.workflowName.padEnd(32)} ${state}`);
100
+ }
101
+
102
+ if (pending.length > 0) {
103
+ console.error(`\nāŒ Release Aborted: ${pending.length} CI run(s) still in progress for HEAD.`);
104
+ console.error(` Wait for them to finish: ${pending[0].url}`);
105
+ process.exit(1);
106
+ }
107
+ if (failed.length > 0) {
108
+ console.error(`\nāŒ Release Aborted: ${failed.length} CI run(s) not green for HEAD.`);
109
+ for (const r of failed) console.error(` ${r.workflowName}: ${r.conclusion} — ${r.url}`);
110
+ process.exit(1);
111
+ }
112
+ console.log(` āœ… All ${runs.length} CI run(s) green for ${headSha.slice(0, 7)}.\n`);
113
+ }
114
+
60
115
  // 2. Extract release notes from CHANGELOG.md
61
116
  console.log(`2. Extracting release notes for ${tagName} from CHANGELOG.md...`);
62
117
  let notes = "";
@@ -10,7 +10,21 @@ const testFiles = readdirSync(testDir)
10
10
  .sort()
11
11
  .map((f) => join("test", f));
12
12
 
13
- const res = spawnSync(process.execPath, ["--test", ...testFiles], {
13
+ // A hung test has to fail, not stall. The readKeypresses() stdin regression
14
+ // failed by hanging rather than throwing: without a per-test deadline that
15
+ // burns a CI job to GitHub's six-hour default instead of going red in
16
+ // seconds. The slowest genuine test on the Windows runner is ~2s, so this is
17
+ // two orders of magnitude of headroom and only ever fires on a real hang.
18
+ const timeoutMs = Number(process.env.JULES_TEST_TIMEOUT_MS) || 60_000;
19
+
20
+ // `--test-timeout` landed in Node 20.6. package.json engines allows >=20.0.0,
21
+ // and an unrecognised flag makes Node abort before running anything — so on an
22
+ // older runtime the suite runs without the deadline rather than not at all.
23
+ const [major, minor] = process.versions.node.split(".").map(Number);
24
+ const supportsTestTimeout = major > 20 || (major === 20 && minor >= 6);
25
+ const timeoutArgs = supportsTestTimeout ? [`--test-timeout=${timeoutMs}`] : [];
26
+
27
+ const res = spawnSync(process.execPath, ["--test", ...timeoutArgs, ...testFiles], {
14
28
  cwd: root,
15
29
  stdio: "inherit",
16
30
  env: process.env,
package/scripts/utils.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Backward compatibility shim for utils.mjs in v0.9.0.
3
- * Re-exports utilities from modular src/ domain modules.
2
+ * Common scripting utilities and helper functions.
4
3
  */
5
4
 
6
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
7
6
  import { join, resolve } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
8
  import os from "node:os";
9
9
  import { resolveRoot } from "../src/config.mjs";
10
- import { appendLedger, checkDailyBudget as baseCheckDailyBudget } from "../src/state.mjs";
10
+ import { appendLedger, checkDailyBudget as baseCheckDailyBudget, verifyLedgerIntegrity as baseVerifyLedgerIntegrity } from "../src/state.mjs";
11
11
 
12
12
  export { loadConfig, resolveRoot, normalizePath } from "../src/config.mjs";
13
13
  export {
@@ -45,7 +45,7 @@ export function loadEnv(targetDir = process.cwd()) {
45
45
  if (!existsSync(envPath)) return;
46
46
  try {
47
47
  const raw = readFileSync(envPath, "utf-8");
48
- for (const line of raw.split("\n")) {
48
+ for (const line of raw.split(/\r?\n/)) {
49
49
  const trimmed = line.trim();
50
50
  if (!trimmed || trimmed.startsWith("#")) continue;
51
51
  const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
@@ -79,7 +79,7 @@ export function logToHistory(filename, content) {
79
79
  const dateStr = new Date().toISOString().split("T")[0];
80
80
  const historyDir = resolve(process.env.JULES_PROJECT_ROOT || process.cwd(), ".agent/history");
81
81
  if (!existsSync(historyDir)) {
82
- try { writeFileSync(historyDir, ""); } catch (_) {}
82
+ try { mkdirSync(historyDir, { recursive: true }); } catch (_) {}
83
83
  }
84
84
  const filePath = join(historyDir, `${dateStr}-${filename}`);
85
85
  try { writeFileSync(filePath, content, "utf-8"); } catch (_) {}
@@ -145,7 +145,7 @@ export function reserveDailyBudget(maxSessions = 300, taskKey = "", root = resol
145
145
  // The id is what makes the reservation releasable. Written without one, a
146
146
  // reservation counted against the day and no rollback, commit or reconcile
147
147
  // could ever name it again — it stayed charged until the ledger rotated.
148
- const reservationId = `res-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
148
+ const reservationId = `res-${Date.now()}-${randomUUID().slice(0, 8)}`;
149
149
  appendLedger({ event: "budget_reserved", reservationId, key: taskKey }, root);
150
150
  const check = checkDailyBudget(root, maxSessions);
151
151
  return { ok: check.ok, used: check.used, budget: maxSessions, reservationId };
@@ -155,12 +155,17 @@ export function verifyLedgerIntegrity(filePath) {
155
155
  if (!existsSync(filePath)) return { ok: false, count: 0 };
156
156
  try {
157
157
  const lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
158
+ let hasHashes = true;
158
159
  for (const line of lines) {
159
- JSON.parse(line);
160
+ const obj = JSON.parse(line);
161
+ if (!obj.hash) hasHashes = false;
162
+ }
163
+ if (hasHashes && lines.length > 0) {
164
+ return baseVerifyLedgerIntegrity(filePath);
160
165
  }
161
166
  return { ok: true, count: lines.length, lastHash: "sha256-verified" };
162
- } catch (_) {
163
- return { ok: false, count: 0, error: "Invalid JSON in ledger" };
167
+ } catch (err) {
168
+ return { ok: false, count: 0, error: err.message || "Invalid JSON in ledger" };
164
169
  }
165
170
  }
166
171