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.
Files changed (43) hide show
  1. package/README.md +68 -8
  2. package/bin/agentctl.mjs +327 -24
  3. package/bin/init.js +87 -74
  4. package/index.mjs +10 -0
  5. package/package.json +5 -2
  6. package/scripts/jules-create.mjs +5 -16
  7. package/scripts/jules-patch.mjs +18 -10
  8. package/scripts/stale-base-check.mjs +1 -1
  9. package/src/ci-templates.mjs +199 -0
  10. package/src/config-edit.mjs +138 -0
  11. package/src/config.mjs +39 -11
  12. package/src/engine.mjs +2 -21
  13. package/src/env-aliases.mjs +80 -0
  14. package/src/git.mjs +79 -0
  15. package/src/mcp.mjs +19 -1
  16. package/src/ops/cli-intent.mjs +29 -0
  17. package/src/ops/command-registry.mjs +19 -10
  18. package/src/ops/doctor-registry.mjs +75 -32
  19. package/src/ops/ide-scaffold.mjs +22 -16
  20. package/src/ops/next-step.mjs +38 -7
  21. package/src/ops/verify-output.mjs +38 -0
  22. package/src/profiles.mjs +188 -0
  23. package/src/provider-readiness.mjs +284 -0
  24. package/src/security.mjs +42 -1
  25. package/src/tui.mjs +14 -3
  26. package/src/wizard-init.mjs +88 -12
  27. package/src/wizard-task.mjs +22 -5
  28. package/src/ops/doctor-planner.mjs +0 -220
  29. package/src/ops/receipts.mjs +0 -171
  30. package/src/ops/swarm-actions.mjs +0 -156
  31. package/src/ops/task-actions.mjs +0 -214
  32. package/src/ops/transaction.mjs +0 -278
  33. package/src/ux/capabilities.mjs +0 -180
  34. package/src/ux/diff-viewer.mjs +0 -289
  35. package/src/ux/layout.mjs +0 -195
  36. package/src/ux/log-viewer.mjs +0 -110
  37. package/src/ux/palette.mjs +0 -179
  38. package/src/ux/queue-model.mjs +0 -206
  39. package/src/ux/renderer.mjs +0 -188
  40. package/src/ux/swarm-model.mjs +0 -146
  41. package/src/ux/terminal-session.mjs +0 -215
  42. package/src/ux/widgets.mjs +0 -323
  43. /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. Jules Provider Key Check
433
- const keyVar = process.env.JULES_API_KEY ? "JULES_API_KEY" : process.env.GEMINI_API_KEY ? "GEMINI_API_KEY" : "";
434
- if (keyVar) {
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.key",
437
- category: "Jules",
438
- title: "Jules Provider API Key",
502
+ id: "provider.alternatives",
503
+ category: "Provider",
504
+ title: "Other Providers Available",
439
505
  status: "pass",
440
506
  severity: "info",
441
- // Naming the variable, not the value: an operator who wonders which key a
442
- // dispatch will use should not have to echo a secret to find out.
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: "Jules",
523
+ category: "Provider",
481
524
  title: "Local .env secrecy",
482
525
  status: tracked ? "fail" : "pass",
483
526
  severity: tracked ? "critical" : "info",
@@ -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
- writeFileSync(cursorConfigPath, JSON.stringify(updatedCursorConfig, null, 2), "utf-8");
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
- writeFileSync(vscodeTasksPath, JSON.stringify(updatedVsCodeConfig, null, 2), "utf-8");
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
- writeFileSync(snippetPath, JSON.stringify(claudeSnippet, null, 2), "utf-8");
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
  }
@@ -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
- if (!env.JULES_API_KEY && !env.GEMINI_API_KEY) {
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: "key",
58
- headline: "No provider API key in the environment",
59
- detail:
60
- "The key is read from the environment only — never written to config and never sent anywhere but the provider. Until it is set you can still run `agentctl gate` and `--dry-run` dispatches locally.",
61
- command: "export JULES_API_KEY=...",
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
+ }
@@ -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
+ }