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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/bin/agentctl.mjs +372 -20
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +30 -7
- package/src/bidi-guard.mjs +190 -15
- package/src/config.mjs +40 -1
- package/src/engine.mjs +184 -47
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +91 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +1 -1
- package/src/security.mjs +37 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +100 -4
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +8 -2
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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.
|
package/src/ops/pr-harvest.mjs
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|