jules-orchestrator-kit 0.74.0 → 0.75.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.
@@ -3,8 +3,15 @@ import { join, resolve } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
5
  import { loadConfig, detectStack } from "../config.mjs";
6
- import { probeProvider, detectAvailableProviders, probeProviderLiveness } from "../provider-readiness.mjs";
6
+ import { probeProvider, detectAvailableProviders, probeProviderLiveness, whichBinary } from "../provider-readiness.mjs";
7
7
  import { resolveConcurrency } from "../budget.mjs";
8
+ import { isLockLive, getLockDir } from "../state.mjs";
9
+ import {
10
+ extractPrimaryExecutable,
11
+ missingNodeModules,
12
+ missingPythonEnv,
13
+ probeOracleLightly,
14
+ } from "./toolchain-diagnostics.mjs";
8
15
 
9
16
  /**
10
17
  * @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
@@ -429,6 +436,119 @@ export async function runDoctorChecks(options = {}) {
429
436
  });
430
437
  }
431
438
 
439
+
440
+ // 4b. Toolchain readiness — name a missing verify binary / install tree before
441
+ // dispatch, so first-run failures are diagnosable without reading spawn ENOENT.
442
+ if (effectiveTest) {
443
+ const primaryBin = extractPrimaryExecutable(effectiveTest);
444
+ if (primaryBin) {
445
+ const onPath = Boolean(whichBinary(primaryBin));
446
+ if (!onPath) {
447
+ const status = verificationRequired ? "fail" : "warn";
448
+ addResult({
449
+ id: "toolchain.binary",
450
+ category: "Verification",
451
+ title: "Verify Toolchain Binary",
452
+ status,
453
+ severity: verificationRequired ? "high" : "medium",
454
+ summary: `Verification binary \`${primaryBin}\` is not on PATH — gate will fail when it runs \`${effectiveTest}\``,
455
+ evidence: [
456
+ { label: "primaryBinary", value: primaryBin, sensitive: false },
457
+ { label: "testCommand", value: effectiveTest, sensitive: false },
458
+ ],
459
+ fixes: [
460
+ {
461
+ id: "toolchain.install-binary",
462
+ title: `Install ${primaryBin}`,
463
+ summary: `Install \`${primaryBin}\` (or change verify.test to a command available on this machine)`,
464
+ risk: "low",
465
+ automatic: false,
466
+ requiresProbe: false,
467
+ },
468
+ ],
469
+ });
470
+ } else {
471
+ addResult({
472
+ id: "toolchain.binary",
473
+ category: "Verification",
474
+ title: "Verify Toolchain Binary",
475
+ status: "pass",
476
+ severity: "info",
477
+ summary: `Verification binary \`${primaryBin}\` is on PATH`,
478
+ evidence: [
479
+ { label: "primaryBinary", value: primaryBin, sensitive: false },
480
+ { label: "testCommand", value: effectiveTest, sensitive: false },
481
+ ],
482
+ });
483
+
484
+ // Active probe: lightweight read-only check so ops know the pipeline
485
+ // answers before a paid dispatch.
486
+ if (activeProbe) {
487
+ const probe = probeOracleLightly(root, effectiveTest, primaryBin);
488
+ addResult({
489
+ id: "oracle.probe",
490
+ category: "Verification",
491
+ title: "Verification Oracle Probe",
492
+ status: probe.ok ? "pass" : "warn",
493
+ severity: probe.ok ? "info" : "medium",
494
+ summary: probe.detail,
495
+ evidence: [
496
+ { label: "primaryBinary", value: primaryBin, sensitive: false },
497
+ { label: "probed", value: true, sensitive: false },
498
+ ],
499
+ });
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ // 4c. Dependency install trees — warn only; doctor must stay exit 0 for a
506
+ // missing node_modules on a brand-new clone until the operator installs.
507
+ if (missingNodeModules(root)) {
508
+ addResult({
509
+ id: "toolchain.deps",
510
+ category: "Verification",
511
+ title: "Node Dependency Tree",
512
+ status: "warn",
513
+ severity: "medium",
514
+ summary: "package.json is present but node_modules/ is missing — run `npm install` (or pnpm/yarn) before verify",
515
+ evidence: [{ label: "nodeModules", value: false, sensitive: false }],
516
+ fixes: [
517
+ {
518
+ id: "toolchain.npm-install",
519
+ title: "Install Node dependencies",
520
+ summary: "Run npm install (or pnpm install / yarn) at the repository root",
521
+ risk: "low",
522
+ automatic: false,
523
+ requiresProbe: false,
524
+ },
525
+ ],
526
+ });
527
+ }
528
+
529
+ if (missingPythonEnv(root)) {
530
+ addResult({
531
+ id: "toolchain.python-env",
532
+ category: "Verification",
533
+ title: "Python Virtual Environment",
534
+ status: "warn",
535
+ severity: "medium",
536
+ summary:
537
+ "Python project manifest found but no usable venv (.venv/venv) — create one and install deps before verify",
538
+ evidence: [{ label: "venv", value: false, sensitive: false }],
539
+ fixes: [
540
+ {
541
+ id: "toolchain.python-venv",
542
+ title: "Create a Python venv",
543
+ summary: "Run `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt` (or poetry/uv equivalent)",
544
+ risk: "low",
545
+ automatic: false,
546
+ requiresProbe: false,
547
+ },
548
+ ],
549
+ });
550
+ }
551
+
432
552
  // 5. State & Telemetry Checks
433
553
  const telemetryDate = new Date().toISOString().split("T")[0];
434
554
  const telemetryHeadPath = join(root, ".agent", "state", `telemetry-${telemetryDate}.head`);
@@ -463,13 +583,30 @@ export async function runDoctorChecks(options = {}) {
463
583
  }
464
584
 
465
585
  // 6. VFS Locks Check
466
- const locksDir = join(root, ".agent", "state", "locks");
586
+ const locksDir = getLockDir(root);
467
587
  let activeLockCount = 0;
588
+ let staleLockCount = 0;
468
589
  let lockInspectionFailed = false;
469
590
  try {
470
591
  if (existsSync(locksDir)) {
471
592
  const lockFiles = readdirSync(locksDir).filter((f) => f.endsWith(".json"));
472
- activeLockCount = lockFiles.length;
593
+ for (const file of lockFiles) {
594
+ try {
595
+ const content = readFileSync(join(locksDir, file), "utf-8");
596
+ if (!content || !content.trim()) {
597
+ staleLockCount += 1;
598
+ continue;
599
+ }
600
+ const record = JSON.parse(content);
601
+ if (isLockLive(record)) {
602
+ activeLockCount += 1;
603
+ } else {
604
+ staleLockCount += 1;
605
+ }
606
+ } catch (_) {
607
+ staleLockCount += 1;
608
+ }
609
+ }
473
610
  }
474
611
  } catch (_) {
475
612
  lockInspectionFailed = true;
@@ -482,35 +619,50 @@ export async function runDoctorChecks(options = {}) {
482
619
  summary: "Could not inspect VFS lock directory",
483
620
  });
484
621
  }
485
- if (!lockInspectionFailed && activeLockCount === 0) {
486
- addResult({
487
- id: "locks.active",
488
- category: "State",
489
- title: "VFS Active Locks",
490
- status: "pass",
491
- severity: "info",
492
- summary: "No active VFS locks present",
493
- });
494
- } else if (!lockInspectionFailed) {
495
- addResult({
496
- id: "locks.active",
497
- category: "State",
498
- title: "VFS Active Locks",
499
- status: "warn",
500
- severity: "medium",
501
- summary: `${activeLockCount} active VFS lock(s) held`,
502
- evidence: [{ label: "lockCount", value: activeLockCount, sensitive: false }],
503
- fixes: [
504
- {
505
- id: "locks.prune-stale",
506
- title: "Prune stale locks",
507
- summary: "Remove locks owned by dead processes",
508
- risk: "low",
509
- automatic: true,
510
- requiresProbe: false,
511
- },
512
- ],
513
- });
622
+ if (!lockInspectionFailed) {
623
+ if (staleLockCount > 0) {
624
+ addResult({
625
+ id: "locks.active",
626
+ category: "State",
627
+ title: "VFS Locks",
628
+ status: "warn",
629
+ severity: "medium",
630
+ summary: `${staleLockCount} stale VFS lock(s) detected (${activeLockCount} active)`,
631
+ evidence: [
632
+ { label: "staleLockCount", value: staleLockCount, sensitive: false },
633
+ { label: "activeLockCount", value: activeLockCount, sensitive: false },
634
+ ],
635
+ fixes: [
636
+ {
637
+ id: "locks.prune-stale",
638
+ title: "Prune stale locks",
639
+ summary: "Run `agentctl lock reap` to clean up dead or expired locks",
640
+ risk: "low",
641
+ automatic: true,
642
+ requiresProbe: false,
643
+ },
644
+ ],
645
+ });
646
+ } else if (activeLockCount === 0) {
647
+ addResult({
648
+ id: "locks.active",
649
+ category: "State",
650
+ title: "VFS Active Locks",
651
+ status: "pass",
652
+ severity: "info",
653
+ summary: "No active VFS locks present",
654
+ });
655
+ } else {
656
+ addResult({
657
+ id: "locks.active",
658
+ category: "State",
659
+ title: "VFS Active Locks",
660
+ status: "pass",
661
+ severity: "info",
662
+ summary: `${activeLockCount} active VFS lock(s) held`,
663
+ evidence: [{ label: "lockCount", value: activeLockCount, sensitive: false }],
664
+ });
665
+ }
514
666
  }
515
667
 
516
668
  // 7. Provider readiness — for the provider this repository actually selected.
@@ -102,6 +102,23 @@ export function readPrFiles(pr = {}, fileCap = 100) {
102
102
  * @param {string|string[]} tierOption
103
103
  * @returns {Set<string>}
104
104
  */
105
+ /**
106
+ * Checks if a PR carries an exact hold label (case-insensitive, whitespace-trimmed).
107
+ * Safely handles GitHub label records ({ name }) and raw strings.
108
+ * @param {object} pr
109
+ * @returns {boolean}
110
+ */
111
+ export function hasHoldLabel(pr = {}) {
112
+ const labels = pr && Array.isArray(pr.labels) ? pr.labels : [];
113
+ for (const label of labels) {
114
+ const name = typeof label === "string" ? label : label && typeof label === "object" ? label.name : "";
115
+ if (typeof name === "string" && name.trim().toLowerCase() === "hold") {
116
+ return true;
117
+ }
118
+ }
119
+ return false;
120
+ }
121
+
105
122
  export function parseTierFilter(tierOption) {
106
123
  const mapShorthand = (t) => {
107
124
  const upper = String(t).toUpperCase().trim();
@@ -207,12 +224,15 @@ export async function harvestPullRequests(root = process.cwd(), options = {}) {
207
224
  const isMergeable = String(pr.mergeable || "").toUpperCase() === "MERGEABLE";
208
225
  const checksUsable = checks.passing || (checks.noChecks && allowNoChecks);
209
226
  const filesUsable = fileInfo.known && !fileInfo.truncated;
227
+ const isHeld = hasHoldLabel(pr);
210
228
 
211
- const eligible = checksUsable && filesUsable && gate.safe && tierMatches && isMergeable;
229
+ const eligible = checksUsable && filesUsable && gate.safe && tierMatches && isMergeable && !isHeld;
212
230
  let actionStatus = "SKIPPED";
213
231
  let actionReason = "";
214
232
 
215
- if (!filesUsable) {
233
+ if (isHeld) {
234
+ actionReason = "PR carries hold label ('hold')";
235
+ } else if (!filesUsable) {
216
236
  actionReason = fileInfo.truncated
217
237
  ? `Changed-file list truncated at ${files.length} entries — risk tier cannot be trusted`
218
238
  : "Changed-file list unavailable from gh — risk tier cannot be determined";
@@ -0,0 +1,253 @@
1
+ /**
2
+ * First-run toolchain diagnostics: name the binary a verify command needs,
3
+ * spot missing install trees, and turn Exit-4 failure text into a targeted hint.
4
+ *
5
+ * Zero third-party deps. PATH lookup reuses whichBinary from provider-readiness.
6
+ */
7
+
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { whichBinary } from "../provider-readiness.mjs";
12
+
13
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
14
+ const SHELL_SKIP = new Set(["cd", "env", "command", "exec", "nice", "time", "nohup"]);
15
+
16
+ /**
17
+ * Peel the primary executable out of an effective verify.test command string.
18
+ *
19
+ * Handles `PYTHONPATH=src python3 -m pytest`, `npm test`, `cargo test`, and
20
+ * simple `cmd && …` chains (first segment only). Returns null when nothing
21
+ * resolvable remains.
22
+ *
23
+ * @param {string} cmd
24
+ * @returns {string|null}
25
+ */
26
+ export function extractPrimaryExecutable(cmd) {
27
+ if (!cmd || typeof cmd !== "string") return null;
28
+ const tokens = cmd.trim().split(/\s+/).filter(Boolean);
29
+ let i = 0;
30
+ while (i < tokens.length) {
31
+ if (ENV_ASSIGNMENT.test(tokens[i])) {
32
+ i += 1;
33
+ continue;
34
+ }
35
+ if (tokens[i] === "cd" && tokens[i + 1] && !tokens[i + 1].startsWith("-")) {
36
+ i += 2;
37
+ if (tokens[i] === "&&" || tokens[i] === ";" || tokens[i] === "||") i += 1;
38
+ continue;
39
+ }
40
+ if (tokens[i] === "&&" || tokens[i] === ";" || tokens[i] === "||" || tokens[i] === "|") {
41
+ i += 1;
42
+ continue;
43
+ }
44
+ if (SHELL_SKIP.has(tokens[i])) {
45
+ i += 1;
46
+ continue;
47
+ }
48
+ break;
49
+ }
50
+ let bin = tokens[i] || "";
51
+ bin = bin.replace(/^['"]|['"]$/g, "");
52
+ if (!bin || bin.startsWith("-")) return null;
53
+ // Basename for PATH lookup when the command used an absolute/relative path.
54
+ if (bin.includes("/") || bin.includes("\\")) {
55
+ const parts = bin.split(/[/\\]/);
56
+ const base = parts[parts.length - 1];
57
+ return base || bin;
58
+ }
59
+ return bin;
60
+ }
61
+
62
+ /**
63
+ * True when a Python virtualenv looks usable under root (or VIRTUAL_ENV).
64
+ * @param {string} root
65
+ * @param {NodeJS.ProcessEnv} [env]
66
+ * @returns {boolean}
67
+ */
68
+ export function hasUsableVenv(root, env = process.env) {
69
+ if (env.VIRTUAL_ENV && existsSync(env.VIRTUAL_ENV)) {
70
+ const marker = join(env.VIRTUAL_ENV, process.platform === "win32" ? "Scripts" : "bin");
71
+ if (existsSync(marker)) return true;
72
+ }
73
+ const candidates = [".venv", "venv", ".direnv"];
74
+ for (const name of candidates) {
75
+ const base = join(root, name);
76
+ const binDir = join(base, process.platform === "win32" ? "Scripts" : "bin");
77
+ const py = join(binDir, process.platform === "win32" ? "python.exe" : "python");
78
+ const py3 = join(binDir, process.platform === "win32" ? "python.exe" : "python3");
79
+ if (existsSync(py) || existsSync(py3)) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * True when root has a package.json but no node_modules directory.
86
+ * @param {string} root
87
+ * @returns {boolean}
88
+ */
89
+ export function missingNodeModules(root) {
90
+ return existsSync(join(root, "package.json")) && !existsSync(join(root, "node_modules"));
91
+ }
92
+
93
+ /**
94
+ * True when a Python dep manifest is present without a usable venv.
95
+ * @param {string} root
96
+ * @param {NodeJS.ProcessEnv} [env]
97
+ * @returns {boolean}
98
+ */
99
+ export function missingPythonEnv(root, env = process.env) {
100
+ const hasManifest =
101
+ existsSync(join(root, "pyproject.toml")) ||
102
+ existsSync(join(root, "requirements.txt")) ||
103
+ existsSync(join(root, "Pipfile")) ||
104
+ existsSync(join(root, "setup.py"));
105
+ if (!hasManifest) return false;
106
+ return !hasUsableVenv(root, env);
107
+ }
108
+
109
+ /**
110
+ * Lightweight, read-only probe that the verify binary answers at all.
111
+ * Prefers `--version` / `-V`; for `python* -m MOD` also tries a cheap import.
112
+ *
113
+ * @param {string} root
114
+ * @param {string} effectiveTest
115
+ * @param {string} primaryBin
116
+ * @param {NodeJS.ProcessEnv} [env]
117
+ * @returns {{ ok: boolean, detail: string }}
118
+ */
119
+ export function probeOracleLightly(root, effectiveTest, primaryBin, env = process.env) {
120
+ if (!primaryBin) return { ok: false, detail: "No primary executable to probe" };
121
+ const resolved = whichBinary(primaryBin, env);
122
+ if (!resolved) return { ok: false, detail: `\`${primaryBin}\` is not on PATH` };
123
+
124
+ const trySpawn = (args) => {
125
+ try {
126
+ const ret = spawnSync(resolved, args, {
127
+ cwd: root,
128
+ encoding: "utf-8",
129
+ timeout: 8000,
130
+ stdio: ["ignore", "pipe", "pipe"],
131
+ env,
132
+ });
133
+ return ret.status === 0;
134
+ } catch (_) {
135
+ return false;
136
+ }
137
+ };
138
+
139
+ if (trySpawn(["--version"]) || trySpawn(["-V"]) || trySpawn(["version"])) {
140
+ return { ok: true, detail: `\`${primaryBin}\` answered a version probe` };
141
+ }
142
+
143
+ const modMatch = String(effectiveTest || "").match(/(?:^|\s)-m\s+(\S+)/);
144
+ if (modMatch && /^python/.test(primaryBin)) {
145
+ const mod = modMatch[1].replace(/[^A-Za-z0-9_.]/g, "");
146
+ if (mod && trySpawn(["-c", `import ${mod}`])) {
147
+ return { ok: true, detail: `\`${primaryBin} -c "import ${mod}"\` succeeded` };
148
+ }
149
+ return {
150
+ ok: false,
151
+ detail: `\`${primaryBin}\` is on PATH but could not import \`${mod || "module"}\` — install project deps / activate a venv`,
152
+ };
153
+ }
154
+
155
+ // Binary exists on PATH; version flags are optional for some tools.
156
+ try {
157
+ if (existsSync(resolved) && statSync(resolved).isFile()) {
158
+ return { ok: true, detail: `\`${primaryBin}\` is on PATH at ${resolved}` };
159
+ }
160
+ } catch (_) {}
161
+ return { ok: false, detail: `\`${primaryBin}\` did not respond to a lightweight probe` };
162
+ }
163
+
164
+ /**
165
+ * Name the missing binary from a failed verify stage when possible.
166
+ * @param {{ command?: string|null, stderr?: string, stdout?: string, error?: string, code?: string|number }} failure
167
+ * @returns {string|null}
168
+ */
169
+ export function inferMissingBinary(failure = {}) {
170
+ const text = [failure.stderr, failure.stdout, failure.error, failure.message]
171
+ .filter(Boolean)
172
+ .map(String)
173
+ .join("\n");
174
+ // Node spawn: `spawnSync foo ENOENT` / `spawn foo ENOENT`
175
+ const spawn = text.match(/spawn(?:Sync)?(?:\s+\S+\/)*\s*([^\s/\\]+)(?:\.exe|\.cmd|\.bat)?\s+ENOENT/i);
176
+ if (spawn) return spawn[1];
177
+ const notFound = text.match(/(?:command not found|not found):\s*([^\s]+)/i);
178
+ if (notFound) return notFound[1].replace(/^['"`]|['"`]$/g, "");
179
+ if (failure.command) return extractPrimaryExecutable(String(failure.command));
180
+ return null;
181
+ }
182
+
183
+ /**
184
+ * Classify an Exit-4 verification failure for the CLI remediation hint.
185
+ *
186
+ * @param {{ exitCode?: number|null, stderr?: string, stdout?: string, error?: string|object, code?: string|number, command?: string|null, message?: string }} failure
187
+ * @returns {{ kind: "missing-binary"|"missing-deps"|"general", binary?: string|null, lines: string[] }}
188
+ */
189
+ export function diagnoseVerifyFailure(failure = {}) {
190
+ const exitCode = failure.exitCode;
191
+ const errCode =
192
+ typeof failure.code === "string"
193
+ ? failure.code
194
+ : failure.error && typeof failure.error === "object" && failure.error.code
195
+ ? String(failure.error.code)
196
+ : "";
197
+ const text = [
198
+ failure.stderr || "",
199
+ failure.stdout || "",
200
+ typeof failure.error === "string" ? failure.error : failure.error?.message || "",
201
+ failure.message || "",
202
+ errCode,
203
+ ].join("\n");
204
+
205
+ const missingBinary =
206
+ exitCode === 127 ||
207
+ errCode === "ENOENT" ||
208
+ /\bENOENT\b/i.test(text) ||
209
+ /command not found/i.test(text) ||
210
+ /is not recognized as an internal or external command/i.test(text);
211
+
212
+ if (missingBinary) {
213
+ const binary = inferMissingBinary(failure);
214
+ const named = binary ? `\`${binary}\`` : "the required binary";
215
+ return {
216
+ kind: "missing-binary",
217
+ binary,
218
+ lines: [
219
+ `Verification could not start because ${named} is missing from PATH (exit ${exitCode ?? "n/a"}).`,
220
+ binary
221
+ ? `Install \`${binary}\`, or set verify.test in .agent/config.yml to a command this machine has.`
222
+ : "Install the missing tool, or set verify.test in .agent/config.yml to a command this machine has.",
223
+ ],
224
+ };
225
+ }
226
+
227
+ const missingDeps =
228
+ /Cannot find module\b/i.test(text) ||
229
+ /ModuleNotFoundError\b/i.test(text) ||
230
+ /No module named\b/i.test(text) ||
231
+ /Could not find a version that satisfies/i.test(text);
232
+
233
+ if (missingDeps) {
234
+ const isPy = /ModuleNotFoundError\b|No module named\b/i.test(text);
235
+ return {
236
+ kind: "missing-deps",
237
+ lines: [
238
+ "Verification failed because a required dependency is not installed.",
239
+ isPy
240
+ ? "Create/activate a venv and install deps (e.g. `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt`), then re-run the gate."
241
+ : "Install project dependencies (e.g. `npm install` / `pnpm install`), then re-run the gate.",
242
+ ],
243
+ };
244
+ }
245
+
246
+ return {
247
+ kind: "general",
248
+ lines: [
249
+ "The stage above exited non-zero. Reproduce it locally, then re-run the gate.",
250
+ "To start an explicit repair workflow, pipe the failing command's output to: agentctl repair",
251
+ ],
252
+ };
253
+ }