jules-orchestrator-kit 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,12 +49,15 @@
49
49
  <a id="quickstart"></a>
50
50
  ## Quickstart
51
51
 
52
- Get running in any repository in 3 commands (zero configuration required):
52
+ Get running in any repository in 3 commands. `init` asks seven questions and
53
+ fills in a sensible answer for each; `--yes` accepts all of them, detects the
54
+ stack, and probes the test command it picked before writing it down.
53
55
 
54
56
  ```bash
55
57
  # 1. Scaffold config, AGENTS.md, role prompts and guardrails
56
58
  # (auto-detects Python, Rust, Go, Node, PHP, etc.)
57
- npx jules-orchestrator-kit init
59
+ # Drop --yes to choose provider, plan, profile and workflows yourself.
60
+ npx jules-orchestrator-kit init --yes
58
61
  ```
59
62
 
60
63
  ```bash
@@ -204,7 +207,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
204
207
  * **Fail-Closed Security & Secret Redaction:** Evaluates explicit Deny rules before Allow rules against canonicalized, case-folded paths. Redacts high-entropy keys and base64-encoded credentials (such as Kubernetes `Secret` manifests).
205
208
  * **Complexity & Cost Router:** Zero-dependency heuristic classifier (`src/router.mjs`) routing mechanical tasks to lightweight models while reserving primary models for complex refactors, with a `node --check` syntax-verification gate that transparently escalates a FAST-tier result to the primary provider if it left broken JS on disk.
206
209
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
207
- * **Verified Test Suite:** Tested with **1085 unit tests across 150 suites passing in < 15.0s**.
210
+ * **Verified Test Suite:** Tested with **1109 unit tests across 154 suites passing in < 15.0s**.
208
211
 
209
212
  <br/>
210
213
 
package/bin/agentctl.mjs CHANGED
@@ -429,6 +429,11 @@ async function main() {
429
429
  if (p.violations) {
430
430
  p.violations.forEach((v) => console.log(` - Violation: ${v.file} (Rule: ${v.rule})`));
431
431
  }
432
+ if (p.setup) {
433
+ console.log(` - Setup: accepted ${p.setup.length} gate scaffold file(s) this repository did not have yet`);
434
+ p.setup.forEach((f) => console.log(` ${f}`));
435
+ console.log(` Commit them to the base branch and the full protect rules apply from then on.`);
436
+ }
432
437
  if (p.findings) {
433
438
  p.findings.forEach((f) => console.log(` - [${f.severity}] ${f.type}: ${f.description}`));
434
439
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "Zero-dependency safety gatekeeper, test oracle generator, and multi-agent coordination protocol for autonomous coding agents — Google Jules, Claude Code, Codex and Gemini CLI.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,6 +51,7 @@ import {
51
51
  TAMPER_CANARIES,
52
52
  PREDICATE_MUTANTS,
53
53
  EMPTY_RUN_CANARIES,
54
+ COUNTED_RUN_CANARIES,
54
55
  SCOPE_CANARIES,
55
56
  INNOCENT_EDITS,
56
57
  UNREADABLE_DIALECTS,
@@ -105,6 +106,17 @@ const add = (name, ok, detail) => {
105
106
 
106
107
  {
107
108
  const missed = EMPTY_RUN_CANARIES.filter((c) => parseCollectedTests(c.output, "").count !== 0);
109
+ const undercounted = COUNTED_RUN_CANARIES.filter((c) => {
110
+ const n = parseCollectedTests(c.output, "").count;
111
+ return n === null || n < c.atLeast;
112
+ });
113
+ add(
114
+ "policy: a stated count is never read as empty",
115
+ undercounted.length === 0,
116
+ undercounted.length
117
+ ? undercounted.map((c) => `${c.id} (${c.why})`).join("; ")
118
+ : `${COUNTED_RUN_CANARIES.length} healthy runs counted, not rejected`
119
+ );
108
120
  add(
109
121
  "policy: empty-run detection",
110
122
  missed.length === 0,
package/src/engine.mjs CHANGED
@@ -204,6 +204,16 @@ export async function gate(opts = {}) {
204
204
  // Read from the base commit like every other trusted field: an
205
205
  // uncommitted `required: false` must not be able to switch the gate off.
206
206
  required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
207
+ // The floor the collection check applies. Omitting it here meant
208
+ // `verify.minTests` was silently dropped and always defaulted to 1
209
+ // — while the failure message told the operator to set exactly
210
+ // that. A remediation hint that does nothing is worse than none.
211
+ minTests:
212
+ parsed.verify?.minTests !== undefined
213
+ ? parsed.verify.minTests
214
+ : parsed.verify?.min_tests !== undefined
215
+ ? parsed.verify.min_tests
216
+ : config.verify.minTests,
207
217
  scope: parsed.verify?.scope || config.verify.scope || "global",
208
218
  timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
209
219
  };
@@ -249,7 +259,45 @@ export async function gate(opts = {}) {
249
259
  violation.file = link;
250
260
  }
251
261
 
252
- phases.push({ phase: "scope", ok: scopeResult.ok, violations: scopeResult.violations });
262
+ // Bootstrap: the files that bring a repository under the gate are not agent
263
+ // edits to the gate.
264
+ //
265
+ // `init` writes `.agent/**` and then tells the user to commit it. Doing
266
+ // exactly that produced Exit 3 on the very first run, because the base
267
+ // branch does not have the commit yet and every scaffolded path matches
268
+ // BUILTIN_PROTECT or BUILTIN_DENY. The advice printed alongside it was
269
+ // `--allow-protected` — so a newcomer's first lesson was how to switch the
270
+ // scope guard off. A gate that refuses its own installation is not strict,
271
+ // it is broken.
272
+ //
273
+ // Narrow on purpose, and only where it cannot weaken anything: the base
274
+ // commit must have no gate config at all — in which case `trustedScope` is
275
+ // already built-ins only and nothing in the added files is trusted — and
276
+ // every violating path must be scaffold that the base does not have. A
277
+ // repository already under the gate keeps the full rule, so an agent still
278
+ // cannot touch the policy it is governed by.
279
+ let acceptedScaffold = [];
280
+ if (!scopeResult.ok && !trustedConfigRaw) {
281
+ const violations = scopeResult.violations || [];
282
+ const isScaffold = (f) => typeof f === "string" && f.replace(/\\/g, "/").startsWith(".agent/");
283
+ if (
284
+ violations.length > 0 &&
285
+ violations.every((v) => isScaffold(v.file) && showFromOrigin(root, base, v.file) === null)
286
+ ) {
287
+ acceptedScaffold = violations.map((v) => v.file);
288
+ scopeResult.violations = [];
289
+ scopeResult.ok = true;
290
+ }
291
+ }
292
+
293
+ phases.push({
294
+ phase: "scope",
295
+ ok: scopeResult.ok,
296
+ violations: scopeResult.violations,
297
+ // Reported, never silent: the operator has to see that the gate accepted
298
+ // files it would otherwise have blocked, and why.
299
+ ...(acceptedScaffold.length > 0 ? { setup: acceptedScaffold } : {}),
300
+ });
253
301
  appendTelemetry(root, "gate_phase", { phase: "scope", ok: scopeResult.ok });
254
302
  if (progressBus && progressToken) {
255
303
  progressBus.reportProgress(progressToken, 25, 100, "Phase 1/4: Scope Guard verification complete");
@@ -479,3 +479,34 @@ IMPORT_EXTRACTION_CASES.push(
479
479
  why: "same, in the other place examples live",
480
480
  }
481
481
  );
482
+
483
+ /**
484
+ * Runs that stated a count, which must never be read as empty.
485
+ *
486
+ * The floor was written to be one-sided — only a *stated* zero fails — and
487
+ * then a phrase was allowed to outrank a statement. A healthy 190-test TAP
488
+ * suite whose one skipped fixture printed `# SKIP no tests found` was
489
+ * rejected as empty and attributed to Jest, in a repository that does not
490
+ * use Jest. A false red on a correct repository is how a user learns the
491
+ * gate is broken and turns it off.
492
+ */
493
+ export const COUNTED_RUN_CANARIES = [
494
+ {
495
+ id: "tap with a skip message",
496
+ output: "TAP version 13\n# Subtest: performance\n # SKIP no tests found\nok 1 - performance # SKIP\n1..191\n# tests 191\n# pass 190\n# skip 1",
497
+ atLeast: 1,
498
+ why: "`no tests found` inside a skip comment is not a statement about the run",
499
+ },
500
+ {
501
+ id: "pytest mentioning an empty module",
502
+ output: "collected 12 items\n\ntests/test_a.py ............\n\n12 passed in 0.3s",
503
+ atLeast: 1,
504
+ why: "a stated count is present and must win",
505
+ },
506
+ {
507
+ id: "go, verbose, two tests",
508
+ output: "--- PASS: TestAdd (0.00s)\n--- PASS: TestSub (0.00s)\nok \texample.com/lib\t0.004s",
509
+ atLeast: 1,
510
+ why: "Go's own `ok <package>` line, which a bare `^ok\\s` confused with TAP's `ok 1 - name`",
511
+ },
512
+ ];
@@ -71,8 +71,15 @@ const EXPLICIT_ZERO = [
71
71
 
72
72
  /** Go prints this per package that has no test files at all. */
73
73
  const GO_NO_TEST_FILES = /\[no test files\]/;
74
- /** Any sign that a Go package did run tests. */
75
- const GO_RAN_SOMETHING = /^(?:ok|FAIL|---\s+(?:PASS|FAIL|SKIP)):?\s/m;
74
+ /**
75
+ * Any sign that a Go package did run tests.
76
+ *
77
+ * The negative lookahead is what separates Go from TAP. `ok 1 - performance`
78
+ * is a TAP result line and `ok example.com/lib 0.004s` is a Go package
79
+ * summary, and a bare `^ok\s` matched both — so a 190-test TAP suite was
80
+ * classified as Go and reported as having stated no count at all.
81
+ */
82
+ const GO_RAN_SOMETHING = /^(?:(?:ok|FAIL)\s+(?!\d+\s)\S+|---\s+(?:PASS|FAIL|SKIP):?\s)/m;
76
83
 
77
84
  /**
78
85
  * Read a collected-test count out of a runner's output.
@@ -87,8 +94,20 @@ export function parseCollectedTests(stdout = "", stderr = "") {
87
94
  const text = `${stdout || ""}\n${stderr || ""}`;
88
95
  if (!text.trim()) return { count: null, runner: null };
89
96
 
90
- for (const rule of EXPLICIT_ZERO) {
91
- if (rule.re.test(text)) return { count: 0, runner: rule.name };
97
+ // A stated count wins over a phrase that merely resembles one.
98
+ //
99
+ // `EXPLICIT_ZERO` used to be consulted first, so any output containing the
100
+ // words "no tests found" was read as a zero — including a healthy TAP run
101
+ // of 190 passing tests whose one skipped fixture printed
102
+ // `# SKIP no tests found`. The gate rejected the suite as empty and named
103
+ // Jest as the runner, in a repository that does not use Jest. A phrase
104
+ // appears anywhere in a stream; a count is stated deliberately, so the
105
+ // count is the better witness and has to be asked first.
106
+ for (const rule of COUNT_PATTERNS) {
107
+ const m = rule.re.exec(text);
108
+ if (!m) continue;
109
+ const n = Number(m[1]);
110
+ if (Number.isFinite(n)) return { count: n, runner: rule.name };
92
111
  }
93
112
 
94
113
  // Go states absence per package rather than as a count, so it needs its own
@@ -104,11 +123,9 @@ export function parseCollectedTests(stdout = "", stderr = "") {
104
123
  return { count: perTest && perTest.length > 0 ? perTest.length : null, runner: "go" };
105
124
  }
106
125
 
107
- for (const rule of COUNT_PATTERNS) {
108
- const m = rule.re.exec(text);
109
- if (!m) continue;
110
- const n = Number(m[1]);
111
- if (Number.isFinite(n)) return { count: n, runner: rule.name };
126
+ // Only now: no runner stated a number, so a declared absence is all there is.
127
+ for (const rule of EXPLICIT_ZERO) {
128
+ if (rule.re.test(text)) return { count: 0, runner: rule.name };
112
129
  }
113
130
 
114
131
  return { count: null, runner: null };
@@ -188,6 +188,56 @@ export function detectEdgeRuntime(projectRoot = process.cwd()) {
188
188
  /**
189
189
  * Detects 24+ polyglot stacks and container environments.
190
190
  */
191
+ /**
192
+ * Test commands worth trying, best first, when the detected one does not run.
193
+ *
194
+ * `init` probes the command it picked. On a repository whose Makefile
195
+ * declares a `test` target that needs a build environment the machine does
196
+ * not have, that probe failed, printed `Oracle verification probe failed`,
197
+ * and the wizard wrote the broken command into the config anyway — in a
198
+ * repository where `pytest` was on PATH and all 360 tests passed in 1.3s.
199
+ * Measuring something and then ignoring the measurement is worse than not
200
+ * measuring: it produces a hard red on day one, which is how a user learns
201
+ * the gate is broken and turns it off.
202
+ *
203
+ * Kept deliberately generic — a per-ecosystem convention, never a per-project
204
+ * or per-provider guess.
205
+ *
206
+ * @param {string} root
207
+ * @param {string} [detected] - the command detection chose; always first.
208
+ * @returns {string[]} ordered, de-duplicated candidates
209
+ */
210
+ export function oracleCandidates(root = process.cwd(), detected = "") {
211
+ const out = [];
212
+ const push = (c) => {
213
+ const v = (c || "").trim();
214
+ if (v && !out.includes(v) && !isPlaceholderTestScript(v)) out.push(v);
215
+ };
216
+ const has = (f) => existsSync(join(root, f));
217
+
218
+ push(detected);
219
+
220
+ if (has("package.json")) {
221
+ try {
222
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
223
+ if (pkg.scripts?.test && !isPlaceholderTestScript(pkg.scripts.test)) push("npm test");
224
+ } catch (_) {}
225
+ }
226
+ if (has("pytest.ini") || has("pyproject.toml") || has("setup.py") || has("tox.ini") || has("setup.cfg")) {
227
+ push(pytestCmd());
228
+ }
229
+ if (has("Cargo.toml")) push("cargo test");
230
+ if (has("go.mod")) push("go test ./...");
231
+ if (has("Gemfile")) push("bundle exec rspec");
232
+ if (has("composer.json")) push("./vendor/bin/phpunit");
233
+ if (has("pom.xml")) push("mvn -q test");
234
+ if (has("build.gradle") || has("build.gradle.kts")) push("./gradlew test");
235
+ if (has("pubspec.yaml")) push("dart test");
236
+ if (has("Package.swift")) push("swift test");
237
+
238
+ return out;
239
+ }
240
+
191
241
  export function detectPolyglotStack(projectRoot = process.cwd()) {
192
242
  const edgeInfo = detectEdgeRuntime(projectRoot);
193
243
  const isDevcontainer = existsSync(join(projectRoot, ".devcontainer", "devcontainer.json"));
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { parseYaml, 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
- import { resolveWorkspaceBoundary } from "./stack-detector.mjs";
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
9
  import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
@@ -302,6 +302,51 @@ export function loadPresets(root = process.cwd()) {
302
302
  * @param {object} [options]
303
303
  * @returns {Promise<{ ok: boolean, configPath: string, plan: object }>}
304
304
  */
305
+ /**
306
+ * Probe the chosen test command, and take detection's next choice if it fails.
307
+ *
308
+ * Runs on the non-interactive path too. `--yes` means "do not ask me", not
309
+ * "do not check" — and the user who is not watching is exactly the one who
310
+ * cannot notice that the command written into their config does not run.
311
+ * Before this, the probe lived inside the interactive branch, so
312
+ * `agentctl init --yes` wrote `make test` into a repository where `make test`
313
+ * exits 2 and `npm test` passes, and the first gate run was a hard red.
314
+ *
315
+ * @returns {Promise<string>} the command to save
316
+ */
317
+ async function resolveRunnableOracle(root, testCmd, options = {}) {
318
+ if (!testCmd) return testCmd;
319
+ const probeSp = spinner(`Probing oracle: ${testCmd}`, options);
320
+ const probeRes = await runVerificationProbe(testCmd, root);
321
+ if (probeRes.ok) {
322
+ probeSp.stop(`Oracle verified successfully (${probeRes.durationMs}ms)`);
323
+ return testCmd;
324
+ }
325
+ probeSp.fail(`Oracle verification probe failed (Exit ${probeRes.code})`);
326
+
327
+ const alternates = oracleCandidates(root, testCmd).filter((c) => c !== testCmd).slice(0, 3);
328
+ for (const cand of alternates) {
329
+ const altSp = spinner(`Trying ${cand}`, options);
330
+ const altRes = await runVerificationProbe(cand, root);
331
+ if (altRes.ok) {
332
+ altSp.stop(`${cand} runs here (${altRes.durationMs}ms) — using it instead`);
333
+ return cand;
334
+ }
335
+ altSp.fail(`${cand} also failed (Exit ${altRes.code})`);
336
+ }
337
+
338
+ // Nothing runs. Say so in terms the user can act on, rather than leaving a
339
+ // failed spinner to scroll past and a broken command in the config.
340
+ const out = options.stdout || process.stdout;
341
+ out.write("\n");
342
+ out.write(" \u26a0\ufe0f No test command could be run in this environment.\n");
343
+ out.write(` Keeping "${testCmd}" \u2014 the gate will fail until it runs here.\n`);
344
+ out.write(" Point verify.test in .agent/config.yml at a command that works,\n");
345
+ out.write(" or, if this repository genuinely has no suite, set\n");
346
+ out.write(" verify.required: false deliberately rather than by accident.\n\n");
347
+ return testCmd;
348
+ }
349
+
305
350
  export async function runInitWizard(root = process.cwd(), options = {}) {
306
351
  const interactive = options.interactive !== false && isTTY(options.stdin || process.stdin);
307
352
 
@@ -322,6 +367,7 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
322
367
  let selectedProvider = options.provider || existingConfig.provider;
323
368
  let selectedProfile = options.profile || existingConfig.verify?.profile;
324
369
  let testCmd = options.testCmd;
370
+ let probeInteractive = null;
325
371
  let buildCmd = options.buildCmd;
326
372
  let selectedPresets = options.presets;
327
373
 
@@ -402,16 +448,20 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
402
448
 
403
449
  selectedPresets = await multiSelect(presetOptions, "Select Autonomous Workflows to Enable", options);
404
450
 
405
- const shouldProbe = await confirm("Run verification probe on test command before saving?", true, options);
406
- if (shouldProbe && testCmd) {
407
- const probeSp = spinner(`Probing oracle: ${testCmd}`, options);
408
- const probeRes = await runVerificationProbe(testCmd, root);
409
- if (probeRes.ok) {
410
- probeSp.stop(`Oracle verified successfully (${probeRes.durationMs}ms)`);
411
- } else {
412
- probeSp.fail(`Oracle verification probe failed (Exit ${probeRes.code})`);
413
- }
414
- }
451
+ probeInteractive = await confirm("Run verification probe on test command before saving?", true, options);
452
+ }
453
+
454
+ // The probe runs whether or not anyone was asked: interactive users can
455
+ // decline it, but silence from `--yes` is not a decline.
456
+ if (probeInteractive !== false && options.probe !== false) {
457
+ // Resolve the command the way planInit will, or there is nothing to
458
+ // probe: on the headless path `testCmd` stays undefined until planInit
459
+ // fills it in from detection, so the probe silently examined nothing —
460
+ // the exact fail-open shape this project keeps finding in itself.
461
+ const effective =
462
+ testCmd || existingConfig.verify?.test || detectStackOracles(root)?.candidates?.testCmd || "";
463
+ const adopted = await resolveRunnableOracle(root, effective, options);
464
+ if (adopted) testCmd = adopted;
415
465
  }
416
466
 
417
467
  // `...options` first, for the same reason as in wizard-task.mjs: spreading it