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.
@@ -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,77 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PROFILE_NAMES } from "./profiles.mjs";
4
+
5
+ /**
6
+ * Set `verify.profile` in the repository's manifest, in place.
7
+ *
8
+ * A surgical text edit rather than a parse-and-reserialise: the kit's YAML
9
+ * reader is a subset parser, so round-tripping the file through it would drop
10
+ * every comment the wizard wrote and any key the subset does not model. The
11
+ * manifest is a file a human maintains; a tool that rewrites it must leave the
12
+ * rest of it alone.
13
+ *
14
+ * @param {string} root
15
+ * @param {string} profile - one of {@link PROFILE_NAMES}
16
+ * @returns {{ ok: boolean, file?: string, error?: string }}
17
+ */
18
+ export function setVerificationProfile(root, profile) {
19
+ const name = String(profile || "").toLowerCase();
20
+ if (!PROFILE_NAMES.includes(name)) {
21
+ return { ok: false, error: `Unknown profile '${profile}'. Choose one of: ${PROFILE_NAMES.join(", ")}` };
22
+ }
23
+
24
+ const candidates = [join(root, ".agent", "config.yml"), join(root, ".agent", "jules.yml")];
25
+ const file = candidates.find((f) => existsSync(f));
26
+ if (!file) {
27
+ return { ok: false, error: "No .agent/config.yml found. Run `agentctl init` first." };
28
+ }
29
+
30
+ let text;
31
+ try {
32
+ text = readFileSync(file, "utf-8");
33
+ } catch (err) {
34
+ return { ok: false, error: `Could not read ${file}: ${err.message}` };
35
+ }
36
+
37
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
38
+ const lines = text.split(/\r?\n/);
39
+
40
+ // Find the `verify:` mapping and the `profile:` key nested directly under it.
41
+ let verifyIdx = -1;
42
+ let profileIdx = -1;
43
+ for (let i = 0; i < lines.length; i++) {
44
+ if (/^verify:\s*$/.test(lines[i])) {
45
+ verifyIdx = i;
46
+ for (let j = i + 1; j < lines.length; j++) {
47
+ // A non-indented, non-blank line ends the block.
48
+ if (lines[j].trim() !== "" && !/^\s/.test(lines[j])) break;
49
+ if (/^\s+profile:\s*/.test(lines[j])) {
50
+ profileIdx = j;
51
+ break;
52
+ }
53
+ }
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (profileIdx >= 0) {
59
+ const indent = lines[profileIdx].match(/^(\s*)/)[1];
60
+ lines[profileIdx] = `${indent}profile: ${name}`;
61
+ } else if (verifyIdx >= 0) {
62
+ lines.splice(verifyIdx + 1, 0, ` profile: ${name}`);
63
+ } else {
64
+ if (lines.length && lines[lines.length - 1] !== "") lines.push("");
65
+ lines.push("verify:", ` profile: ${name}`, "");
66
+ }
67
+
68
+ const tmp = `${file}.tmp-${process.pid}`;
69
+ try {
70
+ writeFileSync(tmp, lines.join(eol), "utf-8");
71
+ renameSync(tmp, file);
72
+ } catch (err) {
73
+ return { ok: false, error: `Could not write ${file}: ${err.message}` };
74
+ }
75
+
76
+ return { ok: true, file };
77
+ }
@@ -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
+ }
@@ -0,0 +1,215 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join, delimiter } from "node:path";
3
+
4
+ /**
5
+ * What each provider actually needs before a dispatch can succeed.
6
+ *
7
+ * The kit shipped four provider adapters (`src/provider.mjs`) but only ever
8
+ * asked one question about readiness: "is JULES_API_KEY set?". That question is
9
+ * meaningless for `claude-code` and `codex`, which authenticate through their
10
+ * own CLI and need a binary on PATH instead of an environment variable — so a
11
+ * repository driven by a local agent CLI was told, forever, that it was
12
+ * misconfigured. Readiness is a property of the *selected* provider, and this
13
+ * table is where that property lives.
14
+ *
15
+ * `envKeys` are alternatives, not requirements in sequence: any one satisfies
16
+ * the provider. `bin` is the executable the exec adapter spawns.
17
+ *
18
+ * No documentation URLs live here on purpose: `test/egress-allowlist.test.mjs`
19
+ * treats every host literal under src/ as a network destination to justify, and
20
+ * a vendor doc link that nothing ever fetches would spend that budget without
21
+ * buying anything. `install` and `remedy` carry the actionable part.
22
+ */
23
+ export const PROVIDER_DESCRIPTORS = {
24
+ jules: {
25
+ name: "jules",
26
+ kind: "http",
27
+ label: "Google Jules — hosted asynchronous agent (REST)",
28
+ envKeys: ["JULES_API_KEY", "GEMINI_API_KEY"],
29
+ bin: null,
30
+ install: null,
31
+ remedy: "export JULES_API_KEY=...",
32
+ // Only the hosted provider clones a GitHub repository server-side, so only
33
+ // it needs to be told which one.
34
+ needsRepoSource: true,
35
+ },
36
+ "claude-code": {
37
+ name: "claude-code",
38
+ kind: "exec",
39
+ label: "Claude Code CLI — local agent",
40
+ envKeys: [],
41
+ bin: "claude",
42
+ install: "npm i -g @anthropic-ai/claude-code",
43
+ remedy: "Install the Claude Code CLI and run `claude` once to authenticate",
44
+ needsRepoSource: false,
45
+ },
46
+ codex: {
47
+ name: "codex",
48
+ kind: "exec",
49
+ label: "OpenAI Codex CLI — local agent",
50
+ envKeys: [],
51
+ bin: "codex",
52
+ install: "npm i -g @openai/codex",
53
+ remedy: "Install the Codex CLI and run `codex` once to authenticate",
54
+ needsRepoSource: false,
55
+ },
56
+ "gemini-flash": {
57
+ name: "gemini-flash",
58
+ kind: "exec",
59
+ label: "Gemini CLI — local agent (cheap/fast router tier)",
60
+ envKeys: ["GEMINI_API_KEY"],
61
+ bin: "gemini",
62
+ install: "npm i -g @google/gemini-cli",
63
+ remedy: "Install the Gemini CLI and export GEMINI_API_KEY",
64
+ needsRepoSource: false,
65
+ },
66
+ };
67
+
68
+ /** `gemini-cli` is an accepted spelling in NAMED_PRESETS; keep the two in step. */
69
+ PROVIDER_DESCRIPTORS["gemini-cli"] = { ...PROVIDER_DESCRIPTORS["gemini-flash"], name: "gemini-cli" };
70
+
71
+ /**
72
+ * Preference order when nothing is configured and several providers are ready.
73
+ *
74
+ * Hosted first because it is the only one that can run a task while the
75
+ * operator's machine is asleep; the local CLIs follow in descending
76
+ * capability. This is a *tie-break*, never an override: an explicit
77
+ * `provider:` in .agent/config.yml always wins.
78
+ */
79
+ export const PROVIDER_PREFERENCE = ["jules", "claude-code", "codex", "gemini-flash"];
80
+
81
+ /**
82
+ * Cross-platform `which`, without shelling out.
83
+ *
84
+ * Spawning `which`/`where` costs a process per probe and does not exist
85
+ * identically on Windows; PATHEXT resolution is the part that actually differs,
86
+ * and it is three lines. Returns the absolute path or null.
87
+ *
88
+ * @param {string} bin
89
+ * @param {NodeJS.ProcessEnv} [env=process.env]
90
+ * @returns {string|null}
91
+ */
92
+ export function whichBinary(bin, env = process.env) {
93
+ if (!bin || typeof bin !== "string") return null;
94
+ const pathVar = env.PATH || env.Path || env.path || "";
95
+ if (!pathVar) return null;
96
+
97
+ const isWindows = process.platform === "win32";
98
+ // On Windows a bare name has no extension; PATHEXT lists the ones the shell
99
+ // would have tried. Elsewhere the name is the whole story.
100
+ const extensions = isWindows
101
+ ? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean)
102
+ : [""];
103
+
104
+ for (const dir of pathVar.split(delimiter)) {
105
+ if (!dir) continue;
106
+ for (const ext of extensions) {
107
+ const candidate = join(dir, bin + ext);
108
+ try {
109
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
110
+ } catch (_) {
111
+ // Unreadable PATH entry (a stale mount, a permission-denied directory).
112
+ // A probe must never throw: an unreadable directory simply holds nothing.
113
+ }
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Report whether one named provider can be dispatched to right now.
121
+ *
122
+ * Never throws and never spawns the agent: this is called from `agentctl` with
123
+ * no arguments and from `doctor`, both of which must stay instant and free.
124
+ *
125
+ * @param {string} name
126
+ * @param {object} [opts]
127
+ * @param {NodeJS.ProcessEnv} [opts.env=process.env]
128
+ * @returns {{name: string, kind: string, label: string, ready: boolean, reason: string, remedy: string, keySource: string|null, binPath: string|null, known: boolean}}
129
+ */
130
+ export function probeProvider(name, opts = {}) {
131
+ const env = opts.env || process.env;
132
+ const key = String(name || "").trim().toLowerCase();
133
+ const descriptor = PROVIDER_DESCRIPTORS[key];
134
+
135
+ if (!descriptor) {
136
+ return {
137
+ name: key || "(unset)",
138
+ kind: "unknown",
139
+ label: key ? `Custom provider '${key}'` : "No provider selected",
140
+ ready: false,
141
+ known: false,
142
+ reason: key
143
+ ? `'${key}' is not a built-in provider preset, so its readiness cannot be checked here.`
144
+ : "No provider is configured.",
145
+ remedy: `Set provider: to one of ${PROVIDER_PREFERENCE.join(", ")} in .agent/config.yml`,
146
+ keySource: null,
147
+ binPath: null,
148
+ };
149
+ }
150
+
151
+ const keySource = descriptor.envKeys.find((k) => (env[k] || "").trim()) || null;
152
+ const binPath = descriptor.bin ? whichBinary(descriptor.bin, env) : null;
153
+
154
+ // An http provider is gated purely on credentials; an exec provider is gated
155
+ // on the binary, and treats its env key as optional because the CLIs carry
156
+ // their own stored login.
157
+ const ready = descriptor.kind === "http" ? Boolean(keySource) : Boolean(binPath);
158
+
159
+ let reason;
160
+ if (ready) {
161
+ reason =
162
+ descriptor.kind === "http"
163
+ ? `Credential supplied via ${keySource} (read from the environment only).`
164
+ : `\`${descriptor.bin}\` found on PATH${keySource ? ` (plus ${keySource})` : ""}.`;
165
+ } else {
166
+ reason =
167
+ descriptor.kind === "http"
168
+ ? `None of ${descriptor.envKeys.join(", ")} is set in the environment.`
169
+ : `\`${descriptor.bin}\` is not on PATH.`;
170
+ }
171
+
172
+ return {
173
+ name: descriptor.name,
174
+ kind: descriptor.kind,
175
+ label: descriptor.label,
176
+ ready,
177
+ known: true,
178
+ reason,
179
+ remedy: descriptor.install && !binPath ? descriptor.install : descriptor.remedy,
180
+ keySource,
181
+ binPath,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Probe every built-in provider, ready ones first, in preference order.
187
+ *
188
+ * Used by `agentctl init` to propose a provider the machine can actually reach
189
+ * instead of scaffolding `provider: jules` into a repository whose operator has
190
+ * no Jules key and never wanted one.
191
+ *
192
+ * @param {object} [opts]
193
+ * @param {NodeJS.ProcessEnv} [opts.env=process.env]
194
+ * @returns {Array<ReturnType<typeof probeProvider>>}
195
+ */
196
+ export function detectAvailableProviders(opts = {}) {
197
+ const probes = PROVIDER_PREFERENCE.map((name) => probeProvider(name, opts));
198
+ return probes.sort((a, b) => {
199
+ if (a.ready !== b.ready) return a.ready ? -1 : 1;
200
+ return PROVIDER_PREFERENCE.indexOf(a.name) - PROVIDER_PREFERENCE.indexOf(b.name);
201
+ });
202
+ }
203
+
204
+ /**
205
+ * The provider a fresh `init` should scaffold: the first one that is actually
206
+ * usable on this machine, falling back to the hosted default so a repository
207
+ * initialised offline still names something coherent.
208
+ *
209
+ * @param {object} [opts]
210
+ * @returns {string}
211
+ */
212
+ export function suggestProvider(opts = {}) {
213
+ const ready = detectAvailableProviders(opts).find((p) => p.ready);
214
+ return ready ? ready.name : "jules";
215
+ }
package/src/security.mjs CHANGED
@@ -328,6 +328,47 @@ export function isForbiddenPath(filePath, config = {}) {
328
328
  return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
329
329
  }
330
330
 
331
+ /**
332
+ * The builtin deny patterns that exist to keep credentials out of a diff, and
333
+ * the documented template filenames those patterns must not catch.
334
+ *
335
+ * The recursive dot-env glob is correct for `.env.local` and `.env.production`
336
+ * and wrong for `.env.example` — a file nearly every repository commits
337
+ * precisely so the environment can be documented without the values. Denying it
338
+ * meant no agent could ever be asked to document a new variable, in any project.
339
+ *
340
+ * The exemption is deliberately narrow. It applies only when one of the two
341
+ * *builtin* patterns matched: a repository that writes its own broader dot-env
342
+ * deny rule blocks templates too, because the pattern string is not one of
343
+ * these. And the diff secret scanner runs over every changed file regardless of
344
+ * scope, so a real credential pasted into a template still fails on exit 6.
345
+ */
346
+ const BUILTIN_ENV_DENY_PATTERNS = new Set(["**/.env", "**/.env.*"]);
347
+ export const ENV_TEMPLATE_BASENAMES = new Set([
348
+ ".env.example",
349
+ ".env.sample",
350
+ ".env.template",
351
+ ".env.dist",
352
+ ".env.defaults",
353
+ ]);
354
+
355
+ /**
356
+ * True when a deny hit is the builtin credential rule catching a committed
357
+ * environment *template* rather than an environment file.
358
+ *
359
+ * @param {string} file - canonicalised repo-relative path
360
+ * @param {string} pattern - the deny pattern that matched
361
+ * @returns {boolean}
362
+ */
363
+ export function isEnvTemplateException(file, pattern) {
364
+ if (!BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
365
+ const name = basename(file).toLowerCase();
366
+ if (ENV_TEMPLATE_BASENAMES.has(name)) return true;
367
+ // `.env.production.example`, `.env.test.sample`, ... — the documented-template
368
+ // suffix is what matters, not how many environment segments precede it.
369
+ return /^\.env\..+\.(example|sample|template|dist|defaults)$/.test(name);
370
+ }
371
+
331
372
  export function checkScope(files = [], scope = {}, opts = {}) {
332
373
  const violations = [];
333
374
  const deny = scope.deny || [];
@@ -360,7 +401,7 @@ export function checkScope(files = [], scope = {}, opts = {}) {
360
401
  // Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
361
402
  // directory as ".github/", so a case-sensitive deny is bypassable there.
362
403
  const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
363
- if (matchedDeny) {
404
+ if (matchedDeny && !isEnvTemplateException(file, matchedDeny)) {
364
405
  violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
365
406
  continue;
366
407
  }
package/src/tui.mjs CHANGED
@@ -41,9 +41,20 @@ export function styleText(text, style) {
41
41
  return `${style}${text}${ANSI.reset}`;
42
42
  }
43
43
 
44
- import { WizardCancelledError } from "./ux/terminal-session.mjs";
45
- import { createKeyDecoder } from "./ux/key-decoder.mjs";
46
- export { WizardCancelledError };
44
+ import { createKeyDecoder } from "./key-decoder.mjs";
45
+
46
+ export class WizardCancelledError extends Error {
47
+ /**
48
+ * @param {string} [message]
49
+ * @param {"escape" | "ctrl-c" | "quit" | "stream-closed"} [reason="ctrl-c"]
50
+ */
51
+ constructor(message = "Wizard operation cancelled by user", reason = "ctrl-c") {
52
+ super(message);
53
+ this.name = "WizardCancelledError";
54
+ this.code = 130;
55
+ this.reason = reason;
56
+ }
57
+ }
47
58
 
48
59
  /**
49
60
  * Read keypress in raw mode from TTY input stream without destroying stream on early return.