jules-orchestrator-kit 0.53.0 → 0.54.1

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
@@ -197,10 +197,12 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
197
197
  * **Zero Runtime Dependencies:** Built exclusively on Node.js 20+ built-in modules (`node:fs`, `node:child_process`, `node:crypto`, `node:path`, `node:http`, `node:tty`, `node:test`).
198
198
  * **Cross-Platform Parity:** Verified 100% green across Linux, macOS (Darwin), and Windows on Node 20, 22, and 24.
199
199
  * **Autonomous Self-Healing Loop:** Captures test stderr/stdout, fingerprints error traces, and feeds structured context back into automated repair turns (up to 3 attempts) before human escalation.
200
+ * **Fail-Closed Verification:** A change that ran no verification command at all is rejected, not approved — "nothing to run" is not a pass. Repositories using only the scope and secret phases opt out explicitly with `verify.required: false`.
201
+ * **Binary-Aware Scanning:** Files git renders as `Binary files ... differ` are read directly for structured credentials, and their real size is charged against the diff ceiling, so a leading NUL byte cannot hide a token and a committed blob cannot walk past the payload governor.
200
202
  * **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).
201
203
  * **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.
202
204
  * **Terminal UI & Diagnostic Matrix (`agentctl doctor`):** Interactive terminal dashboard, task sidecar manager, and automated transactional self-repair.
203
- * **Verified Test Suite:** Tested with **797 unit tests across 98 suites passing in < 12.0s**.
205
+ * **Verified Test Suite:** Tested with **835 unit tests across 109 suites passing in < 14.0s**.
204
206
 
205
207
  <br/>
206
208
 
@@ -226,10 +228,11 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
226
228
  | `retry` | `agentctl retry <sessionId> [--role <role>] [--with-failure] [--json]` | Fetches error traces and activity logs from a failed session and synthesizes a targeted OODA retry dispatch. | `0` (Dispatched), `1` (Error) |
227
229
  | `prune` | `agentctl prune [--age 7d] [--state <state>] [--delete] [--yes] [--json]` | Queries and batch-archives or deletes stale/completed sessions via Jules v1alpha API to keep workspaces clean. | `0` (Cleaned) |
228
230
  | `pr harvest` | `agentctl pr harvest [--tier r0,r1] [--limit <n>] [--auto] [--allow-no-checks] [--dry-run]` | Discovers open agent PRs, evaluates CI checks & risk tiers, and auto-squashes green low-risk changes autonomously. A PR reporting **no** CI checks is skipped unless `--allow-no-checks` is passed, and an unavailable changed-file list blocks rather than classifying as low risk. | `0` (Triaged/Merged), `1` (Error) |
229
- | `providers` | `agentctl providers [--json]` | Probes every built-in provider and reports which ones this machine can dispatch to, what each one is missing, and which is active. | `0` (Active provider ready), `1` (Not ready) |
231
+ | `providers` | `agentctl providers [--json]` | Probes every built-in provider and reports which ones this machine can dispatch to, what each one is missing, and which is active. For a CLI provider, "ready" means the binary is on `PATH` — it does not prove the CLI is signed in. | `0` (Active provider ready), `1` (Not ready) |
232
+ | `provider set` | `agentctl provider set <name>` | Switches the active provider in `.agent/config.yml` in place, preserving comments. | `0` (Set), `1` (No manifest), `2` (Name missing) |
230
233
  | `profile` | `agentctl profile [--list] [--set minimal\|standard\|max] [--json]` | Shows the verification stages the configured profile expands to on this stack, or writes a new profile into `.agent/config.yml` without disturbing comments. | `0` (Shown/Set), `2` (Unknown profile) |
231
234
  | `ci init` | `agentctl ci init [--target github\|gitlab] [--force] [--dry-run] [--json]` | Generates a stack-aware CI gate workflow (`.github/workflows/agent-gate.yml` or `.gitlab-ci.agent-gate.yml`) that runs `agentctl check --mode committed`. Refuses to overwrite without `--force`. | `0` (Written/Skipped), `1` (Write error), `2` (Unknown target) |
232
- | `doctor` | `agentctl doctor [--json]` | Diagnostic DAG check runner & automated transactional self-repair engine. | `0` (Healthy), `1` (Failures) |
235
+ | `doctor` | `agentctl doctor [--probe] [--json]` | Diagnostic check runner. `--probe` additionally starts the configured provider's CLI to confirm it answers, rather than only finding it on `PATH`. | `0` (Healthy), `1` (Failures) |
233
236
  | `queue` | `agentctl queue [--dag] [--concurrency <n>] [--dry-run] [--json]` | Consumes and executes task envelopes in `.agent/jules-queue/` with Kahn's DAG dependency resolution. Non-task files (manifests, `README.md`) are skipped, and `--dry-run` previews without moving anything. | `0` (Complete) |
234
237
  | `swarm` | `agentctl swarm [--json]` | Runs parallel multi-agent swarm across worker slots with PID liveness detection. | `0` (Complete) |
235
238
  | `check` / `gate` / `audit`| `agentctl check [--mode working-tree] [--fix] [--json] [--json-report <path>]` | Runs security, secret scanning, rules budget audit, and tiered verification gates (with declarative assertion support) against working tree or branch. | `0` (Approved), `1` (Budget/Arg), `3` (Scope), `4` (Verify), `5` (Diff >75K), `6` (Secret), `8` (Flaky) |
package/bin/agentctl.mjs CHANGED
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
4
4
  import { readFileSync, existsSync, readdirSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { applyEnvAliases } from "../src/env-aliases.mjs";
7
+ import { selectFailureOutput } from "../src/ops/verify-output.mjs";
7
8
  import { loadConfig, resolveRoot, detectStack, bootstrapZeroTestRepo } from "../src/config.mjs";
8
9
  import { gate, dispatch, run, isTaskFile } from "../src/engine.mjs";
9
10
  import { acquireLock, releaseLock, lockStatus, getQueueDir } from "../src/state.mjs";
@@ -63,6 +64,7 @@ Commands:
63
64
  lock <action> Manage mutex locks (acquire | release | status)
64
65
  doctor Run system diagnostics and stack resolution checks
65
66
  providers List agent providers and whether this machine can reach them (--json)
67
+ provider set <name> Switch the active provider in .agent/config.yml
66
68
  profile Show or set the verification profile (--list, --set <name>, --json)
67
69
  ci init Generate a stack-aware CI gate workflow (--target github|gitlab, --force)
68
70
  bootstrap Bootstrap zero-test repository with verification oracle
@@ -195,9 +197,7 @@ function printVerifyFailure(failure) {
195
197
  if (failure.command) console.log(` - Command: ${failure.command}`);
196
198
  for (const d of failure.diagnostics || []) console.log(` - ${d}`);
197
199
 
198
- // stderr is where a failing suite says what it expected; stdout is the
199
- // fallback for the runners that report everything there.
200
- const output = (failure.stderr || "").trim() || (failure.stdout || "").trim();
200
+ const output = selectFailureOutput(failure);
201
201
  if (!output) return;
202
202
  const lines = output.split("\n");
203
203
  const tail = lines.slice(-VERIFY_OUTPUT_TAIL_LINES);
@@ -419,6 +419,13 @@ async function main() {
419
419
  if (p.findings) {
420
420
  p.findings.forEach((f) => console.log(` - [${f.severity}] ${f.type}: ${f.description}`));
421
421
  }
422
+ // `git_resolution` reports its cause in `error` and nothing else.
423
+ // Never rendering it meant an unresolvable base branch printed a bare
424
+ // "Phase [GIT_RESOLUTION] : ❌ FAIL" and the operator had to re-run
425
+ // with --json to find out what the tool had objected to.
426
+ if (!p.ok && p.error) {
427
+ console.log(` - Error: ${p.error}`);
428
+ }
422
429
  if (!p.ok && p.failure) {
423
430
  printVerifyFailure(p.failure);
424
431
  }
@@ -427,13 +434,49 @@ async function main() {
427
434
  console.log(`Overall Result: ${res.ok ? "APPROVED (Exit 0)" : `REJECTED (Exit ${res.code})`}\n`);
428
435
  if (!res.ok) {
429
436
  const failedPhase = res.phases.find((p) => !p.ok)?.phase;
437
+ // The secret scanner also carries the integrity findings, so exit 6
438
+ // alone cannot pick the hint: a weakened assertion and a leaked key
439
+ // arrive under the same phase and the same code.
440
+ const secretsPhase = res.phases.find((p) => p.phase === "secrets" && !p.ok);
441
+ const findingTypes = new Set((secretsPhase?.findings || []).map((f) => f.type));
442
+ const INTEGRITY_TYPES = new Set([
443
+ "TEST_TAMPERING_DETECTED",
444
+ "EDGE_RUNTIME_VIOLATION",
445
+ "CROSS_PACKAGE_BOUNDARY_VIOLATION",
446
+ ]);
447
+ const onlyIntegrityFindings =
448
+ findingTypes.size > 0 && [...findingTypes].every((t) => INTEGRITY_TYPES.has(t));
449
+
430
450
  // Exit 3 is also what a strictTestLock tamper verdict returns, so the
431
451
  // code alone cannot pick the hint — a scope remediation for a rewritten
432
452
  // test file sends the operator to the wrong flag entirely.
433
453
  if (res.repairs) {
434
- console.log(`💡 Remediation Hint (Exit 4 OODA Repair Exhausted):`);
435
- console.log(` • Automated self-repair could not pass tests cleanly.`);
436
- console.log(` • Review error fingerprints via: agentctl doctor\n`);
454
+ // A repair loop that never reached the agent is not an exhausted
455
+ // repair loop. Telling someone their tests could not be fixed when
456
+ // the provider rejected the credential sends them to read test
457
+ // output that was never produced.
458
+ const providerFailure =
459
+ res.repairs.finalStatus === "PROVIDER_INFRASTRUCTURE_FAILURE" ||
460
+ (res.repairs.attempts || []).every((a) => a && a.ok === false && a.error);
461
+ const firstError = (res.repairs.attempts || []).find((a) => a && a.error)?.error || res.repairs.error;
462
+ if (providerFailure && firstError) {
463
+ console.log(`💡 Remediation Hint (Exit 4 — the repair agent never ran):`);
464
+ console.log(` • The provider rejected the dispatch, so no repair was attempted.`);
465
+ console.log(` • Provider error: ${firstError}`);
466
+ console.log(` • Check the provider is usable: agentctl providers`);
467
+ console.log(` • The verification failure above is unchanged — fix it locally, or retry once the provider works.\n`);
468
+ } else {
469
+ console.log(`💡 Remediation Hint (Exit 4 OODA Repair Exhausted):`);
470
+ console.log(` • Automated self-repair could not pass tests cleanly.`);
471
+ if (firstError) console.log(` • Last attempt error: ${firstError}`);
472
+ console.log(` • Review error fingerprints via: agentctl doctor\n`);
473
+ }
474
+ } else if (onlyIntegrityFindings) {
475
+ console.log(`💡 Remediation Hint (Exit ${res.code} Test Integrity Violation — no secret was found):`);
476
+ console.log(` • The diff weakens or removes verification rather than leaking a credential.`);
477
+ console.log(` • Restore the assertion the finding names. A requirement that is not met belongs`);
478
+ console.log(` RED with a stated reason, not silenced.`);
479
+ console.log(` • Nothing needs rotating: this exit code is shared with the secret scanner.\n`);
437
480
  } else if (res.flakyVerdict?.verdict === "QUARANTINED") {
438
481
  console.log(`💡 Remediation Hint (Exit 8 Flaky Test Quarantined):`);
439
482
  console.log(` • This command has alternated between pass and fail across recent runs.`);
@@ -441,14 +484,52 @@ async function main() {
441
484
  console.log(`💡 Remediation Hint (Exit 188 Offline Network Violation / Infrastructure):`);
442
485
  console.log(` • An unmocked network request was blocked by the preload network guard during verification.`);
443
486
  console.log(` • Ensure dependencies are installed locally (run: npm install) and all network calls in tests are mocked.\n`);
487
+ } else if (res.phases.find((p) => p.phase === "verify" && !p.ok)?.failure?.stageId === "oracle") {
488
+ // Nothing exited non-zero here and --fix cannot help: there was no
489
+ // command to run. Offering the repair loop would send an agent to
490
+ // fix a failure that does not exist.
491
+ console.log(`💡 Remediation Hint (Exit ${res.code} No Verification Oracle):`);
492
+ console.log(` • Nothing was executed against this change, so the gate cannot approve it.`);
493
+ console.log(` • Give it a command: agentctl bootstrap (generates one for this stack)`);
494
+ console.log(` • Or set it by hand: verify.test in ${config._file || ".agent/config.yml"}`);
495
+ console.log(` • Scope- and secret-scanning only, on purpose? Set verify.required: false there.\n`);
444
496
  } else if (failedPhase === "verify" || failedPhase === "evidence") {
445
497
  console.log(`💡 Remediation Hint (Exit ${res.code} Verification Failed):`);
446
498
  console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
447
499
  console.log(` • To let agentctl attempt the repair loop itself, pass: agentctl gate --fix\n`);
448
500
  } else if (res.code === 3) {
449
- console.log(`💡 Remediation Hint (Exit 3 Scope Violation):`);
450
- console.log(` • To allow protected files in this run, pass: agentctl gate --allow-protected`);
451
- console.log(` • Or remove protected/denied paths from the diff before dispatching.\n`);
501
+ // The same violation has two very different causes. Right after
502
+ // `init`, every offending path is a file the tool itself just wrote
503
+ // and has not committed — advising --allow-protected there teaches
504
+ // the newcomer to bypass the gate on their first run, when what
505
+ // they need is `git commit`.
506
+ const scopeFiles = (res.phases.find((p) => p.phase === "scope")?.violations || [])
507
+ .map((v) => v.file)
508
+ .filter(Boolean);
509
+ const { partitionTracked } = await import("../src/git.mjs");
510
+ const { tracked, untracked } = partitionTracked(root, scopeFiles);
511
+ const allNewScaffolding =
512
+ untracked.length > 0 &&
513
+ tracked.length === 0 &&
514
+ untracked.every((f) => /^(\.agent\/|AGENTS\.md$|SPEC\.md$|CONSTRAINTS\.md$|DESIGN\.md$)/.test(f));
515
+
516
+ if (allNewScaffolding) {
517
+ console.log(`💡 Remediation Hint (Exit ${res.code} — these are the files init just wrote):`);
518
+ console.log(` • The agent manifest and guardrails are gate-protected on purpose: an agent must`);
519
+ console.log(` not edit the rules it is governed by. Uncommitted, they read as exactly that.`);
520
+ console.log(` • Commit them once and the gate goes green:`);
521
+ console.log(` git add ${untracked.join(" ")} && git commit -m "chore: add agent config"\n`);
522
+ } else {
523
+ console.log(`💡 Remediation Hint (Exit ${res.code} Scope Violation):`);
524
+ console.log(` • To allow protected files in this run, pass: agentctl gate --allow-protected`);
525
+ console.log(` • Or remove protected/denied paths from the diff before dispatching.\n`);
526
+ }
527
+ } else if (failedPhase === "git_resolution") {
528
+ console.log(`💡 Remediation Hint (Exit ${res.code} Base Branch Unresolvable):`);
529
+ console.log(` • The gate compares your work against a base branch, and this one is not reachable.`);
530
+ console.log(` • This repository's branches: git branch -a`);
531
+ console.log(` • Point the gate at the right one: agentctl check --base <branch>`);
532
+ console.log(` • Or record it once in .agent/config.yml as: base_branch: <branch>\n`);
452
533
  } else if (res.code === 5) {
453
534
  console.log(`💡 Remediation Hint (Exit 5 Diff Payload Overflow):`);
454
535
  console.log(` • Total diff exceeds ${config.limits?.diffKb || 75} KB limit.`);
@@ -554,7 +635,11 @@ async function main() {
554
635
 
555
636
  const root = resolveRoot();
556
637
  const minCoverage = values.min ? parseFloat(values.min) : (values["min-coverage"] ? parseFloat(values["min-coverage"]) : 100);
557
- const diffStr = diffText(root, values.base || "main", values.mode || "working-tree");
638
+ // `config.baseBranch` was skipped here while every other gate honoured it,
639
+ // so `agentctl coverage` alone died on "Cannot resolve base reference
640
+ // main" in any repository not on `main`.
641
+ const coverageBase = values.base || config.baseBranch || "main";
642
+ const diffStr = diffText(root, coverageBase, values.mode || "working-tree");
558
643
 
559
644
  const covRes = runV8Coverage(values.cmd, { root });
560
645
  const report = calculateDiffCoverage(covRes.coverageByFile, diffStr, { root, minCoverage });
@@ -562,7 +647,7 @@ async function main() {
562
647
  if (values.json) {
563
648
  console.log(JSON.stringify({ ...report, testPass: covRes.ok }, null, 2));
564
649
  } else {
565
- console.log(`\n📊 V8 Native Diff Coverage Report (Base: ${values.base || "main"})`);
650
+ console.log(`\n📊 V8 Native Diff Coverage Report (Base: ${coverageBase})`);
566
651
  console.log(`------------------------------------------------------------------`);
567
652
  console.log(` Target Min Coverage : ${report.minCoverage}%`);
568
653
  console.log(` Achieved Coverage : ${report.score}%`);
@@ -1161,7 +1246,13 @@ async function main() {
1161
1246
  case "doctor": {
1162
1247
  const { values } = parseArgs({
1163
1248
  args: args.slice(1),
1164
- options: { json: { type: "boolean", short: "j" } },
1249
+ options: {
1250
+ json: { type: "boolean", short: "j" },
1251
+ // `--probe` was advertised in the command registry and declared
1252
+ // nowhere, so `runDoctorChecks` never saw `activeProbe` and the flag
1253
+ // was silently inert.
1254
+ probe: { type: "boolean" },
1255
+ },
1165
1256
  allowPositionals: true,
1166
1257
  strict: false,
1167
1258
  });
@@ -1170,7 +1261,7 @@ async function main() {
1170
1261
  // them: this command printed a hand-written summary, so findings like a
1171
1262
  // git-tracked .env were computed, tested, and never shown to anyone.
1172
1263
  const { runDoctorChecks } = await import("../src/ops/doctor-registry.mjs");
1173
- const report = await runDoctorChecks({ root });
1264
+ const report = await runDoctorChecks({ root, activeProbe: Boolean(values.probe) });
1174
1265
 
1175
1266
  if (values.json) {
1176
1267
  console.log(JSON.stringify(report, null, 2));
@@ -1190,7 +1281,11 @@ async function main() {
1190
1281
  const icon = { pass: "✅", warn: "⚠️ ", fail: "❌", skip: "⏭️ ", unknown: "❔" };
1191
1282
  for (const r of report.results) {
1192
1283
  console.log(` ${icon[r.status] || "•"} ${r.title}`);
1193
- if (r.status !== "pass") {
1284
+ // A passing row normally speaks for itself. Provider readiness does
1285
+ // not: green there means "a binary was found", and read as "the
1286
+ // provider works" it sends someone into a dispatch that cannot succeed.
1287
+ // Such a row asks to be spelled out even when it passes.
1288
+ if (r.status !== "pass" || r.alwaysShowSummary) {
1194
1289
  console.log(` ${r.summary}`);
1195
1290
  for (const fix of r.remediation || []) console.log(` → ${fix.summary}`);
1196
1291
  }
@@ -1203,7 +1298,32 @@ async function main() {
1203
1298
  break;
1204
1299
  }
1205
1300
 
1301
+ case "provider":
1206
1302
  case "providers": {
1303
+ // `agentctl providers` used to tell the operator to run
1304
+ // `agentctl init --provider <name>` to switch — which restarts the whole
1305
+ // onboarding wizard, plan question included, to change one line.
1306
+ if (args[1] === "set") {
1307
+ const target = args[2];
1308
+ if (!target) {
1309
+ console.error(`❌ Usage: agentctl provider set <name> (see: agentctl providers)`);
1310
+ process.exit(2);
1311
+ }
1312
+ const { setConfigProvider } = await import("../src/config-edit.mjs");
1313
+ const res = setConfigProvider(root, target);
1314
+ if (!res.ok) {
1315
+ console.error(`❌ ${res.error}`);
1316
+ process.exit(1);
1317
+ }
1318
+ const { probeProvider } = await import("../src/provider-readiness.mjs");
1319
+ const probe = probeProvider(target);
1320
+ console.log(`✅ Provider set to '${target}' in ${res.file}`);
1321
+ console.log(` ${probe.ready ? "Ready" : `Not ready — ${probe.remedy}`}`);
1322
+ if (!probe.known) console.log(` Note: '${target}' is not a built-in preset, so its readiness cannot be checked.`);
1323
+ console.log(` The manifest is gate-protected — commit it so the next check does not read it as an agent edit.`);
1324
+ process.exit(0);
1325
+ }
1326
+
1207
1327
  const { values } = parseArgs({
1208
1328
  args: args.slice(1),
1209
1329
  options: { json: { type: "boolean", short: "j" } },
@@ -1231,7 +1351,9 @@ async function main() {
1231
1351
  console.log(` ${activeProbe.reason}`);
1232
1352
  }
1233
1353
  console.log(`\n Active: ${active} (provider: in ${config._file || ".agent/config.yml"})`);
1234
- console.log(` Switch: agentctl init --provider <name> · every gate below works with no provider at all\n`);
1354
+ console.log(` Switch: agentctl provider set <name> · every gate below works with no provider at all`);
1355
+ console.log(` Note: for the CLI providers, "ready" means the binary is on PATH — it does not prove`);
1356
+ console.log(` the CLI is signed in. A dispatch is the first thing that can tell you that.\n`);
1235
1357
  process.exit(activeProbe.ready ? 0 : 1);
1236
1358
  break;
1237
1359
  }
@@ -1254,13 +1376,14 @@ async function main() {
1254
1376
  console.error(`❌ Unknown profile '${values.set}'. Choose one of: ${PROFILE_NAMES.join(", ")}`);
1255
1377
  process.exit(2);
1256
1378
  }
1257
- const { setVerificationProfile } = await import("../src/profiles-io.mjs");
1379
+ const { setVerificationProfile } = await import("../src/config-edit.mjs");
1258
1380
  const res = setVerificationProfile(root, name);
1259
1381
  if (!res.ok) {
1260
1382
  console.error(`❌ ${res.error}`);
1261
1383
  process.exit(1);
1262
1384
  }
1263
1385
  console.log(`✅ Verification profile set to '${name}' in ${res.file}`);
1386
+ console.log(` The manifest is gate-protected — commit it so the next check does not read it as an agent edit.`);
1264
1387
  process.exit(0);
1265
1388
  }
1266
1389
 
@@ -1512,8 +1635,15 @@ async function main() {
1512
1635
  allowPositionals: true,
1513
1636
  });
1514
1637
 
1515
- const isNonInteractive = Boolean(values["non-interactive"] || values["no-interactive"] || values.yes);
1516
- const isInteractive = values.interactive === true ? true : (isNonInteractive ? false : undefined);
1638
+ const { resolveWizardInteractivity } = await import("../src/ops/cli-intent.mjs");
1639
+ const isInteractive = resolveWizardInteractivity({
1640
+ interactive: values.interactive,
1641
+ nonInteractive: values["non-interactive"] || values["no-interactive"],
1642
+ yes: values.yes,
1643
+ // A title and a prompt together state the whole task; there is
1644
+ // nothing left for the wizard to ask.
1645
+ fullySpecified: Boolean(values.title && resolvePromptInput(values, positionals)),
1646
+ });
1517
1647
 
1518
1648
  const { runTaskCreateWizard } = await import("../src/wizard-task.mjs");
1519
1649
  const res = await runTaskCreateWizard(root, {
@@ -1528,12 +1658,17 @@ async function main() {
1528
1658
  requirePlanApproval: values["require-plan-approval"],
1529
1659
  repoless: values.repoless,
1530
1660
  interactive: isInteractive,
1661
+ dryRun: values["dry-run"],
1531
1662
  });
1532
1663
 
1533
1664
  if (values.json) {
1534
1665
  console.log(JSON.stringify(res, null, 2));
1535
1666
  } else {
1536
- console.log(`✅ Task synthesized & queued at ${res.taskFile}`);
1667
+ console.log(
1668
+ res.dryRun
1669
+ ? `🧪 Dry run — envelope synthesized and validated, nothing written (would be ${res.taskFile})`
1670
+ : `✅ Task synthesized & queued at ${res.taskFile}`
1671
+ );
1537
1672
  console.log(` Task ID : ${res.plan.taskId}`);
1538
1673
  console.log(` Title : ${res.plan.title}`);
1539
1674
  if (res.plan.role) console.log(` Role : ${res.plan.role}`);
@@ -2349,26 +2484,32 @@ async function main() {
2349
2484
  const subAction = args[1];
2350
2485
  if (subAction === "init") {
2351
2486
  const { scaffoldIdeConfig } = await import("../src/ops/ide-scaffold.mjs");
2352
- const { values } = parseArgs({
2487
+ const { values, positionals } = parseArgs({
2353
2488
  args: args.slice(2),
2354
2489
  options: {
2355
- target: { type: "string", short: "t", default: "all" },
2490
+ // No `default: "all"` here. parseArgs sets a default eagerly, so
2491
+ // `values.target` was always truthy and the `|| args[2]` fallback
2492
+ // below could never be reached — `agentctl mcp init cursor`, the
2493
+ // spelling --help advertises, silently scaffolded Cursor, VS Code
2494
+ // and Claude Desktop alike. The default belongs at the end of the
2495
+ // resolution chain, not at the start of it.
2496
+ target: { type: "string", short: "t" },
2356
2497
  json: { type: "boolean", short: "j" },
2357
2498
  "dry-run": { type: "boolean", short: "d" },
2358
2499
  },
2359
2500
  allowPositionals: true,
2360
2501
  });
2361
2502
 
2362
- const target = values.target || args[2] || "all";
2503
+ const target = values.target || positionals[0] || "all";
2363
2504
  try {
2364
- const res = scaffoldIdeConfig(target, { root });
2505
+ const res = scaffoldIdeConfig(target, { root, dryRun: values["dry-run"] });
2365
2506
  if (values.json) {
2366
2507
  console.log(JSON.stringify(res, null, 2));
2367
2508
  } else {
2368
- console.log(`\n🔌 IDE MCP Config Scaffolded Successfully!`);
2509
+ console.log(res.dryRun ? `\n🧪 IDE MCP Config — dry run, nothing written` : `\n🔌 IDE MCP Config Scaffolded Successfully!`);
2369
2510
  console.log(` Target : ${res.target}`);
2370
2511
  for (const item of res.results) {
2371
- console.log(` - ${item.target.toUpperCase()} : ${item.file}`);
2512
+ console.log(` - ${item.target.toUpperCase()} : ${item.file}${res.dryRun ? " (would write)" : ""}`);
2372
2513
  }
2373
2514
  console.log("");
2374
2515
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.53.0",
3
+ "version": "0.54.1",
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",
@@ -0,0 +1,138 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PROFILE_NAMES } from "./profiles.mjs";
4
+ import { PROVIDER_DESCRIPTORS } from "./provider-readiness.mjs";
5
+
6
+ /**
7
+ * In-place edits to the repository's agent manifest.
8
+ *
9
+ * Surgical text edits rather than parse-and-reserialise: the kit's YAML reader
10
+ * is a subset parser, so round-tripping the file through it would drop every
11
+ * comment the wizard wrote and any key the subset does not model. The manifest
12
+ * is a file a human maintains; a tool that rewrites it must leave the rest of
13
+ * it alone.
14
+ */
15
+
16
+ /**
17
+ * The manifest this repository uses, or null when it has none.
18
+ * @param {string} root
19
+ * @returns {string|null}
20
+ */
21
+ function findManifest(root) {
22
+ return [join(root, ".agent", "config.yml"), join(root, ".agent", "jules.yml")].find((f) => existsSync(f)) || null;
23
+ }
24
+
25
+ /**
26
+ * Rewrite the manifest from a line transform, atomically.
27
+ * @param {string} file
28
+ * @param {(lines: string[]) => string[]} transform
29
+ * @returns {{ ok: boolean, file?: string, error?: string }}
30
+ */
31
+ function editManifest(file, transform) {
32
+ let text;
33
+ try {
34
+ text = readFileSync(file, "utf-8");
35
+ } catch (err) {
36
+ return { ok: false, error: `Could not read ${file}: ${err.message}` };
37
+ }
38
+
39
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
40
+ const lines = transform(text.split(/\r?\n/));
41
+
42
+ const tmp = `${file}.tmp-${process.pid}`;
43
+ try {
44
+ writeFileSync(tmp, lines.join(eol), "utf-8");
45
+ renameSync(tmp, file);
46
+ } catch (err) {
47
+ return { ok: false, error: `Could not write ${file}: ${err.message}` };
48
+ }
49
+ return { ok: true, file };
50
+ }
51
+
52
+ /**
53
+ * Set the top-level `provider:` key.
54
+ *
55
+ * `agentctl providers` used to point at `agentctl init --provider <name>` to
56
+ * change providers, which restarts the whole onboarding wizard — plan question
57
+ * and all — to edit one line. This is that one line.
58
+ *
59
+ * @param {string} root
60
+ * @param {string} provider
61
+ * @returns {{ ok: boolean, file?: string, error?: string }}
62
+ */
63
+ export function setConfigProvider(root, provider) {
64
+ const name = String(provider || "").trim();
65
+ if (!name) {
66
+ return { ok: false, error: `Provider name required. Known presets: ${Object.keys(PROVIDER_DESCRIPTORS).join(", ")}` };
67
+ }
68
+ if (!/^[\w.-]+$/.test(name)) {
69
+ return { ok: false, error: `Invalid provider name '${provider}'.` };
70
+ }
71
+
72
+ const file = findManifest(root);
73
+ if (!file) return { ok: false, error: "No .agent/config.yml found. Run `agentctl init` first." };
74
+
75
+ return editManifest(file, (lines) => {
76
+ const idx = lines.findIndex((l) => /^provider:\s*/.test(l));
77
+ if (idx >= 0) {
78
+ lines[idx] = `provider: ${name}`;
79
+ return lines;
80
+ }
81
+ // Keep it near the top where the wizard writes it, after `version:` if
82
+ // present, so a hand-read manifest still leads with what it is.
83
+ const versionIdx = lines.findIndex((l) => /^version:\s*/.test(l));
84
+ lines.splice(versionIdx >= 0 ? versionIdx + 1 : 0, 0, `provider: ${name}`);
85
+ return lines;
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Set `verify.profile` in the repository's manifest, in place.
91
+ *
92
+ * @param {string} root
93
+ * @param {string} profile - one of {@link PROFILE_NAMES}
94
+ * @returns {{ ok: boolean, file?: string, error?: string }}
95
+ */
96
+ export function setVerificationProfile(root, profile) {
97
+ const name = String(profile || "").toLowerCase();
98
+ if (!PROFILE_NAMES.includes(name)) {
99
+ return { ok: false, error: `Unknown profile '${profile}'. Choose one of: ${PROFILE_NAMES.join(", ")}` };
100
+ }
101
+
102
+ const file = findManifest(root);
103
+ if (!file) {
104
+ return { ok: false, error: "No .agent/config.yml found. Run `agentctl init` first." };
105
+ }
106
+
107
+ return editManifest(file, (lines) => {
108
+
109
+ // Find the `verify:` mapping and the `profile:` key nested directly under it.
110
+ let verifyIdx = -1;
111
+ let profileIdx = -1;
112
+ for (let i = 0; i < lines.length; i++) {
113
+ if (/^verify:\s*$/.test(lines[i])) {
114
+ verifyIdx = i;
115
+ for (let j = i + 1; j < lines.length; j++) {
116
+ // A non-indented, non-blank line ends the block.
117
+ if (lines[j].trim() !== "" && !/^\s/.test(lines[j])) break;
118
+ if (/^\s+profile:\s*/.test(lines[j])) {
119
+ profileIdx = j;
120
+ break;
121
+ }
122
+ }
123
+ break;
124
+ }
125
+ }
126
+
127
+ if (profileIdx >= 0) {
128
+ const indent = lines[profileIdx].match(/^(\s*)/)[1];
129
+ lines[profileIdx] = `${indent}profile: ${name}`;
130
+ } else if (verifyIdx >= 0) {
131
+ lines.splice(verifyIdx + 1, 0, ` profile: ${name}`);
132
+ } else {
133
+ if (lines.length && lines[lines.length - 1] !== "") lines.push("");
134
+ lines.push("verify:", ` profile: ${name}`, "");
135
+ }
136
+ return lines;
137
+ });
138
+ }
package/src/config.mjs CHANGED
@@ -522,6 +522,11 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
522
522
  teardown: rawTeardown ?? autoVerify.teardown ?? "",
523
523
  build: rawBuild ?? autoVerify.build,
524
524
  policy: parsed.verify?.policy ?? autoVerify.policy,
525
+ // Whether the gate may approve a change that ran no verification at all.
526
+ // True by default: "nothing to run" is not a pass, and a security tool that
527
+ // says APPROVED after checking nothing is worse than no tool. A repository
528
+ // that deliberately uses only the scope and secret phases sets this false.
529
+ required: parsed.verify?.required !== false,
525
530
  };
526
531
 
527
532
  // A hand-written `verify.stages:` is the operator being explicit and always
package/src/engine.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadConfig, parseYaml, normalizeScope } from "./config.mjs";
2
- import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
3
- import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
2
+ import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
3
+ import { changedFiles, diffBytes, diffText, binaryDiffEntries, showFromOrigin, runCmd } from "./git.mjs";
4
4
  import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
5
5
  import { resolveRoutedProvider } from "./router.mjs";
6
6
  import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
@@ -198,6 +198,9 @@ export async function gate(opts = {}) {
198
198
  build: parsed.verify?.build || parsed.build_cmd || config.verify.build,
199
199
  stages: parsed.verify?.stages || config.verify.stages,
200
200
  policy: parsed.verify?.policy || config.verify.policy,
201
+ // Read from the base commit like every other trusted field: an
202
+ // uncommitted `required: false` must not be able to switch the gate off.
203
+ required: parsed.verify?.required !== undefined ? parsed.verify.required !== false : config.verify.required !== false,
201
204
  timeoutMs: parsed.verify?.timeoutMs || parsed.verify?.timeout_ms || config.verify.timeoutMs,
202
205
  };
203
206
  }
@@ -242,13 +245,25 @@ export async function gate(opts = {}) {
242
245
 
243
246
  // Phase 3: Diff Secret Scanner & Security Checks
244
247
  const secretResult = scanDiff(diffStr, { root });
245
- phases.push({ phase: "secrets", ok: secretResult.ok, findings: secretResult.findings });
246
- appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretResult.ok });
248
+ // A binary file reaches the scanner as one summary line, so its contents were
249
+ // never looked at — a NUL byte in front of a token was enough to hide it.
250
+ // Inspect those files directly and fold the verdict in.
251
+ let binaryFindings = [];
252
+ try {
253
+ binaryFindings = scanBinaryPayloads(binaryDiffEntries(root, base, mode), root);
254
+ } catch (_) {
255
+ // Never let the extra pass break a gate that would otherwise have run; the
256
+ // text scan above has already been applied.
257
+ }
258
+ const allSecretFindings = [...(secretResult.findings || []), ...binaryFindings];
259
+ const secretsOk = secretResult.ok && binaryFindings.length === 0;
260
+ phases.push({ phase: "secrets", ok: secretsOk, findings: allSecretFindings });
261
+ appendTelemetry(root, "gate_phase", { phase: "secrets", ok: secretsOk });
247
262
  if (progressBus && progressToken) {
248
263
  progressBus.reportProgress(progressToken, 75, 100, "Phase 3/4: Diff Secret Scanner check complete");
249
264
  }
250
265
 
251
- if (!secretResult.ok) {
266
+ if (!secretsOk) {
252
267
  appendTelemetry(root, "gate_finished", { ok: false, code: 6 });
253
268
  return { ok: false, code: 6, phases };
254
269
  }
@@ -415,7 +430,23 @@ export async function gate(opts = {}) {
415
430
  }
416
431
  }
417
432
 
418
- const verifyOk = !failingCmd && !testTampered;
433
+ // A gate that ran no verification at all must not report APPROVED.
434
+ //
435
+ // `testResult` starts optimistic and the stage loop skips a stage with no
436
+ // command, so a repository with no test oracle produced zero execution
437
+ // records and a clean bill of health — syntactically broken code included.
438
+ // That is the product's central claim inverted: the whole point is that a
439
+ // change is verified before it is approved, and "nothing to run" is not
440
+ // verification. Repositories that deliberately use only the scope and secret
441
+ // phases opt out with `verify.required: false`.
442
+ // Assertions are guards, not oracles: `assert:test-integrity` proves the diff
443
+ // did not weaken a test, which says nothing about whether the code works. The
444
+ // question is whether any command was executed against the change at all.
445
+ const verificationRequired = trustedVerify.required !== false;
446
+ const ranNoVerification = !executionRecords.some((r) => r && r.kind !== "assert");
447
+ const missingOracle = verificationRequired && ranNoVerification;
448
+
449
+ const verifyOk = !failingCmd && !testTampered && !missingOracle;
419
450
 
420
451
  // What actually broke. Without this the verify phase reported `ok: false` and
421
452
  // nothing else — not the stage, not the exit code, not a line of output — so
@@ -448,7 +479,21 @@ export async function gate(opts = {}) {
448
479
  stderr: `Test files changed during the run (${preTestHash.slice(0, 12)} → ${postTestHash.slice(0, 12)}). evidence.strictTestLock treats a passing suite that the diff also rewrote as unproven.`,
449
480
  diagnostics: [],
450
481
  }
451
- : null;
482
+ : missingOracle
483
+ ? {
484
+ stageId: "oracle",
485
+ command: null,
486
+ exitCode: null,
487
+ stdout: "",
488
+ stderr:
489
+ "No verification command ran, so nothing about this change was checked. " +
490
+ "Set verify.test in .agent/config.yml (or run `agentctl bootstrap` to generate one). " +
491
+ "If this repository intentionally uses only the scope and secret phases, set verify.required: false.",
492
+ diagnostics: [
493
+ "The gate approves a change because verification passed. Zero stages executed is not a pass.",
494
+ ],
495
+ }
496
+ : null;
452
497
 
453
498
  // Generate & persist Evidence Manifest
454
499
  const evidenceManifest = generateEvidenceManifest(root, {