jules-orchestrator-kit 0.52.8 → 0.54.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/README.md +68 -8
- package/bin/agentctl.mjs +327 -24
- package/bin/init.js +87 -74
- package/index.mjs +10 -0
- package/package.json +5 -2
- package/scripts/jules-create.mjs +5 -16
- package/scripts/jules-patch.mjs +18 -10
- package/scripts/stale-base-check.mjs +1 -1
- package/src/ci-templates.mjs +199 -0
- package/src/config-edit.mjs +138 -0
- package/src/config.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/git.mjs +79 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/cli-intent.mjs +29 -0
- package/src/ops/command-registry.mjs +19 -10
- package/src/ops/doctor-registry.mjs +75 -32
- package/src/ops/ide-scaffold.mjs +22 -16
- package/src/ops/next-step.mjs +38 -7
- package/src/ops/verify-output.mjs +38 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +284 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +88 -12
- package/src/wizard-task.mjs +22 -5
- package/src/ops/doctor-planner.mjs +0 -220
- package/src/ops/receipts.mjs +0 -171
- package/src/ops/swarm-actions.mjs +0 -156
- package/src/ops/task-actions.mjs +0 -214
- package/src/ops/transaction.mjs +0 -278
- package/src/ux/capabilities.mjs +0 -180
- package/src/ux/diff-viewer.mjs +0 -289
- package/src/ux/layout.mjs +0 -195
- package/src/ux/log-viewer.mjs +0 -110
- package/src/ux/palette.mjs +0 -179
- package/src/ux/queue-model.mjs +0 -206
- package/src/ux/renderer.mjs +0 -188
- package/src/ux/swarm-model.mjs +0 -146
- package/src/ux/terminal-session.mjs +0 -215
- package/src/ux/widgets.mjs +0 -323
- /package/src/{ux/key-decoder.mjs → key-decoder.mjs} +0 -0
|
@@ -3,6 +3,7 @@ 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 } from "../config.mjs";
|
|
6
|
+
import { probeProvider, detectAvailableProviders, probeProviderLiveness } from "../provider-readiness.mjs";
|
|
6
7
|
import { resolveConcurrency } from "../budget.mjs";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -429,40 +430,82 @@ export async function runDoctorChecks(options = {}) {
|
|
|
429
430
|
});
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
// 7.
|
|
433
|
-
|
|
434
|
-
|
|
433
|
+
// 7. Provider readiness — for the provider this repository actually selected.
|
|
434
|
+
//
|
|
435
|
+
// This used to ask one question ("is JULES_API_KEY set?") and report it as a
|
|
436
|
+
// high-severity warning regardless of the configured provider, so a
|
|
437
|
+
// repository driving the Claude Code or Codex CLI was permanently told it was
|
|
438
|
+
// misconfigured over a key it neither needs nor should have.
|
|
439
|
+
let configuredProvider = "jules";
|
|
440
|
+
try {
|
|
441
|
+
configuredProvider = loadConfig(root).provider || "jules";
|
|
442
|
+
} catch (_) {
|
|
443
|
+
// Fall back to the default; config.present already reports a broken config.
|
|
444
|
+
}
|
|
445
|
+
const providerProbe = probeProvider(configuredProvider);
|
|
446
|
+
// A green row here used to read as "the provider works", when all it ever
|
|
447
|
+
// checked was a name on PATH or a variable in the environment. An installed
|
|
448
|
+
// CLI whose account has no entitlement passes both and then fails on the
|
|
449
|
+
// first dispatch, so the row has to say what it actually verified.
|
|
450
|
+
const liveness = activeProbe ? probeProviderLiveness(configuredProvider) : null;
|
|
451
|
+
const scopeNote =
|
|
452
|
+
providerProbe.kind === "exec"
|
|
453
|
+
? "Checked: the binary is on PATH. Not checked: whether the CLI is signed in — only a dispatch can show that."
|
|
454
|
+
: "Checked: a credential is present in the environment. Not checked: whether the provider accepts it.";
|
|
455
|
+
const livenessFailed = Boolean(liveness && liveness.attempted && !liveness.ok);
|
|
456
|
+
|
|
457
|
+
addResult({
|
|
458
|
+
id: "provider.key",
|
|
459
|
+
category: "Provider",
|
|
460
|
+
title: `Provider Readiness (${providerProbe.name})`,
|
|
461
|
+
alwaysShowSummary: true,
|
|
462
|
+
status: providerProbe.ready && !livenessFailed ? "pass" : "warn",
|
|
463
|
+
severity: providerProbe.ready && !livenessFailed ? "info" : "high",
|
|
464
|
+
// Naming the variable, not the value: an operator who wonders which key a
|
|
465
|
+
// dispatch will use should not have to echo a secret to find out.
|
|
466
|
+
summary: [
|
|
467
|
+
`${providerProbe.label} — ${providerProbe.reason}`,
|
|
468
|
+
liveness && liveness.attempted ? liveness.detail : null,
|
|
469
|
+
providerProbe.ready ? scopeNote : null,
|
|
470
|
+
!activeProbe && providerProbe.kind === "exec" ? "Run `agentctl doctor --probe` to start the CLI and confirm it answers." : null,
|
|
471
|
+
]
|
|
472
|
+
.filter(Boolean)
|
|
473
|
+
.join(" "),
|
|
474
|
+
remediation:
|
|
475
|
+
providerProbe.ready && !livenessFailed
|
|
476
|
+
? []
|
|
477
|
+
: [
|
|
478
|
+
{
|
|
479
|
+
summary: livenessFailed ? `The CLI is installed but did not run cleanly: ${liveness.detail}` : providerProbe.remedy,
|
|
480
|
+
risk: "low",
|
|
481
|
+
automatic: false,
|
|
482
|
+
requiresProbe: !providerProbe.ready ? false : true,
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
evidence: [
|
|
486
|
+
{ label: "provider", value: providerProbe.name, sensitive: false },
|
|
487
|
+
{ label: "providerKind", value: providerProbe.kind, sensitive: false },
|
|
488
|
+
{ label: "ready", value: providerProbe.ready, sensitive: false },
|
|
489
|
+
{ label: "keySource", value: providerProbe.keySource || "", sensitive: false },
|
|
490
|
+
{ label: "binaryFound", value: Boolean(providerProbe.binPath), sensitive: false },
|
|
491
|
+
{ label: "livenessProbed", value: Boolean(liveness && liveness.attempted), sensitive: false },
|
|
492
|
+
{ label: "livenessOk", value: liveness ? liveness.ok : null, sensitive: false },
|
|
493
|
+
],
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// 7a. What else this machine could dispatch to. Purely informational: an
|
|
497
|
+
// operator blocked on one provider should not have to discover by reading
|
|
498
|
+
// source that three others are installed and ready.
|
|
499
|
+
const alternatives = detectAvailableProviders().filter((p) => p.ready && p.name !== providerProbe.name);
|
|
500
|
+
if (alternatives.length > 0) {
|
|
435
501
|
addResult({
|
|
436
|
-
id: "provider.
|
|
437
|
-
category: "
|
|
438
|
-
title: "
|
|
502
|
+
id: "provider.alternatives",
|
|
503
|
+
category: "Provider",
|
|
504
|
+
title: "Other Providers Available",
|
|
439
505
|
status: "pass",
|
|
440
506
|
severity: "info",
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
summary: `API key supplied via ${keyVar} (environment only — never written to config or sent anywhere but the provider)`,
|
|
444
|
-
evidence: [
|
|
445
|
-
{ label: "keyConfigured", value: true, sensitive: false },
|
|
446
|
-
{ label: "keySource", value: keyVar, sensitive: false },
|
|
447
|
-
],
|
|
448
|
-
});
|
|
449
|
-
} else {
|
|
450
|
-
addResult({
|
|
451
|
-
id: "provider.key",
|
|
452
|
-
category: "Jules",
|
|
453
|
-
title: "Jules Provider API Key",
|
|
454
|
-
status: "warn",
|
|
455
|
-
severity: "high",
|
|
456
|
-
summary: "Neither JULES_API_KEY nor GEMINI_API_KEY environment variable is set",
|
|
457
|
-
remediation: [
|
|
458
|
-
{
|
|
459
|
-
summary: "Export JULES_API_KEY in your shell profile, or place it in a git-ignored .env",
|
|
460
|
-
risk: "low",
|
|
461
|
-
automatic: false,
|
|
462
|
-
requiresProbe: false,
|
|
463
|
-
},
|
|
464
|
-
],
|
|
465
|
-
evidence: [{ label: "keyConfigured", value: false, sensitive: false }],
|
|
507
|
+
summary: `Also ready on this machine: ${alternatives.map((p) => p.name).join(", ")} — switch with provider: in .agent/config.yml`,
|
|
508
|
+
evidence: alternatives.map((p) => ({ label: p.name, value: p.reason, sensitive: false })),
|
|
466
509
|
});
|
|
467
510
|
}
|
|
468
511
|
|
|
@@ -477,7 +520,7 @@ export async function runDoctorChecks(options = {}) {
|
|
|
477
520
|
|
|
478
521
|
addResult({
|
|
479
522
|
id: "provider.key.dotenv",
|
|
480
|
-
category: "
|
|
523
|
+
category: "Provider",
|
|
481
524
|
title: "Local .env secrecy",
|
|
482
525
|
status: tracked ? "fail" : "pass",
|
|
483
526
|
severity: tracked ? "critical" : "info",
|
package/src/ops/ide-scaffold.mjs
CHANGED
|
@@ -11,14 +11,32 @@ export class IdeScaffoldError extends Error {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Scaffolds IDE integration configuration files for Cursor, VS Code, and Claude Desktop.
|
|
14
|
+
*
|
|
15
|
+
* These files land in directories a project may already be using (`.cursor/`,
|
|
16
|
+
* `.vscode/`), and the merge logic preserves what is there — but `--dry-run`
|
|
17
|
+
* was accepted by the CLI and never reached here, so the only way to find out
|
|
18
|
+
* what would be touched was to let it happen.
|
|
19
|
+
*
|
|
14
20
|
* @param {string} target - 'cursor' | 'vscode' | 'claude' | 'all'
|
|
15
21
|
* @param {object} [options]
|
|
22
|
+
* @param {string} [options.root]
|
|
23
|
+
* @param {boolean} [options.dryRun] - report the files without writing them
|
|
16
24
|
* @returns {object} Summary of scaffolded files
|
|
17
25
|
*/
|
|
18
26
|
export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
19
27
|
const root = options.root || resolveRoot();
|
|
28
|
+
const dryRun = Boolean(options.dryRun);
|
|
20
29
|
const validTargets = new Set(["cursor", "vscode", "claude", "all"]);
|
|
21
30
|
|
|
31
|
+
/** Write unless this is a rehearsal; the directory is only made when writing. */
|
|
32
|
+
const writeUnlessRehearsing = (dir, file, contents) => {
|
|
33
|
+
if (dryRun) return;
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
writeFileSync(file, contents, "utf-8");
|
|
38
|
+
};
|
|
39
|
+
|
|
22
40
|
const normTarget = (target || "all").toLowerCase();
|
|
23
41
|
if (!validTargets.has(normTarget)) {
|
|
24
42
|
throw new IdeScaffoldError(`Invalid target '${target}'. Allowed targets: cursor, vscode, claude, all`);
|
|
@@ -29,10 +47,6 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
|
29
47
|
// Target: Cursor (.cursor/mcp.json)
|
|
30
48
|
if (normTarget === "cursor" || normTarget === "all") {
|
|
31
49
|
const cursorDir = join(root, ".cursor");
|
|
32
|
-
try {
|
|
33
|
-
mkdirSync(cursorDir, { recursive: true });
|
|
34
|
-
} catch (_) {}
|
|
35
|
-
|
|
36
50
|
const cursorConfigPath = join(cursorDir, "mcp.json");
|
|
37
51
|
let existingConfig = {};
|
|
38
52
|
if (existsSync(cursorConfigPath)) {
|
|
@@ -52,17 +66,13 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
|
52
66
|
},
|
|
53
67
|
};
|
|
54
68
|
|
|
55
|
-
|
|
69
|
+
writeUnlessRehearsing(cursorDir, cursorConfigPath, JSON.stringify(updatedCursorConfig, null, 2));
|
|
56
70
|
results.push({ target: "cursor", file: ".cursor/mcp.json", ok: true });
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// Target: VS Code (.vscode/tasks.json)
|
|
60
74
|
if (normTarget === "vscode" || normTarget === "all") {
|
|
61
75
|
const vscodeDir = join(root, ".vscode");
|
|
62
|
-
try {
|
|
63
|
-
mkdirSync(vscodeDir, { recursive: true });
|
|
64
|
-
} catch (_) {}
|
|
65
|
-
|
|
66
76
|
const vscodeTasksPath = join(vscodeDir, "tasks.json");
|
|
67
77
|
let existingConfig = { version: "2.0.0", tasks: [] };
|
|
68
78
|
if (existsSync(vscodeTasksPath)) {
|
|
@@ -110,17 +120,13 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
|
110
120
|
tasks: updatedTasks,
|
|
111
121
|
};
|
|
112
122
|
|
|
113
|
-
|
|
123
|
+
writeUnlessRehearsing(vscodeDir, vscodeTasksPath, JSON.stringify(updatedVsCodeConfig, null, 2));
|
|
114
124
|
results.push({ target: "vscode", file: ".vscode/tasks.json", ok: true });
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
// Target: Claude Desktop (claude_desktop_config.json snippet helper)
|
|
118
128
|
if (normTarget === "claude" || normTarget === "all") {
|
|
119
129
|
const agentDir = join(root, ".agent");
|
|
120
|
-
try {
|
|
121
|
-
mkdirSync(agentDir, { recursive: true });
|
|
122
|
-
} catch (_) {}
|
|
123
|
-
|
|
124
130
|
const snippetPath = join(agentDir, "claude_desktop_config.snippet.json");
|
|
125
131
|
const claudeSnippet = {
|
|
126
132
|
mcpServers: {
|
|
@@ -131,9 +137,9 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
|
|
|
131
137
|
},
|
|
132
138
|
};
|
|
133
139
|
|
|
134
|
-
|
|
140
|
+
writeUnlessRehearsing(agentDir, snippetPath, JSON.stringify(claudeSnippet, null, 2));
|
|
135
141
|
results.push({ target: "claude", file: ".agent/claude_desktop_config.snippet.json", ok: true });
|
|
136
142
|
}
|
|
137
143
|
|
|
138
|
-
return { ok: true, target: normTarget, results };
|
|
144
|
+
return { ok: true, target: normTarget, dryRun, results };
|
|
139
145
|
}
|
package/src/ops/next-step.mjs
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { isTaskFile } from "../engine.mjs";
|
|
5
|
+
import { probeProvider } from "../provider-readiness.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The provider named in `.agent/config.yml`, without paying for a full
|
|
9
|
+
* `loadConfig()` (which detects the stack and shells out to git).
|
|
10
|
+
*
|
|
11
|
+
* A bare `agentctl` invocation has to stay instant, and the only field this
|
|
12
|
+
* advisor needs is one line of YAML.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function readConfiguredProvider(root) {
|
|
18
|
+
for (const name of ["config.yml", "jules.yml"]) {
|
|
19
|
+
const file = join(root, ".agent", name);
|
|
20
|
+
if (!existsSync(file)) continue;
|
|
21
|
+
try {
|
|
22
|
+
const match = readFileSync(file, "utf-8").match(/^provider:\s*["']?([\w.-]+)["']?\s*$/m);
|
|
23
|
+
if (match) return match[1];
|
|
24
|
+
} catch (_) {
|
|
25
|
+
// An unreadable config is already reported by the `init` branch above.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "jules";
|
|
29
|
+
}
|
|
5
30
|
|
|
6
31
|
/**
|
|
7
32
|
* Work out the one thing the operator should do next.
|
|
@@ -52,13 +77,19 @@ export function resolveNextStep(root, env = process.env) {
|
|
|
52
77
|
};
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
|
|
80
|
+
// Which credential (or binary) is missing depends entirely on the provider
|
|
81
|
+
// the repository selected. Asking for JULES_API_KEY in a repository driving
|
|
82
|
+
// `claude-code` told the operator to fix something that was never broken.
|
|
83
|
+
const providerName = readConfiguredProvider(root);
|
|
84
|
+
const probe = probeProvider(providerName, { env });
|
|
85
|
+
if (!probe.ready) {
|
|
56
86
|
return {
|
|
57
|
-
id: "
|
|
58
|
-
headline:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
id: "provider",
|
|
88
|
+
headline: probe.known
|
|
89
|
+
? `Provider '${probe.name}' is not usable yet`
|
|
90
|
+
: `Provider '${probe.name}' is not a built-in preset`,
|
|
91
|
+
detail: `${probe.reason} Until it is resolved you can still run \`agentctl gate\` and \`--dry-run\` dispatches locally — every verification gate works with no provider at all.`,
|
|
92
|
+
command: probe.remedy,
|
|
62
93
|
blocking: false,
|
|
63
94
|
};
|
|
64
95
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choose what to show the operator from a failed stage's captured streams.
|
|
3
|
+
*
|
|
4
|
+
* stderr is where a failing suite says what it expected; stdout is where
|
|
5
|
+
* several runners (node:test among them) put the whole report — assertion text
|
|
6
|
+
* and stack included.
|
|
7
|
+
*
|
|
8
|
+
* Preferring stderr outright discarded all of that whenever stderr held so much
|
|
9
|
+
* as the spawn wrapper's own "Command failed: npm test", which it always does.
|
|
10
|
+
* The operator was shown a line naming the command they had just typed, and
|
|
11
|
+
* nothing about which test failed or why. A wrapper line is not diagnostic
|
|
12
|
+
* output: when that is all stderr has, stdout is the report.
|
|
13
|
+
*
|
|
14
|
+
* Lives in src/ rather than in the CLI script so it can be imported and tested
|
|
15
|
+
* without executing the CLI, which runs on import.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ stdout?: string, stderr?: string }} failure
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function selectFailureOutput(failure = {}) {
|
|
21
|
+
const rawStderr = (failure.stderr || "").trim();
|
|
22
|
+
const rawStdout = (failure.stdout || "").trim();
|
|
23
|
+
if (!rawStderr) return rawStdout;
|
|
24
|
+
|
|
25
|
+
const stderrIsWrapperOnly = rawStderr
|
|
26
|
+
.split("\n")
|
|
27
|
+
.every((line) => line.trim() === "" || /^(command failed|error: command failed|npm err!?)/i.test(line.trim()));
|
|
28
|
+
|
|
29
|
+
if (stderrIsWrapperOnly && rawStdout) return rawStdout;
|
|
30
|
+
if (rawStdout && rawStdout !== rawStderr) {
|
|
31
|
+
// Both carry something real, so show both rather than guessing which runner
|
|
32
|
+
// this is. stderr goes last on purpose: the caller keeps only the tail, and
|
|
33
|
+
// when stderr has real content it is nearly always the failure itself —
|
|
34
|
+
// putting the runner's banner after it would spend the cap on noise.
|
|
35
|
+
return `${rawStdout}\n${rawStderr}`;
|
|
36
|
+
}
|
|
37
|
+
return rawStderr;
|
|
38
|
+
}
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification profiles: one word in `.agent/config.yml` that turns the kit's
|
|
3
|
+
* verification primitives on as a coherent set.
|
|
4
|
+
*
|
|
5
|
+
* The kit ships mutation testing, V8 diff coverage, flakiness probing and
|
|
6
|
+
* anti-tamper detection, and every one of them was reachable only by knowing
|
|
7
|
+
* the command exists and typing it by hand. A repository that scaffolded a
|
|
8
|
+
* default config got exactly one gate — `npm test` — and none of the rest, so
|
|
9
|
+
* the capability shipped and then sat idle. A profile is the shortest sentence
|
|
10
|
+
* that says how hard this repository wants its agents verified.
|
|
11
|
+
*
|
|
12
|
+
* Profiles are expanded at load time (see `loadConfig`), not frozen into the
|
|
13
|
+
* scaffolded YAML, so the stage list stays correct when the stack changes and
|
|
14
|
+
* improves when the kit does.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Ordered weakest to strictest; `standard` is the default for a fresh repo. */
|
|
18
|
+
export const PROFILE_NAMES = ["minimal", "standard", "max"];
|
|
19
|
+
|
|
20
|
+
export const PROFILE_DESCRIPTIONS = {
|
|
21
|
+
minimal: "Tests only. For a slow suite, an unfamiliar stack, or a first day with the kit.",
|
|
22
|
+
standard: "Lint, tests, build, plus anti-tamper on the diff. The everyday gate.",
|
|
23
|
+
max: "Everything mechanical: adds mutation scoring, diff coverage, and flakiness probing.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stacks whose test command runs on Node and therefore honours
|
|
28
|
+
* `NODE_V8_COVERAGE`, which is how `assert:diff-coverage` collects data.
|
|
29
|
+
*
|
|
30
|
+
* Bun and Deno are deliberately absent: they run JavaScript but do not emit V8
|
|
31
|
+
* coverage into that directory, so the assertion would find no coverage maps
|
|
32
|
+
* and fail every diff for a reason that has nothing to do with the diff.
|
|
33
|
+
*/
|
|
34
|
+
const V8_COVERAGE_STACKS = new Set(["node", "turbo", "pnpm", "nx", "react-native", "hardhat"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the full verification pipeline for a profile.
|
|
38
|
+
*
|
|
39
|
+
* Returns the *complete* stage list, not an addendum: `gate()` replaces its
|
|
40
|
+
* built-in pipeline wholesale when `verify.stages` is present, so a profile
|
|
41
|
+
* that emitted only its extra assertions would silently drop the tests.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} profile - one of {@link PROFILE_NAMES}
|
|
44
|
+
* @param {object} ctx
|
|
45
|
+
* @param {string} [ctx.stack] - detected stack id (see `detectPolyglotStack`)
|
|
46
|
+
* @param {{setup?: string, lint?: string, test?: string, unit?: string, e2e?: string, build?: string, policy?: object}} [ctx.verify]
|
|
47
|
+
* @returns {{ profile: string, stages: object[], skipped: Array<{id: string, reason: string}> }}
|
|
48
|
+
*/
|
|
49
|
+
export function buildProfileStages(profile, ctx = {}) {
|
|
50
|
+
const name = PROFILE_NAMES.includes(String(profile || "").toLowerCase())
|
|
51
|
+
? String(profile).toLowerCase()
|
|
52
|
+
: "standard";
|
|
53
|
+
const verify = ctx.verify || {};
|
|
54
|
+
const stack = ctx.stack || "unknown";
|
|
55
|
+
const network = verify.policy?.networkAccess || "allow";
|
|
56
|
+
const testCmd = verify.test || verify.unit || "";
|
|
57
|
+
|
|
58
|
+
const stages = [];
|
|
59
|
+
const skipped = [];
|
|
60
|
+
|
|
61
|
+
// Dependency install always runs first when the stack declares one; without
|
|
62
|
+
// it every later stage fails on a missing toolchain rather than on the code.
|
|
63
|
+
if (verify.setup) {
|
|
64
|
+
stages.push({ id: "setup", kind: "setup", cmd: verify.setup, required: true, networkAccess: "allow" });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (name !== "minimal" && verify.lint) {
|
|
68
|
+
stages.push({ id: "lint", kind: "lint", cmd: verify.lint, required: true, networkAccess: network });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (testCmd) {
|
|
72
|
+
stages.push({ id: "unit", kind: "test", cmd: testCmd, required: true, networkAccess: network });
|
|
73
|
+
} else {
|
|
74
|
+
skipped.push({
|
|
75
|
+
id: "unit",
|
|
76
|
+
reason: "No test command is configured or detectable — set verify.test in .agent/config.yml.",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (name !== "minimal" && verify.e2e) {
|
|
81
|
+
stages.push({ id: "e2e", kind: "e2e", cmd: verify.e2e, required: true, networkAccess: "allow" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (name !== "minimal" && verify.build) {
|
|
85
|
+
stages.push({ id: "build", kind: "build", cmd: verify.build, required: true, networkAccess: network });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Anti-tamper is diff-based and language-agnostic: it costs nothing to run
|
|
89
|
+
// and it is the one check an agent under pressure to go green will trip.
|
|
90
|
+
if (name !== "minimal") {
|
|
91
|
+
stages.push({ id: "anti-tamper", kind: "assert", assert: "test-integrity", required: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (name === "max") {
|
|
95
|
+
// Mutation operators are C-family (`===`, `&&`, `++`, `true`), so the score
|
|
96
|
+
// is strongest on JS/TS, Go, Rust, Java, C#, PHP, Swift and Dart, and
|
|
97
|
+
// weaker on Python/Ruby where the keywords differ. It is still safe
|
|
98
|
+
// everywhere: it only ever mutates lines the diff added, and a diff with no
|
|
99
|
+
// mutable operators scores 100 and passes.
|
|
100
|
+
stages.push({
|
|
101
|
+
id: "mutation",
|
|
102
|
+
kind: "assert",
|
|
103
|
+
assert: "mutation",
|
|
104
|
+
minScore: 60,
|
|
105
|
+
maxMutants: 20,
|
|
106
|
+
testCmd,
|
|
107
|
+
required: true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (V8_COVERAGE_STACKS.has(stack)) {
|
|
111
|
+
stages.push({
|
|
112
|
+
id: "diff-coverage",
|
|
113
|
+
kind: "assert",
|
|
114
|
+
assert: "diff-coverage",
|
|
115
|
+
minCoverage: 80,
|
|
116
|
+
testCmd,
|
|
117
|
+
required: true,
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
skipped.push({
|
|
121
|
+
id: "diff-coverage",
|
|
122
|
+
reason: `Diff coverage reads NODE_V8_COVERAGE, which stack '${stack}' does not produce.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Three passes is the cheapest count that can distinguish "passed" from
|
|
127
|
+
// "passed this time": a single alternation is enough to quarantine.
|
|
128
|
+
stages.push({
|
|
129
|
+
id: "stability",
|
|
130
|
+
kind: "assert",
|
|
131
|
+
assert: "test-stability",
|
|
132
|
+
repeat: 3,
|
|
133
|
+
minPassRate: 1.0,
|
|
134
|
+
cmd: testCmd,
|
|
135
|
+
required: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// `assert:event-loop-lag` is deliberately not here. It measures the
|
|
139
|
+
// orchestrator's own loop while it blocks on a synchronous child process,
|
|
140
|
+
// so against a spawned test command it reports the blocking duration rather
|
|
141
|
+
// than the application's latency. It stays available as an explicit stage
|
|
142
|
+
// for repositories that know what they are measuring.
|
|
143
|
+
skipped.push({
|
|
144
|
+
id: "event-loop-lag",
|
|
145
|
+
reason: "Opt-in only: meaningful for in-process latency targets, not for a spawned test command.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { profile: name, stages, skipped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The pipeline `gate()` runs when no profile and no explicit stages are set:
|
|
154
|
+
* every verification command the stack resolved, in dependency order.
|
|
155
|
+
*
|
|
156
|
+
* Lives here rather than inline in `gate()` so that `agentctl profile` can show
|
|
157
|
+
* an operator what will actually run without reimplementing — and drifting
|
|
158
|
+
* from — the sequence the gate uses.
|
|
159
|
+
*
|
|
160
|
+
* @param {{setup?: string, lint?: string, test?: string, unit?: string, fuzz?: string, invariant?: string, e2e?: string, build?: string, policy?: object}} verify
|
|
161
|
+
* @returns {object[]}
|
|
162
|
+
*/
|
|
163
|
+
export function buildDefaultStages(verify = {}) {
|
|
164
|
+
const network = verify.policy?.networkAccess || "allow";
|
|
165
|
+
const stages = [];
|
|
166
|
+
if (verify.setup) stages.push({ id: "setup", kind: "setup", cmd: verify.setup, required: true, networkAccess: "allow" });
|
|
167
|
+
if (verify.lint) stages.push({ id: "lint", kind: "lint", cmd: verify.lint, required: true, networkAccess: network });
|
|
168
|
+
if (verify.test || verify.unit) {
|
|
169
|
+
stages.push({ id: "unit", kind: "test", cmd: verify.test || verify.unit, required: true, networkAccess: network });
|
|
170
|
+
}
|
|
171
|
+
if (verify.fuzz) stages.push({ id: "fuzz", kind: "fuzz", cmd: verify.fuzz, required: true, networkAccess: network });
|
|
172
|
+
if (verify.invariant) stages.push({ id: "invariant", kind: "invariant", cmd: verify.invariant, required: true, networkAccess: network });
|
|
173
|
+
if (verify.e2e) stages.push({ id: "e2e", kind: "e2e", cmd: verify.e2e, required: true, networkAccess: "allow" });
|
|
174
|
+
if (verify.build) stages.push({ id: "build", kind: "build", cmd: verify.build, required: true, networkAccess: network });
|
|
175
|
+
return stages;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Short human-readable summary of what a profile will run, for `init` output
|
|
180
|
+
* and `doctor`.
|
|
181
|
+
*
|
|
182
|
+
* @param {ReturnType<typeof buildProfileStages>} plan
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function describeProfilePlan(plan) {
|
|
186
|
+
const ids = plan.stages.map((s) => s.id);
|
|
187
|
+
return ids.length ? ids.join(" → ") : "(nothing to run)";
|
|
188
|
+
}
|