jules-orchestrator-kit 0.70.0 → 0.72.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,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { whichBinary } from "./provider-readiness.mjs";
4
+ import { yamlScalar } from "./config.mjs";
4
5
 
5
6
  /**
6
7
  * The Python interpreter to invoke, by whatever name this machine has it under.
@@ -31,11 +32,48 @@ function pythonBin(env = process.env) {
31
32
  * Falls back to the bare console script only when no interpreter can be found
32
33
  * to host the module.
33
34
  */
34
- export function pytestCmd(env = process.env) {
35
+ export function pytestCmd(env = process.env, root = null) {
36
+ const prefix = root && isSrcLayout(root) ? "PYTHONPATH=src " : "";
35
37
  for (const name of ["python3", "python", "py"]) {
36
- if (whichBinary(name, env)) return `${name} -m pytest`;
38
+ if (whichBinary(name, env)) return `${prefix}${name} -m pytest`;
39
+ }
40
+ return `${prefix}pytest`;
41
+ }
42
+
43
+ /**
44
+ * Does this repository keep its package under `src/` rather than at the root?
45
+ *
46
+ * The sibling of the bug above, and a worse one. `-m` puts the *working
47
+ * directory* on `sys.path` — which is the fix for a module at the root, and
48
+ * no help at all when the package lives in `src/`. There, `import iniconfig`
49
+ * finds nothing in the working directory and falls through to whatever is
50
+ * installed in site-packages. The suite then runs green against a *different
51
+ * copy of the library than the one in the diff*: measured on `pytest-dev/
52
+ * iniconfig`, `_parse.py` gutted to `return False`, 49 tests passed, and the
53
+ * gate returned APPROVED (Exit 0).
54
+ *
55
+ * That is this project's worst failure shape — a check that examined
56
+ * something other than the thing under review, reporting a pass — and no
57
+ * amount of counting collected tests can see it, because the tests really
58
+ * did run. Only the import path can.
59
+ *
60
+ * `PYTHONPATH=src` is the ordinary spelling and puts the working tree first
61
+ * whether or not the package is also installed. Leading assignments are
62
+ * peeled into the child's environment by `runCommand`, so this needs no shell.
63
+ *
64
+ * Keyed on `__init__.py` under `src/`, which is the marker of a Python
65
+ * package and not of a Rust, C or JavaScript `src/` directory.
66
+ */
67
+ export function isSrcLayout(root) {
68
+ const src = join(root, "src");
69
+ if (!existsSync(src)) return false;
70
+ try {
71
+ return readdirSync(src, { withFileTypes: true }).some(
72
+ (e) => e.isDirectory() && existsSync(join(src, e.name, "__init__.py"))
73
+ );
74
+ } catch (_) {
75
+ return false;
37
76
  }
38
- return "pytest";
39
77
  }
40
78
 
41
79
  /**
@@ -80,6 +118,27 @@ export function isPlaceholderTestScript(cmd) {
80
118
  const trimmed = cmd.trim();
81
119
  if (!trimmed) return true;
82
120
  if (EMPTY_PROGRAM.test(trimmed)) return true;
121
+
122
+ // Node/Deno/Bun process.exit(0)
123
+ if (/^(?:\S*[/\\])?(?:node|nodejs|deno|bun)\b.*?(?:-e|--eval)\s+["'`]\s*(?:(?:void\s+0|0)?\s*;?\s*process\.exit\s*\(\s*0?\s*\)\s*;?)*\s*["'`]\s*$/.test(trimmed)) {
124
+ return true;
125
+ }
126
+
127
+ // Python exit(0) / pass
128
+ if (/^(?:\S*[/\\])?python[\d.]*\b.*?-c\s+["'`]\s*(?:(?:import\s+sys\s*;?\s*)?(?:sys\.)?exit\s*\(\s*0?\s*\)|pass)?\s*;?\s*["'`]\s*$/.test(trimmed)) {
129
+ return true;
130
+ }
131
+
132
+ // Shell running no-op / exit 0
133
+ if (/^(?:\S*[/\\])?(?:sh|bash|zsh|dash)\b.*?-c\s+["'`]?\s*(?::|true|exit\s+0)?\s*;?\s*["'`]?\s*$/.test(trimmed)) {
134
+ return true;
135
+ }
136
+
137
+ // Pytest collection only without test execution
138
+ if (/\bpytest\b.*?(?:--collect-only\b)/.test(trimmed)) {
139
+ return true;
140
+ }
141
+
83
142
  // Drop the announcements; what matters is what the shell is left doing.
84
143
  const remainder = trimmed
85
144
  .split(/&&|;/)
@@ -234,7 +293,7 @@ export function oracleCandidates(root = process.cwd(), detected = "") {
234
293
  } catch (_) {}
235
294
  }
236
295
  if (has("pytest.ini") || has("pyproject.toml") || has("setup.py") || has("tox.ini") || has("setup.cfg")) {
237
- push(pytestCmd());
296
+ push(pytestCmd(process.env, root));
238
297
  }
239
298
  if (has("Cargo.toml")) push("cargo test");
240
299
  if (has("go.mod")) push("go test ./...");
@@ -386,7 +445,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
386
445
  }
387
446
  if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
388
447
  const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
389
- return { ...container, stack: "python", testCmd: pytestCmd(), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile };
448
+ return { ...container, stack: "python", testCmd: pytestCmd(process.env, projectRoot), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile };
390
449
  }
391
450
  if (existsSync(join(projectRoot, "mix.exs"))) {
392
451
  return { ...container, stack: "mix", testCmd: "mix test", buildCmd: "mix compile", triggerFile: "mix.exs" };
@@ -470,7 +529,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
470
529
  }
471
530
  const pyFile = rootFiles.find((f) => f.endsWith(".py"));
472
531
  if (pyFile) {
473
- return { ...container, stack: "python", testCmd: pytestCmd(), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile: pyFile };
532
+ return { ...container, stack: "python", testCmd: pytestCmd(process.env, projectRoot), buildCmd: `${pythonBin()} -m compileall -q .`, triggerFile: pyFile };
474
533
  }
475
534
  } catch (_) {}
476
535
 
@@ -966,19 +1025,19 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
966
1025
  try {
967
1026
  let rawConfig = readFileSync(configPath, "utf-8");
968
1027
  if (/^\s*test:\s*.*$/m.test(rawConfig)) {
969
- rawConfig = rawConfig.replace(/^\s*test:\s*.*$/m, ` test: "${testCmd}"`);
1028
+ rawConfig = rawConfig.replace(/^\s*test:\s*.*$/m, () => ` test: ${yamlScalar(testCmd)}`);
970
1029
  } else if (/^\s*verify:\s*$/m.test(rawConfig)) {
971
- rawConfig = rawConfig.replace(/^\s*verify:\s*$/m, `verify:\n test: "${testCmd}"`);
1030
+ rawConfig = rawConfig.replace(/^\s*verify:\s*$/m, () => `verify:\n test: ${yamlScalar(testCmd)}`);
972
1031
  } else {
973
- rawConfig += `\nverify:\n test: "${testCmd}"\n`;
1032
+ rawConfig += `\nverify:\n test: ${yamlScalar(testCmd)}\n`;
974
1033
  }
975
1034
  if (detected.buildCmd && /^\s*build:\s*["']?["']?\s*$/m.test(rawConfig)) {
976
- rawConfig = rawConfig.replace(/^\s*build:\s*.*$/m, ` build: "${detected.buildCmd}"`);
1035
+ rawConfig = rawConfig.replace(/^\s*build:\s*.*$/m, () => ` build: ${yamlScalar(detected.buildCmd)}`);
977
1036
  }
978
1037
  writeFileSync(configPath, rawConfig, "utf-8");
979
1038
  } catch (_) {}
980
1039
  } else {
981
- const cfg = `version: 1\nprovider: jules\ntier: free\nverify:\n test: "${testCmd}"\n build: "${detected.buildCmd || ""}"\nlimits:\n diff_kb: 75\n daily_tasks: 15\n repair_attempts: 3\nbranch_prefix: agent/\nbase_branch: main\n`;
1040
+ const cfg = `version: 1\nprovider: jules\ntier: free\nverify:\n test: ${yamlScalar(testCmd)}\n build: ${yamlScalar(detected.buildCmd || "")}\nlimits:\n diff_kb: 75\n daily_tasks: 15\n repair_attempts: 3\nbranch_prefix: agent/\nbase_branch: main\n`;
982
1041
  writeFileSync(configPath, cfg, "utf-8");
983
1042
  }
984
1043
 
@@ -987,12 +1046,12 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
987
1046
  try {
988
1047
  let rawJules = readFileSync(julesPath, "utf-8");
989
1048
  if (/^\s*test_cmd:\s*.*$/m.test(rawJules)) {
990
- rawJules = rawJules.replace(/^\s*test_cmd:\s*.*$/m, `test_cmd: "${testCmd}"`);
1049
+ rawJules = rawJules.replace(/^\s*test_cmd:\s*.*$/m, () => `test_cmd: ${yamlScalar(testCmd)}`);
991
1050
  } else {
992
- rawJules += `\ntest_cmd: "${testCmd}"\n`;
1051
+ rawJules += `\ntest_cmd: ${yamlScalar(testCmd)}\n`;
993
1052
  }
994
1053
  if (detected.buildCmd && /^\s*build_cmd:\s*["']?["']?\s*$/m.test(rawJules)) {
995
- rawJules = rawJules.replace(/^\s*build_cmd:\s*.*$/m, `build_cmd: "${detected.buildCmd}"`);
1054
+ rawJules = rawJules.replace(/^\s*build_cmd:\s*.*$/m, () => `build_cmd: ${yamlScalar(detected.buildCmd)}`);
996
1055
  }
997
1056
  writeFileSync(julesPath, rawJules, "utf-8");
998
1057
  } catch (_) {}
@@ -40,7 +40,15 @@ const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__"
40
40
  * Covers, by directory: `test/`, `tests/`, `spec/`, `specs/`, `__tests__/` at
41
41
  * any depth including the repository root. By filename: `foo.test.js`,
42
42
  * `foo.spec.ts`, `foo_test.go`, `foo_spec.rb`, `test_calc.py` (pytest),
43
- * `spec_helper.rb`, and Foundry's `Foo.t.sol` / `FooTest.sol`.
43
+ * `spec_helper.rb`, Foundry's `Foo.t.sol` / `FooTest.sol`, and the canonical
44
+ * Node root test file — `test.js`, `test.mjs`, `test.ts` and the rest of the
45
+ * `.cjs`/`.jsx`/`.tsx` family. AVA runs the root `test.js` by convention
46
+ * ("test files starting with `test` … at the repo root"), and so does
47
+ * `node --test test.js`; P-Limit's whole suite lived in one and the guard
48
+ * watched none of it. The bare-name rule is limited to the Node family on
49
+ * purpose: `test.py` is not a pytest file (pytest collects `test_*.py`),
50
+ * `test.go` is not a Go test (`*_test.go`), and `test.rb` is neither an RSpec
51
+ * (`*_spec.rb`) nor a Minitest (`test_*.rb`) file.
44
52
  *
45
53
  * @param {string} file - Repo-relative path, either separator.
46
54
  * @returns {boolean}
@@ -55,6 +63,7 @@ export function isTestPath(file) {
55
63
  }
56
64
 
57
65
  const base = segments[segments.length - 1];
66
+ if (ROOT_NODE_TEST_RE.test(base)) return true;
58
67
  return (
59
68
  base.includes(".test.") ||
60
69
  base.includes(".spec.") ||
@@ -66,3 +75,11 @@ export function isTestPath(file) {
66
75
  base.endsWith(".t.sol")
67
76
  );
68
77
  }
78
+
79
+ /**
80
+ * A supported Node runner's canonical root test file: `test` exactly, with a
81
+ * JavaScript-family extension. `test.helpers.js` and `tests.js` must not
82
+ * match — a name that starts with the word and keeps going is a support
83
+ * module, and the plural is an ordinary source file.
84
+ */
85
+ const ROOT_NODE_TEST_RE = /^test\.(?:[cm]?js|jsx|[cm]?ts|tsx)$/;
@@ -1,12 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
3
+ import { parseYaml, yamlScalar, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
4
4
  import { suggestProvider, detectAvailableProviders } from "./provider-readiness.mjs";
5
5
  import { detectDefaultBranch } from "./git.mjs";
6
6
  import { resolveWorkspaceBoundary, oracleCandidates } from "./stack-detector.mjs";
7
7
  import { PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
8
8
  import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
9
- import { parseCollectedTests } from "./ops/test-collection.mjs";
9
+ import { parseCollectedTests, producedNoOutput, looksLikeTestSuiteCommand } from "./ops/test-collection.mjs";
10
10
  import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
11
11
  import { KIT_VERSION } from "./version.mjs";
12
12
 
@@ -130,22 +130,26 @@ export function planInit(root = process.cwd(), options = {}) {
130
130
  // scaffolded limits always match what the runtime will later enforce.
131
131
  const tierPresetLimits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
132
132
 
133
- // Preserve existing config if present
133
+ // Preserve existing config if present (ignored when pristine requested)
134
134
  let existingConfig = {};
135
- const existingConfigPath = join(root, ".agent", "config.yml");
136
- if (existsSync(existingConfigPath)) {
137
- try {
138
- existingConfig = parseYaml(readFileSync(existingConfigPath, "utf-8")) || {};
139
- } catch (_) {}
135
+ if (!options.pristine) {
136
+ const existingConfigPath = join(root, ".agent", "config.yml");
137
+ if (existsSync(existingConfigPath)) {
138
+ try {
139
+ existingConfig = parseYaml(readFileSync(existingConfigPath, "utf-8")) || {};
140
+ } catch (_) {}
141
+ }
140
142
  }
141
143
 
142
- // Preserve existing jules.yml if present
144
+ // Preserve existing jules.yml if present (ignored when pristine requested)
143
145
  let existingJules = {};
144
- const existingJulesPath = join(root, ".agent", "jules.yml");
145
- if (existsSync(existingJulesPath)) {
146
- try {
147
- existingJules = parseYaml(readFileSync(existingJulesPath, "utf-8")) || {};
148
- } catch (_) {}
146
+ if (!options.pristine) {
147
+ const existingJulesPath = join(root, ".agent", "jules.yml");
148
+ if (existsSync(existingJulesPath)) {
149
+ try {
150
+ existingJules = parseYaml(readFileSync(existingJulesPath, "utf-8")) || {};
151
+ } catch (_) {}
152
+ }
149
153
  }
150
154
 
151
155
  const customLimits = options.limits || existingConfig.limits;
@@ -201,6 +205,19 @@ export function planInit(root = process.cwd(), options = {}) {
201
205
  ? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
202
206
  : "";
203
207
 
208
+ // A generated comment must not begin with an ESLint directive keyword.
209
+ //
210
+ // `global`, `globals`, `exported`, `eslint`, `eslint-disable` and friends are
211
+ // configuration when they open a comment — in any language ESLint has a
212
+ // parser for, YAML included. This template began a line with "global runs
213
+ // the ...", which ESLint read as `/* global runs, the, ... */`: a declaration
214
+ // of globals named after each word of the sentence. Measured on
215
+ // `unjs/unimport`, that produced 18 `no-unused-vars` errors quoting
216
+ // individual English words back at the user, on a file `init` had written
217
+ // thirty seconds earlier.
218
+ //
219
+ // The word is unavoidable — `global` is the name of the setting being
220
+ // explained — so the sentence leads with the key instead.
204
221
  const configYaml = `# Agent Orchestrator Kit Config (v${KIT_VERSION})
205
222
  # provider: jules | claude-code | codex | gemini-flash (agentctl providers)
206
223
  version: 1
@@ -212,16 +229,16 @@ ${limitsBlock}
212
229
  verify:
213
230
  # minimal | standard | max — see: agentctl profile --list
214
231
  profile: ${profile}
215
- # global runs the repository's own commands; affected resolves changed files
216
- # to their sub-projects and runs only those suites (monorepos)
232
+ # scope: global runs the commands this repository declares; affected
233
+ # resolves changed files to their sub-projects, running only those suites
217
234
  scope: ${verifyScope}
218
- test: "${verify.test}"
235
+ test: ${yamlScalar(verify.test)}
219
236
  # How long a verification stage may run before the gate kills it (default
220
237
  # 300000). Raise it for a suite that legitimately takes longer.
221
238
  timeout_ms: 300000
222
- build: "${verify.build}"
223
- lint: "${verify.lint}"
224
- typecheck: "${verify.typecheck}"
239
+ build: ${yamlScalar(verify.build)}
240
+ lint: ${yamlScalar(verify.lint)}
241
+ typecheck: ${yamlScalar(verify.typecheck)}
225
242
 
226
243
  presets:
227
244
  ${selectedPresets.map((p) => ` - ${p}`).join("\n")}
@@ -246,11 +263,11 @@ ${selectedPresets.map((p) => ` - ${p}`).join("\n")}
246
263
 
247
264
  const julesYaml = `# Google Jules Repository Configuration (Version 2)
248
265
  version: 2
249
- test_cmd: "${verify.test}"
250
- build_cmd: "${verify.build}"
266
+ test_cmd: ${yamlScalar(verify.test)}
267
+ build_cmd: ${yamlScalar(verify.build)}
251
268
  forbidden_paths:
252
- ${forbiddenPaths.map((p) => ` - "${p}"`).join("\n")}
253
- allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`).join("\n") : "[]"}
269
+ ${forbiddenPaths.map((p) => ` - ${yamlScalar(p)}`).join("\n")}
270
+ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - ${yamlScalar(p)}`).join("\n") : "[]"}
254
271
  `;
255
272
 
256
273
  return {
@@ -330,8 +347,18 @@ export function loadPresets(root = process.cwd()) {
330
347
  * cost of rejecting a candidate is trying the next one, where at gate time it
331
348
  * would be a hard red on a repository that is fine.
332
349
  */
333
- function probeVerdict(probeRes) {
350
+ function probeVerdict(probeRes, cmd) {
334
351
  if (!probeRes.ok) return "failed";
352
+ // Writing nothing at all is `empty`, not `silent`. The distinction is the
353
+ // whole point: `silent` is the forgiving bucket that keeps a command the
354
+ // guard could not read, and `pnpm -r test` landed in it because it prints
355
+ // no output to be unreadable. So the candidate this verdict was introduced
356
+ // to reject was the one case it waved through, and every repository
357
+ // scaffolded on such a workspace kept it.
358
+ // Same rule as the gate's floor, from the same predicate: a command that
359
+ // claims to run a suite and printed nothing ran none. A static gate that
360
+ // printed nothing did what it promised, so it keeps the forgiving verdict.
361
+ if (looksLikeTestSuiteCommand(cmd) && producedNoOutput(probeRes.stdout, probeRes.stderr)) return "empty";
335
362
  const { count } = parseCollectedTests(probeRes.stdout, probeRes.stderr);
336
363
  if (count === null) return "silent";
337
364
  return count > 0 ? "ran" : "empty";
@@ -341,7 +368,7 @@ async function resolveRunnableOracle(root, testCmd, options = {}) {
341
368
  if (!testCmd) return testCmd;
342
369
  const probeSp = spinner(`Probing oracle: ${testCmd}`, options);
343
370
  const probeRes = await runVerificationProbe(testCmd, root);
344
- const verdict = probeVerdict(probeRes);
371
+ const verdict = probeVerdict(probeRes, testCmd);
345
372
  if (verdict === "ran") {
346
373
  probeSp.stop(`Oracle verified successfully (${probeRes.durationMs}ms)`);
347
374
  return testCmd;
@@ -364,7 +391,7 @@ async function resolveRunnableOracle(root, testCmd, options = {}) {
364
391
  for (const cand of alternates) {
365
392
  const altSp = spinner(`Trying ${cand}`, options);
366
393
  const altRes = await runVerificationProbe(cand, root);
367
- const altVerdict = probeVerdict(altRes);
394
+ const altVerdict = probeVerdict(altRes, cand);
368
395
  if (altVerdict === "ran") {
369
396
  altSp.stop(`${cand} runs here (${altRes.durationMs}ms) — using it instead`);
370
397
  return cand;
@@ -87,7 +87,7 @@ export function detectStackOracles(root = process.cwd()) {
87
87
  const flag = isCargoWs ? " --workspace" : "";
88
88
  candidates.testCmd = `cargo test${flag}`;
89
89
  candidates.buildCmd = `cargo build${flag}`;
90
- candidates.lintCmd = `cargo clippy${flag} -- -D warnings`;
90
+ candidates.lintCmd = `cargo clippy${flag}`;
91
91
  candidates.typecheckCmd = `cargo check${flag}`;
92
92
  }
93
93
 
@@ -261,7 +261,21 @@ ${fullPrompt}
261
261
  * @returns {Promise<{ ok: boolean, dryRun: boolean, taskFile: string, written: boolean, plan: object }>}
262
262
  */
263
263
  export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
264
- const interactive = options.interactive !== false && isTTY(options.stdin || process.stdin);
264
+ // `-p` is the documented way to skip the questions, so it has to skip them.
265
+ //
266
+ // README: "Pass the prompt to skip straight to review:
267
+ // npx jules-orchestrator-kit task create -p 'Refactor the invoice module'".
268
+ // Interactivity was decided by `isTTY` alone, and `-p` was consulted only by
269
+ // the TODO-import branch below — so in a real terminal the advertised
270
+ // quickstart stopped at "? Task Title" and waited for a keypress forever,
271
+ // then asked for the instructions it had already been handed.
272
+ //
273
+ // It looked fine under test because a non-TTY run takes the headless path and
274
+ // never asks. That is the same defect the flag itself has: one rule, two
275
+ // paths, and only the path nobody was watching kept the old answer. Making
276
+ // `-p` mean the headless path everywhere makes the two agree by construction.
277
+ const promptSupplied = typeof options.prompt === "string" && options.prompt.trim() !== "";
278
+ const interactive = options.interactive !== false && !promptSupplied && isTTY(options.stdin || process.stdin);
265
279
 
266
280
  let title = options.title;
267
281
  let promptText = options.prompt;