jules-orchestrator-kit 0.52.8 → 0.53.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.
@@ -4,25 +4,14 @@
4
4
  * Quick task file creator for .agent/jules-queue/.
5
5
  */
6
6
 
7
- import { writeFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { getQueueDir } from "../src/state.mjs";
7
+ import { runTaskCreateWizard } from "../src/wizard-task.mjs";
10
8
 
11
- const taskTitle = process.argv[2] || "New Task";
12
- const queueDir = getQueueDir();
13
- const safeTitle = taskTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
14
- const filepath = join(queueDir, `TASK-${Date.now()}-${safeTitle}.md`);
15
-
16
- const boilerplate = `# ${taskTitle}
17
-
18
- ## Objective
19
- [Describe task goal]
20
- `;
9
+ const prompt = process.argv.slice(2).join(" ").trim();
21
10
 
22
11
  try {
23
- writeFileSync(filepath, boilerplate, "utf-8");
24
- console.log(`[Shim] Scaffolded new task: ${filepath}`);
12
+ const result = await runTaskCreateWizard(process.cwd(), prompt ? { prompt } : {});
13
+ console.log(`✅ Scaffolded task envelope: ${result.taskFile}`);
25
14
  } catch (err) {
26
- console.error(`[Shim] Failed to create task: ${err.message}`);
15
+ console.error(`❌ Failed to create task: ${err.message}`);
27
16
  process.exit(1);
28
17
  }
@@ -4,20 +4,28 @@
4
4
  * Session patch fetching utility.
5
5
  */
6
6
 
7
- export async function fetchSessionPatch(sessionId, _options = {}) {
7
+ import { extractSessionPatch } from "../src/session-ops.mjs";
8
+
9
+ export async function fetchSessionPatch(sessionId, options = {}) {
8
10
  if (!sessionId || typeof sessionId !== "string") {
9
11
  throw new Error("Session ID is required.");
10
12
  }
11
- return {
12
- sessionId,
13
- state: "COMPLETED",
14
- title: "Patch Session",
15
- hasPatch: false,
16
- diff: "",
17
- };
13
+ return await extractSessionPatch(sessionId, options);
18
14
  }
19
15
 
20
16
  if (process.argv[1] && process.argv[1].endsWith("jules-patch.mjs")) {
21
- console.log("Patch fetch completed.");
22
- process.exit(0);
17
+ const sessionId = process.argv[2];
18
+ if (!sessionId) {
19
+ console.error("Usage: node scripts/jules-patch.mjs <sessionId>");
20
+ process.exit(1);
21
+ }
22
+ fetchSessionPatch(sessionId)
23
+ .then((res) => {
24
+ console.log(`Patch fetch completed for ${sessionId}: ${res.patch ? "Patch found" : "No patch"}`);
25
+ process.exit(0);
26
+ })
27
+ .catch((err) => {
28
+ console.error(`Patch fetch failed: ${err.message}`);
29
+ process.exit(1);
30
+ });
23
31
  }
@@ -8,7 +8,7 @@ const baseBranch = process.env.BASE_BRANCH || "main";
8
8
 
9
9
  try {
10
10
  const resolvedBase = resolveBase(root, baseBranch);
11
- const behindStr = git(["rev-list", "--count", `${resolvedBase}..HEAD`], { cwd: root, ignoreError: true });
11
+ const behindStr = git(["rev-list", "--count", `HEAD..${resolvedBase}`], { cwd: root, ignoreError: true });
12
12
  const behindCount = parseInt(behindStr || "0", 10);
13
13
 
14
14
  console.log(`[stale-base-check] Branch HEAD is ${behindCount} commits behind ${resolvedBase} (max allowed: ${maxBehind}).`);
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+
4
+ /**
5
+ * Generate a CI job that runs the kit's gate, with the toolchain the detected
6
+ * stack actually needs.
7
+ *
8
+ * `init` used to copy this repository's *own* audit workflow into whatever
9
+ * repository it was scaffolding: a nine-way Node matrix that ran `npm install`,
10
+ * `npm test` and a script path that only exists here. Dropped into a Rust or
11
+ * Python repository it was red on the first push, for reasons that had nothing
12
+ * to do with that repository's code. A generated workflow is the only kind that
13
+ * can be correct in a repository the kit has never seen.
14
+ */
15
+
16
+ export const CI_TARGETS = ["github", "gitlab"];
17
+
18
+ /**
19
+ * Toolchain setup per stack, expressed once and rendered per CI target.
20
+ *
21
+ * Node is installed unconditionally and is not in this table: `agentctl` is a
22
+ * Node CLI, so the runner needs it regardless of what the project is written
23
+ * in. These are the *additional* runtimes.
24
+ *
25
+ * Actions are referenced by major version tag rather than by commit SHA. A
26
+ * generated file is a starting point the repository owns from the moment it is
27
+ * written, and pinning a SHA the owner did not choose ages into a silent
28
+ * security debt they never agreed to — the header tells them to pin.
29
+ */
30
+ const STACK_TOOLCHAINS = {
31
+ python: { label: "Python 3.x", gh: [{ name: "Set up Python", uses: "actions/setup-python@v5", with: { "python-version": "3.x" } }], gl: "python:3" },
32
+ django: { label: "Python 3.x", gh: [{ name: "Set up Python", uses: "actions/setup-python@v5", with: { "python-version": "3.x" } }], gl: "python:3" },
33
+ go: { label: "Go (stable)", gh: [{ name: "Set up Go", uses: "actions/setup-go@v5", with: { "go-version": "stable" } }], gl: "golang:latest" },
34
+ cargo: { label: "Rust (preinstalled on hosted runners)", gh: [], gl: "rust:latest" },
35
+ maven: { label: "Temurin JDK 21", gh: [{ name: "Set up Java", uses: "actions/setup-java@v4", with: { distribution: "temurin", "java-version": "21" } }], gl: "maven:3-eclipse-temurin-21" },
36
+ gradle: { label: "Temurin JDK 21", gh: [{ name: "Set up Java", uses: "actions/setup-java@v4", with: { distribution: "temurin", "java-version": "21" } }], gl: "gradle:jdk21" },
37
+ dotnet: { label: ".NET 8", gh: [{ name: "Set up .NET", uses: "actions/setup-dotnet@v4", with: { "dotnet-version": "8.0.x" } }], gl: "mcr.microsoft.com/dotnet/sdk:8.0" },
38
+ php: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
39
+ laravel: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
40
+ wordpress: { label: "PHP (preinstalled on hosted runners)", gh: [], gl: "php:8" },
41
+ bundler: { label: "Ruby (preinstalled on hosted runners)", gh: [], gl: "ruby:3" },
42
+ mix: { label: "Elixir — add your own setup step", gh: [], gl: "elixir:latest" },
43
+ bun: { label: "Bun", gh: [{ name: "Set up Bun", uses: "oven-sh/setup-bun@v2" }], gl: "oven/bun:latest" },
44
+ deno: { label: "Deno", gh: [{ name: "Set up Deno", uses: "denoland/setup-deno@v2", with: { "deno-version": "v2.x" } }], gl: "denoland/deno:latest" },
45
+ flutter: { label: "Flutter — add your own setup step", gh: [], gl: "ghcr.io/cirruslabs/flutter:stable" },
46
+ dart: { label: "Dart — add your own setup step", gh: [], gl: "dart:stable" },
47
+ swift: { label: "Swift — add your own setup step", gh: [], gl: "swift:latest" },
48
+ cmake: { label: "CMake/CTest (preinstalled on hosted runners)", gh: [], gl: "gcc:latest" },
49
+ };
50
+
51
+ /** Renders one GitHub Actions step block at four-space indentation. */
52
+ function renderGhStep(step) {
53
+ const lines = [` - name: ${step.name}`, ` uses: ${step.uses}`];
54
+ if (step.with) {
55
+ lines.push(` with:`);
56
+ for (const [k, v] of Object.entries(step.with)) lines.push(` ${k}: "${v}"`);
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+
61
+ const HEADER = (stack, version) =>
62
+ `# Generated by jules-orchestrator-kit v${version} for a '${stack}' project.
63
+ #
64
+ # This file is yours now — edit it freely. Two things worth doing before you
65
+ # rely on it:
66
+ # 1. Pin each third-party action to a commit SHA (a tag can be moved).
67
+ # 2. Check that the runtime versions below match what the project targets.
68
+ #
69
+ # Regenerate with: agentctl ci init --force
70
+ `;
71
+
72
+ /**
73
+ * @param {object} opts
74
+ * @param {string} opts.target - "github" | "gitlab"
75
+ * @param {{stack: string}} opts.stack - result of detectStack()
76
+ * @param {object} [opts.config] - loaded config, for base_branch
77
+ * @param {string} [opts.version]
78
+ * @returns {{ file: string, content: string, setupSummary: string }}
79
+ */
80
+ export function buildCiWorkflow(opts = {}) {
81
+ const target = CI_TARGETS.includes(opts.target) ? opts.target : "github";
82
+ const stackId = opts.stack?.stack || "unknown";
83
+ const base = opts.config?.baseBranch || "main";
84
+ const version = opts.version || "0";
85
+ const toolchain = STACK_TOOLCHAINS[stackId] || null;
86
+ const setupSummary = toolchain ? toolchain.label : "Node only";
87
+
88
+ if (target === "gitlab") {
89
+ const image = toolchain?.gl || "node:20";
90
+ const needsNode = image !== "node:20" && !image.startsWith("node");
91
+ const content = `${HEADER(stackId, version)}
92
+ agent-gate:
93
+ image: ${image}
94
+ stage: test
95
+ variables:
96
+ GIT_DEPTH: "0"
97
+ rules:
98
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
99
+ - if: $CI_COMMIT_BRANCH == "${base}"
100
+ script:${
101
+ needsNode
102
+ ? `
103
+ # agentctl is a Node CLI, so the image needs Node even when the project is not.
104
+ - command -v node >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y -qq nodejs npm)`
105
+ : ""
106
+ }
107
+ - npx --yes jules-orchestrator-kit check --mode committed --base "origin/${base}"
108
+ `;
109
+ return { file: ".gitlab-ci.agent-gate.yml", content, setupSummary };
110
+ }
111
+
112
+ const extraSteps = (toolchain?.gh || []).map(renderGhStep).join("\n");
113
+ const content = `${HEADER(stackId, version)}
114
+ name: Agent Safety Gate
115
+
116
+ on:
117
+ pull_request:
118
+ branches: [ ${base} ]
119
+ push:
120
+ branches: [ ${base} ]
121
+
122
+ permissions:
123
+ contents: read
124
+
125
+ jobs:
126
+ gate:
127
+ runs-on: ubuntu-latest
128
+ timeout-minutes: 20
129
+ steps:
130
+ - name: Checkout repository
131
+ uses: actions/checkout@v4
132
+ with:
133
+ # The gate compares the branch against its merge base, which needs
134
+ # full history rather than the default single-commit fetch.
135
+ fetch-depth: 0
136
+ persist-credentials: false
137
+
138
+ # agentctl is a Node CLI: the runner needs Node whatever the project is
139
+ # written in.
140
+ - name: Set up Node.js
141
+ uses: actions/setup-node@v4
142
+ with:
143
+ node-version: "20"
144
+ ${extraSteps ? extraSteps + "\n" : ""}
145
+ - name: Run agent safety gate
146
+ run: npx --yes jules-orchestrator-kit check --mode committed --base "origin/${base}"
147
+ `;
148
+ return { file: ".github/workflows/agent-gate.yml", content, setupSummary };
149
+ }
150
+
151
+ /**
152
+ * Write the generated workflow, refusing to clobber an existing file unless
153
+ * asked.
154
+ *
155
+ * @param {string} root
156
+ * @param {object} [opts] - see {@link buildCiWorkflow}, plus `force` and `dryRun`
157
+ * @returns {{ ok: boolean, file?: string, written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
158
+ */
159
+ export function writeCiWorkflow(root, opts = {}) {
160
+ if (opts.target && !CI_TARGETS.includes(opts.target)) {
161
+ return { ok: false, error: `Unknown CI target '${opts.target}'. Choose one of: ${CI_TARGETS.join(", ")}` };
162
+ }
163
+
164
+ const built = buildCiWorkflow(opts);
165
+ const stackId = opts.stack?.stack || "unknown";
166
+ const abs = join(root, built.file);
167
+
168
+ if (opts.dryRun) {
169
+ return { ok: true, file: built.file, written: false, stack: stackId, setupSummary: built.setupSummary, content: built.content };
170
+ }
171
+
172
+ if (existsSync(abs) && !opts.force) {
173
+ let identical = false;
174
+ try {
175
+ identical = readFileSync(abs, "utf-8") === built.content;
176
+ } catch (_) {
177
+ // Unreadable target: treat as different so the caller is told to --force
178
+ // rather than being told nothing changed.
179
+ }
180
+ return {
181
+ ok: true,
182
+ file: built.file,
183
+ written: false,
184
+ identical,
185
+ stack: stackId,
186
+ setupSummary: built.setupSummary,
187
+ content: built.content,
188
+ };
189
+ }
190
+
191
+ try {
192
+ mkdirSync(dirname(abs), { recursive: true });
193
+ writeFileSync(abs, built.content, "utf-8");
194
+ } catch (err) {
195
+ return { ok: false, error: `Could not write ${built.file}: ${err.message}` };
196
+ }
197
+
198
+ return { ok: true, file: built.file, written: true, stack: stackId, setupSummary: built.setupSummary, content: built.content };
199
+ }
package/src/config.mjs CHANGED
@@ -293,6 +293,10 @@ export function detectPackageManager(root = process.cwd(), pkg = {}) {
293
293
  return "npm";
294
294
  }
295
295
 
296
+ import { buildProfileStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
297
+
298
+ export { buildProfileStages, PROFILE_NAMES, PROFILE_DESCRIPTIONS };
299
+
296
300
  import {
297
301
  detectPolyglotStack,
298
302
  resolveWorkspaceBoundary,
@@ -321,6 +325,10 @@ export function detectStack(projectRoot = process.cwd()) {
321
325
  export function resolveVerify(root = process.cwd(), userVerify = {}) {
322
326
  const s = detectStack(root);
323
327
  return {
328
+ // Carried through so `verify.profile` can gate stack-specific gates (V8
329
+ // diff coverage only exists where the test command runs on Node) without
330
+ // every caller re-running stack detection.
331
+ stack: s.stack || "unknown",
324
332
  setup: userVerify.setup ?? s.setupCmd ?? "",
325
333
  lint: userVerify.lint ?? s.fmtCmd ?? "",
326
334
  test: userVerify.test ?? s.testCmd ?? "",
@@ -501,22 +509,42 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
501
509
  if (normalizedLimits[k] === undefined) delete normalizedLimits[k];
502
510
  }
503
511
 
512
+ // The verification commands, resolved once, so a profile can be expanded
513
+ // against the same values the gate will actually run.
514
+ const mergedVerify = {
515
+ setup: rawSetup ?? autoVerify.setup ?? "",
516
+ lint: rawLint ?? autoVerify.lint ?? "",
517
+ test: rawTest ?? autoVerify.test,
518
+ unit: rawUnit ?? rawTest ?? autoVerify.unit ?? autoVerify.test,
519
+ fuzz: rawFuzz ?? autoVerify.fuzz ?? "",
520
+ invariant: rawInvariant ?? autoVerify.invariant ?? "",
521
+ e2e: rawE2e ?? autoVerify.e2e ?? "",
522
+ teardown: rawTeardown ?? autoVerify.teardown ?? "",
523
+ build: rawBuild ?? autoVerify.build,
524
+ policy: parsed.verify?.policy ?? autoVerify.policy,
525
+ };
526
+
527
+ // A hand-written `verify.stages:` is the operator being explicit and always
528
+ // wins; `verify.profile:` is the shorthand, expanded here rather than frozen
529
+ // into the scaffolded YAML so it stays correct when the stack changes.
530
+ const rawProfile = parsed.verify?.profile ?? parsed.profile ?? null;
531
+ const explicitStages = parsed.verify?.stages ?? autoVerify.stages ?? null;
532
+ const profilePlan =
533
+ !explicitStages && rawProfile
534
+ ? buildProfileStages(rawProfile, { stack: autoVerify.stack, verify: mergedVerify })
535
+ : null;
536
+
504
537
  const config = {
505
538
  version: parsed.version || DEFAULTS.version,
506
539
  provider: parsed.provider || DEFAULTS.provider,
507
540
  tier: activeTier,
508
541
  verify: {
509
- setup: rawSetup ?? autoVerify.setup ?? "",
510
- lint: rawLint ?? autoVerify.lint ?? "",
511
- test: rawTest ?? autoVerify.test,
512
- unit: rawUnit ?? rawTest ?? autoVerify.unit ?? autoVerify.test,
513
- fuzz: rawFuzz ?? autoVerify.fuzz ?? "",
514
- invariant: rawInvariant ?? autoVerify.invariant ?? "",
515
- e2e: rawE2e ?? autoVerify.e2e ?? "",
516
- teardown: rawTeardown ?? autoVerify.teardown ?? "",
517
- build: rawBuild ?? autoVerify.build,
518
- stages: parsed.verify?.stages ?? autoVerify.stages ?? null,
519
- policy: parsed.verify?.policy ?? autoVerify.policy,
542
+ ...mergedVerify,
543
+ stages: explicitStages ?? profilePlan?.stages ?? null,
544
+ profile: profilePlan?.profile ?? (rawProfile ? String(rawProfile).toLowerCase() : null),
545
+ // Named so `doctor` can tell an operator that the gate they enabled is
546
+ // running one fewer check than they think, and why.
547
+ profileSkipped: profilePlan?.skipped ?? [],
520
548
  timeoutMs: Number.isFinite(Number(verifyTimeoutMs)) ? Number(verifyTimeoutMs) : 60000,
521
549
  },
522
550
  evidence: {
package/src/engine.mjs CHANGED
@@ -19,6 +19,7 @@ import { hydratePrompt, harvestFailure } from "./memory.mjs";
19
19
  import { resolveRolePrompt } from "./role-resolver.mjs";
20
20
 
21
21
  import { runAssertion } from "./assertions.mjs";
22
+ import { buildDefaultStages } from "./profiles.mjs";
22
23
  import {
23
24
  computeDirectoryHash,
24
25
  generateEvidenceManifest,
@@ -283,27 +284,7 @@ export async function gate(opts = {}) {
283
284
  if (Array.isArray(trustedVerify.stages) && trustedVerify.stages.length > 0) {
284
285
  stagesToRun.push(...trustedVerify.stages);
285
286
  } else {
286
- if (trustedVerify.setup) {
287
- stagesToRun.push({ id: "setup", kind: "setup", cmd: trustedVerify.setup, required: true, networkAccess: "allow" });
288
- }
289
- if (trustedVerify.lint) {
290
- stagesToRun.push({ id: "lint", kind: "lint", cmd: trustedVerify.lint, required: true, networkAccess: trustedVerify.policy?.networkAccess || "allow" });
291
- }
292
- if (trustedVerify.test || trustedVerify.unit) {
293
- stagesToRun.push({ id: "unit", kind: "test", cmd: trustedVerify.test || trustedVerify.unit, required: true, networkAccess: trustedVerify.policy?.networkAccess || "allow" });
294
- }
295
- if (trustedVerify.fuzz) {
296
- stagesToRun.push({ id: "fuzz", kind: "fuzz", cmd: trustedVerify.fuzz, required: true, networkAccess: trustedVerify.policy?.networkAccess || "allow" });
297
- }
298
- if (trustedVerify.invariant) {
299
- stagesToRun.push({ id: "invariant", kind: "invariant", cmd: trustedVerify.invariant, required: true, networkAccess: trustedVerify.policy?.networkAccess || "allow" });
300
- }
301
- if (trustedVerify.e2e) {
302
- stagesToRun.push({ id: "e2e", kind: "e2e", cmd: trustedVerify.e2e, required: true, networkAccess: "allow" });
303
- }
304
- if (trustedVerify.build) {
305
- stagesToRun.push({ id: "build", kind: "build", cmd: trustedVerify.build, required: true, networkAccess: trustedVerify.policy?.networkAccess || "allow" });
306
- }
287
+ stagesToRun.push(...buildDefaultStages(trustedVerify));
307
288
  }
308
289
 
309
290
  try {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Vendor-neutral spellings for every environment variable the kit reads.
3
+ *
4
+ * Every knob in this codebase is spelled `JULES_*`, which is accurate for the
5
+ * provider it was written against and wrong for every other one: a repository
6
+ * driving `claude-code` or `codex` still had to export `JULES_SWARM_CONCURRENCY`
7
+ * to set worker slots, and `JULES_API_KEY` to set a key that never goes to
8
+ * Jules. Renaming the variables outright would break every existing checkout
9
+ * and CI job, so both spellings are supported and this table is the only place
10
+ * that has to know they are the same knob.
11
+ *
12
+ * Direction is deliberate: `AGENT_*` is the name to document, `JULES_*` is the
13
+ * name the code reads, and the alias fills the second from the first. A
14
+ * `JULES_*` value already in the environment always wins, so adding an
15
+ * `AGENT_*` export can never change the behaviour of a working setup.
16
+ *
17
+ * @type {Record<string, string>}
18
+ */
19
+ export const ENV_ALIASES = {
20
+ AGENT_API_KEY: "JULES_API_KEY",
21
+ AGENT_API_KEYS: "JULES_API_KEYS",
22
+ AGENT_API_KEY_SECONDARY: "JULES_API_KEY_SECONDARY",
23
+ AGENT_API_URL: "JULES_API_URL",
24
+ AGENT_REPO: "JULES_REPO",
25
+ AGENT_PROJECT_ID: "JULES_PROJECT_ID",
26
+ AGENT_PROJECT_ROOT: "JULES_PROJECT_ROOT",
27
+ AGENT_TIER: "JULES_TIER",
28
+ AGENT_DAILY_BUDGET: "JULES_DAILY_BUDGET",
29
+ AGENT_MAX_DIFF_KB: "JULES_MAX_DIFF_KB",
30
+ AGENT_DRY_RUN: "JULES_DRY_RUN",
31
+ AGENT_REPOLESS: "JULES_REPOLESS",
32
+ AGENT_SWARM_CONCURRENCY: "JULES_SWARM_CONCURRENCY",
33
+ AGENT_SWARM_STAGGER_MS: "JULES_SWARM_STAGGER_MS",
34
+ AGENT_PACE_MS: "JULES_PACE_MS",
35
+ AGENT_SLOT_INDEX: "JULES_SLOT_INDEX",
36
+ AGENT_SLOT_TOTAL: "JULES_SLOT_TOTAL",
37
+ AGENT_USE_WORKTREES: "JULES_USE_WORKTREES",
38
+ AGENT_ALLOW_COMMAND_FILE_CHANGES: "JULES_ALLOW_COMMAND_FILE_CHANGES",
39
+ AGENT_ALLOW_AGENT_RULE_CHANGES: "JULES_ALLOW_AGENT_RULE_CHANGES",
40
+ };
41
+
42
+ /**
43
+ * Fill the legacy `JULES_*` variables from their `AGENT_*` equivalents.
44
+ *
45
+ * Mutates the passed environment object in place — that is the point: every
46
+ * downstream module reads `process.env` directly, and rewriting hundreds of
47
+ * call sites to consult an alias table would be a far larger change with far
48
+ * more places to get it wrong. Called once at process entry.
49
+ *
50
+ * An empty-string alias is treated as unset, matching how the rest of the kit
51
+ * reads keys (`(process.env.X || "").trim()`), so `AGENT_API_KEY=` in a .env
52
+ * file does not shadow a real `JULES_API_KEY`.
53
+ *
54
+ * @param {NodeJS.ProcessEnv} [env=process.env]
55
+ * @returns {string[]} canonical names that were populated from an alias
56
+ */
57
+ export function applyEnvAliases(env = process.env) {
58
+ const applied = [];
59
+ for (const [alias, canonical] of Object.entries(ENV_ALIASES)) {
60
+ const aliasValue = env[alias];
61
+ if (aliasValue === undefined || String(aliasValue).trim() === "") continue;
62
+ const existing = env[canonical];
63
+ if (existing !== undefined && String(existing).trim() !== "") continue;
64
+ env[canonical] = aliasValue;
65
+ applied.push(canonical);
66
+ }
67
+ return applied;
68
+ }
69
+
70
+ /**
71
+ * Both spellings of one knob, for help text and diagnostics that should name
72
+ * the vendor-neutral variable first.
73
+ *
74
+ * @param {string} canonical - a `JULES_*` name
75
+ * @returns {{ canonical: string, alias: string|null }}
76
+ */
77
+ export function describeEnvVar(canonical) {
78
+ const alias = Object.entries(ENV_ALIASES).find(([, c]) => c === canonical)?.[0] || null;
79
+ return { canonical, alias };
80
+ }
package/src/mcp.mjs CHANGED
@@ -143,7 +143,7 @@ export const MCP_TOOLS = [
143
143
  },
144
144
  {
145
145
  name: "get_jules_status",
146
- description: "Retrieve orchestrator status including daily task budget, active locks, and stack diagnostics.",
146
+ description: "Retrieve orchestrator status: daily task budget, active locks, stack diagnostics, the configured provider and whether it is reachable, and the verification stages the gate will run.",
147
147
  inputSchema: {
148
148
  type: "object",
149
149
  properties: {},
@@ -439,6 +439,10 @@ export async function handleMcpRequest(request, opts = {}) {
439
439
  const stackInfo = detectStack(root);
440
440
  const budget = budgetStatus(config, root);
441
441
  const locks = lockStatus(root);
442
+ const { probeProvider } = await import("./provider-readiness.mjs");
443
+ const { buildDefaultStages } = await import("./profiles.mjs");
444
+ const providerProbe = probeProvider(config.provider || "jules");
445
+ const profileStages = config.verify.stages || buildDefaultStages(config.verify);
442
446
  const status = {
443
447
  version: MCP_SERVER_INFO.version,
444
448
  root,
@@ -455,6 +459,20 @@ export async function handleMcpRequest(request, opts = {}) {
455
459
  },
456
460
  activeLocksCount: locks.length,
457
461
  locks,
462
+ // An IDE agent orienting itself needs to know which agent this repo
463
+ // dispatches to and how hard its work will be verified before it
464
+ // starts, not after the gate rejects it.
465
+ provider: {
466
+ name: providerProbe.name,
467
+ kind: providerProbe.kind,
468
+ ready: providerProbe.ready,
469
+ reason: providerProbe.reason,
470
+ },
471
+ verification: {
472
+ profile: config.verify.profile,
473
+ stages: profileStages.map((st) => st.id),
474
+ skipped: (config.verify.profileSkipped || []).map((sk) => sk.id),
475
+ },
458
476
  };
459
477
  return {
460
478
  jsonrpc: "2.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 } from "../provider-readiness.mjs";
6
7
  import { resolveConcurrency } from "../budget.mjs";
7
8
 
8
9
  /**
@@ -429,40 +430,60 @@ 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
+ addResult({
447
+ id: "provider.key",
448
+ category: "Provider",
449
+ title: `Provider Readiness (${providerProbe.name})`,
450
+ status: providerProbe.ready ? "pass" : "warn",
451
+ severity: providerProbe.ready ? "info" : "high",
452
+ // Naming the variable, not the value: an operator who wonders which key a
453
+ // dispatch will use should not have to echo a secret to find out.
454
+ summary: `${providerProbe.label} — ${providerProbe.reason}`,
455
+ remediation: providerProbe.ready
456
+ ? []
457
+ : [
458
+ {
459
+ summary: providerProbe.remedy,
460
+ risk: "low",
461
+ automatic: false,
462
+ requiresProbe: false,
463
+ },
464
+ ],
465
+ evidence: [
466
+ { label: "provider", value: providerProbe.name, sensitive: false },
467
+ { label: "providerKind", value: providerProbe.kind, sensitive: false },
468
+ { label: "ready", value: providerProbe.ready, sensitive: false },
469
+ { label: "keySource", value: providerProbe.keySource || "", sensitive: false },
470
+ { label: "binaryFound", value: Boolean(providerProbe.binPath), sensitive: false },
471
+ ],
472
+ });
473
+
474
+ // 7a. What else this machine could dispatch to. Purely informational: an
475
+ // operator blocked on one provider should not have to discover by reading
476
+ // source that three others are installed and ready.
477
+ const alternatives = detectAvailableProviders().filter((p) => p.ready && p.name !== providerProbe.name);
478
+ if (alternatives.length > 0) {
435
479
  addResult({
436
- id: "provider.key",
437
- category: "Jules",
438
- title: "Jules Provider API Key",
480
+ id: "provider.alternatives",
481
+ category: "Provider",
482
+ title: "Other Providers Available",
439
483
  status: "pass",
440
484
  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 }],
485
+ summary: `Also ready on this machine: ${alternatives.map((p) => p.name).join(", ")} — switch with provider: in .agent/config.yml`,
486
+ evidence: alternatives.map((p) => ({ label: p.name, value: p.reason, sensitive: false })),
466
487
  });
467
488
  }
468
489
 
@@ -477,7 +498,7 @@ export async function runDoctorChecks(options = {}) {
477
498
 
478
499
  addResult({
479
500
  id: "provider.key.dotenv",
480
- category: "Jules",
501
+ category: "Provider",
481
502
  title: "Local .env secrecy",
482
503
  status: tracked ? "fail" : "pass",
483
504
  severity: tracked ? "critical" : "info",