jules-orchestrator-kit 0.53.0 → 0.54.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
@@ -200,7 +200,7 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
200
200
  * **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
201
  * **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
202
  * **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**.
203
+ * **Verified Test Suite:** Tested with **827 unit tests across 107 suites passing in < 14.0s**.
204
204
 
205
205
  <br/>
206
206
 
@@ -226,10 +226,11 @@ To maximize PR merge rates, dispatch tasks according to deterministic boundaries
226
226
  | `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
227
  | `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
228
  | `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) |
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. 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) |
230
+ | `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
231
  | `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
232
  | `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) |
233
+ | `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
234
  | `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
235
  | `swarm` | `agentctl swarm [--json]` | Runs parallel multi-agent swarm across worker slots with PID liveness detection. | `0` (Complete) |
235
236
  | `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.`);
@@ -446,9 +489,38 @@ async function main() {
446
489
  console.log(` • The stage above exited non-zero. Reproduce it locally, then re-run the gate.`);
447
490
  console.log(` • To let agentctl attempt the repair loop itself, pass: agentctl gate --fix\n`);
448
491
  } 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`);
492
+ // The same violation has two very different causes. Right after
493
+ // `init`, every offending path is a file the tool itself just wrote
494
+ // and has not committed — advising --allow-protected there teaches
495
+ // the newcomer to bypass the gate on their first run, when what
496
+ // they need is `git commit`.
497
+ const scopeFiles = (res.phases.find((p) => p.phase === "scope")?.violations || [])
498
+ .map((v) => v.file)
499
+ .filter(Boolean);
500
+ const { partitionTracked } = await import("../src/git.mjs");
501
+ const { tracked, untracked } = partitionTracked(root, scopeFiles);
502
+ const allNewScaffolding =
503
+ untracked.length > 0 &&
504
+ tracked.length === 0 &&
505
+ untracked.every((f) => /^(\.agent\/|AGENTS\.md$|SPEC\.md$|CONSTRAINTS\.md$|DESIGN\.md$)/.test(f));
506
+
507
+ if (allNewScaffolding) {
508
+ console.log(`💡 Remediation Hint (Exit ${res.code} — these are the files init just wrote):`);
509
+ console.log(` • The agent manifest and guardrails are gate-protected on purpose: an agent must`);
510
+ console.log(` not edit the rules it is governed by. Uncommitted, they read as exactly that.`);
511
+ console.log(` • Commit them once and the gate goes green:`);
512
+ console.log(` git add ${untracked.join(" ")} && git commit -m "chore: add agent config"\n`);
513
+ } else {
514
+ console.log(`💡 Remediation Hint (Exit ${res.code} Scope Violation):`);
515
+ console.log(` • To allow protected files in this run, pass: agentctl gate --allow-protected`);
516
+ console.log(` • Or remove protected/denied paths from the diff before dispatching.\n`);
517
+ }
518
+ } else if (failedPhase === "git_resolution") {
519
+ console.log(`💡 Remediation Hint (Exit ${res.code} Base Branch Unresolvable):`);
520
+ console.log(` • The gate compares your work against a base branch, and this one is not reachable.`);
521
+ console.log(` • This repository's branches: git branch -a`);
522
+ console.log(` • Point the gate at the right one: agentctl check --base <branch>`);
523
+ console.log(` • Or record it once in .agent/config.yml as: base_branch: <branch>\n`);
452
524
  } else if (res.code === 5) {
453
525
  console.log(`💡 Remediation Hint (Exit 5 Diff Payload Overflow):`);
454
526
  console.log(` • Total diff exceeds ${config.limits?.diffKb || 75} KB limit.`);
@@ -554,7 +626,11 @@ async function main() {
554
626
 
555
627
  const root = resolveRoot();
556
628
  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");
629
+ // `config.baseBranch` was skipped here while every other gate honoured it,
630
+ // so `agentctl coverage` alone died on "Cannot resolve base reference
631
+ // main" in any repository not on `main`.
632
+ const coverageBase = values.base || config.baseBranch || "main";
633
+ const diffStr = diffText(root, coverageBase, values.mode || "working-tree");
558
634
 
559
635
  const covRes = runV8Coverage(values.cmd, { root });
560
636
  const report = calculateDiffCoverage(covRes.coverageByFile, diffStr, { root, minCoverage });
@@ -562,7 +638,7 @@ async function main() {
562
638
  if (values.json) {
563
639
  console.log(JSON.stringify({ ...report, testPass: covRes.ok }, null, 2));
564
640
  } else {
565
- console.log(`\n📊 V8 Native Diff Coverage Report (Base: ${values.base || "main"})`);
641
+ console.log(`\n📊 V8 Native Diff Coverage Report (Base: ${coverageBase})`);
566
642
  console.log(`------------------------------------------------------------------`);
567
643
  console.log(` Target Min Coverage : ${report.minCoverage}%`);
568
644
  console.log(` Achieved Coverage : ${report.score}%`);
@@ -1161,7 +1237,13 @@ async function main() {
1161
1237
  case "doctor": {
1162
1238
  const { values } = parseArgs({
1163
1239
  args: args.slice(1),
1164
- options: { json: { type: "boolean", short: "j" } },
1240
+ options: {
1241
+ json: { type: "boolean", short: "j" },
1242
+ // `--probe` was advertised in the command registry and declared
1243
+ // nowhere, so `runDoctorChecks` never saw `activeProbe` and the flag
1244
+ // was silently inert.
1245
+ probe: { type: "boolean" },
1246
+ },
1165
1247
  allowPositionals: true,
1166
1248
  strict: false,
1167
1249
  });
@@ -1170,7 +1252,7 @@ async function main() {
1170
1252
  // them: this command printed a hand-written summary, so findings like a
1171
1253
  // git-tracked .env were computed, tested, and never shown to anyone.
1172
1254
  const { runDoctorChecks } = await import("../src/ops/doctor-registry.mjs");
1173
- const report = await runDoctorChecks({ root });
1255
+ const report = await runDoctorChecks({ root, activeProbe: Boolean(values.probe) });
1174
1256
 
1175
1257
  if (values.json) {
1176
1258
  console.log(JSON.stringify(report, null, 2));
@@ -1190,7 +1272,11 @@ async function main() {
1190
1272
  const icon = { pass: "✅", warn: "⚠️ ", fail: "❌", skip: "⏭️ ", unknown: "❔" };
1191
1273
  for (const r of report.results) {
1192
1274
  console.log(` ${icon[r.status] || "•"} ${r.title}`);
1193
- if (r.status !== "pass") {
1275
+ // A passing row normally speaks for itself. Provider readiness does
1276
+ // not: green there means "a binary was found", and read as "the
1277
+ // provider works" it sends someone into a dispatch that cannot succeed.
1278
+ // Such a row asks to be spelled out even when it passes.
1279
+ if (r.status !== "pass" || r.alwaysShowSummary) {
1194
1280
  console.log(` ${r.summary}`);
1195
1281
  for (const fix of r.remediation || []) console.log(` → ${fix.summary}`);
1196
1282
  }
@@ -1203,7 +1289,32 @@ async function main() {
1203
1289
  break;
1204
1290
  }
1205
1291
 
1292
+ case "provider":
1206
1293
  case "providers": {
1294
+ // `agentctl providers` used to tell the operator to run
1295
+ // `agentctl init --provider <name>` to switch — which restarts the whole
1296
+ // onboarding wizard, plan question included, to change one line.
1297
+ if (args[1] === "set") {
1298
+ const target = args[2];
1299
+ if (!target) {
1300
+ console.error(`❌ Usage: agentctl provider set <name> (see: agentctl providers)`);
1301
+ process.exit(2);
1302
+ }
1303
+ const { setConfigProvider } = await import("../src/config-edit.mjs");
1304
+ const res = setConfigProvider(root, target);
1305
+ if (!res.ok) {
1306
+ console.error(`❌ ${res.error}`);
1307
+ process.exit(1);
1308
+ }
1309
+ const { probeProvider } = await import("../src/provider-readiness.mjs");
1310
+ const probe = probeProvider(target);
1311
+ console.log(`✅ Provider set to '${target}' in ${res.file}`);
1312
+ console.log(` ${probe.ready ? "Ready" : `Not ready — ${probe.remedy}`}`);
1313
+ if (!probe.known) console.log(` Note: '${target}' is not a built-in preset, so its readiness cannot be checked.`);
1314
+ console.log(` The manifest is gate-protected — commit it so the next check does not read it as an agent edit.`);
1315
+ process.exit(0);
1316
+ }
1317
+
1207
1318
  const { values } = parseArgs({
1208
1319
  args: args.slice(1),
1209
1320
  options: { json: { type: "boolean", short: "j" } },
@@ -1231,7 +1342,9 @@ async function main() {
1231
1342
  console.log(` ${activeProbe.reason}`);
1232
1343
  }
1233
1344
  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`);
1345
+ console.log(` Switch: agentctl provider set <name> · every gate below works with no provider at all`);
1346
+ console.log(` Note: for the CLI providers, "ready" means the binary is on PATH — it does not prove`);
1347
+ console.log(` the CLI is signed in. A dispatch is the first thing that can tell you that.\n`);
1235
1348
  process.exit(activeProbe.ready ? 0 : 1);
1236
1349
  break;
1237
1350
  }
@@ -1254,13 +1367,14 @@ async function main() {
1254
1367
  console.error(`❌ Unknown profile '${values.set}'. Choose one of: ${PROFILE_NAMES.join(", ")}`);
1255
1368
  process.exit(2);
1256
1369
  }
1257
- const { setVerificationProfile } = await import("../src/profiles-io.mjs");
1370
+ const { setVerificationProfile } = await import("../src/config-edit.mjs");
1258
1371
  const res = setVerificationProfile(root, name);
1259
1372
  if (!res.ok) {
1260
1373
  console.error(`❌ ${res.error}`);
1261
1374
  process.exit(1);
1262
1375
  }
1263
1376
  console.log(`✅ Verification profile set to '${name}' in ${res.file}`);
1377
+ console.log(` The manifest is gate-protected — commit it so the next check does not read it as an agent edit.`);
1264
1378
  process.exit(0);
1265
1379
  }
1266
1380
 
@@ -1512,8 +1626,15 @@ async function main() {
1512
1626
  allowPositionals: true,
1513
1627
  });
1514
1628
 
1515
- const isNonInteractive = Boolean(values["non-interactive"] || values["no-interactive"] || values.yes);
1516
- const isInteractive = values.interactive === true ? true : (isNonInteractive ? false : undefined);
1629
+ const { resolveWizardInteractivity } = await import("../src/ops/cli-intent.mjs");
1630
+ const isInteractive = resolveWizardInteractivity({
1631
+ interactive: values.interactive,
1632
+ nonInteractive: values["non-interactive"] || values["no-interactive"],
1633
+ yes: values.yes,
1634
+ // A title and a prompt together state the whole task; there is
1635
+ // nothing left for the wizard to ask.
1636
+ fullySpecified: Boolean(values.title && resolvePromptInput(values, positionals)),
1637
+ });
1517
1638
 
1518
1639
  const { runTaskCreateWizard } = await import("../src/wizard-task.mjs");
1519
1640
  const res = await runTaskCreateWizard(root, {
@@ -1528,12 +1649,17 @@ async function main() {
1528
1649
  requirePlanApproval: values["require-plan-approval"],
1529
1650
  repoless: values.repoless,
1530
1651
  interactive: isInteractive,
1652
+ dryRun: values["dry-run"],
1531
1653
  });
1532
1654
 
1533
1655
  if (values.json) {
1534
1656
  console.log(JSON.stringify(res, null, 2));
1535
1657
  } else {
1536
- console.log(`✅ Task synthesized & queued at ${res.taskFile}`);
1658
+ console.log(
1659
+ res.dryRun
1660
+ ? `🧪 Dry run — envelope synthesized and validated, nothing written (would be ${res.taskFile})`
1661
+ : `✅ Task synthesized & queued at ${res.taskFile}`
1662
+ );
1537
1663
  console.log(` Task ID : ${res.plan.taskId}`);
1538
1664
  console.log(` Title : ${res.plan.title}`);
1539
1665
  if (res.plan.role) console.log(` Role : ${res.plan.role}`);
@@ -2349,26 +2475,32 @@ async function main() {
2349
2475
  const subAction = args[1];
2350
2476
  if (subAction === "init") {
2351
2477
  const { scaffoldIdeConfig } = await import("../src/ops/ide-scaffold.mjs");
2352
- const { values } = parseArgs({
2478
+ const { values, positionals } = parseArgs({
2353
2479
  args: args.slice(2),
2354
2480
  options: {
2355
- target: { type: "string", short: "t", default: "all" },
2481
+ // No `default: "all"` here. parseArgs sets a default eagerly, so
2482
+ // `values.target` was always truthy and the `|| args[2]` fallback
2483
+ // below could never be reached — `agentctl mcp init cursor`, the
2484
+ // spelling --help advertises, silently scaffolded Cursor, VS Code
2485
+ // and Claude Desktop alike. The default belongs at the end of the
2486
+ // resolution chain, not at the start of it.
2487
+ target: { type: "string", short: "t" },
2356
2488
  json: { type: "boolean", short: "j" },
2357
2489
  "dry-run": { type: "boolean", short: "d" },
2358
2490
  },
2359
2491
  allowPositionals: true,
2360
2492
  });
2361
2493
 
2362
- const target = values.target || args[2] || "all";
2494
+ const target = values.target || positionals[0] || "all";
2363
2495
  try {
2364
- const res = scaffoldIdeConfig(target, { root });
2496
+ const res = scaffoldIdeConfig(target, { root, dryRun: values["dry-run"] });
2365
2497
  if (values.json) {
2366
2498
  console.log(JSON.stringify(res, null, 2));
2367
2499
  } else {
2368
- console.log(`\n🔌 IDE MCP Config Scaffolded Successfully!`);
2500
+ console.log(res.dryRun ? `\n🧪 IDE MCP Config — dry run, nothing written` : `\n🔌 IDE MCP Config Scaffolded Successfully!`);
2369
2501
  console.log(` Target : ${res.target}`);
2370
2502
  for (const item of res.results) {
2371
- console.log(` - ${item.target.toUpperCase()} : ${item.file}`);
2503
+ console.log(` - ${item.target.toUpperCase()} : ${item.file}${res.dryRun ? " (would write)" : ""}`);
2372
2504
  }
2373
2505
  console.log("");
2374
2506
  }
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.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",
@@ -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/git.mjs CHANGED
@@ -454,6 +454,85 @@ export function parseGitHubRepo(remoteUrl) {
454
454
  * @param {string} [root=process.cwd()]
455
455
  * @returns {string}
456
456
  */
457
+ /**
458
+ * Work out which branch this repository actually treats as its base.
459
+ *
460
+ * `main` was hardcoded as the scaffolded default, which is wrong the moment
461
+ * `git init` picks `master` (still the default on many installed gits) or the
462
+ * project standardised on `develop`. The failure was not graceful: the first
463
+ * `agentctl check` could not resolve the base ref and rejected with exit 1.
464
+ *
465
+ * Resolution order, strongest evidence first:
466
+ * 1. `origin/HEAD` — the remote states its own default branch.
467
+ * 2. A local `main`, then `master` — the conventional names, preferred over
468
+ * the checked-out branch so running `init` from a feature branch does not
469
+ * record that feature branch as the base for everything after.
470
+ * 3. The current branch — covers a fresh `git init` with no commits, where
471
+ * HEAD points at an unborn branch that `--show-current` still names.
472
+ * 4. `main`, when there is no git information at all to go on.
473
+ *
474
+ * @param {string} [root=process.cwd()]
475
+ * @returns {string}
476
+ */
477
+ /**
478
+ * Split a list of repo-relative paths into the ones git already tracks and the
479
+ * ones it does not.
480
+ *
481
+ * Used to tell an onboarding mistake apart from an agent overstepping. Both
482
+ * arrive as the same exit-3 scope violation on the same protected paths, but
483
+ * "the files `init` just wrote are not committed yet" and "something edited the
484
+ * rules it is governed by" need opposite advice, and the operator who has just
485
+ * met the tool gets the wrong half if the two are not distinguished.
486
+ *
487
+ * @param {string} root
488
+ * @param {string[]} files - repo-relative paths
489
+ * @returns {{ tracked: string[], untracked: string[] }}
490
+ */
491
+ export function partitionTracked(root = process.cwd(), files = []) {
492
+ const list = files.filter((f) => typeof f === "string" && f && !f.startsWith("-"));
493
+ if (list.length === 0) return { tracked: [], untracked: [] };
494
+ let out = "";
495
+ try {
496
+ out = git(["ls-files", "--", ...list], { cwd: root, ignoreError: true }) || "";
497
+ } catch (_) {
498
+ // Without an answer, claim nothing is tracked-or-not: callers fall back to
499
+ // the generic advice rather than to a guess.
500
+ return { tracked: [], untracked: [] };
501
+ }
502
+ const tracked = new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
503
+ return {
504
+ tracked: list.filter((f) => tracked.has(f)),
505
+ untracked: list.filter((f) => !tracked.has(f)),
506
+ };
507
+ }
508
+
509
+ export function detectDefaultBranch(root = process.cwd()) {
510
+ // `git()` returns trimmed stdout, and "" for a non-zero exit under
511
+ // ignoreError — there is no status field to read.
512
+ const ask = (args) => {
513
+ try {
514
+ return git(args, { cwd: root, ignoreError: true }) || "";
515
+ } catch (_) {
516
+ return "";
517
+ }
518
+ };
519
+
520
+ const remoteHead = ask(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
521
+ if (remoteHead.startsWith("origin/")) {
522
+ const name = remoteHead.slice("origin/".length).trim();
523
+ if (name) return name;
524
+ }
525
+
526
+ for (const candidate of ["main", "master"]) {
527
+ if (ask(["rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`])) return candidate;
528
+ }
529
+
530
+ const current = ask(["branch", "--show-current"]);
531
+ if (current) return current;
532
+
533
+ return "main";
534
+ }
535
+
457
536
  export function resolveGitRemoteOrigin(root = process.cwd()) {
458
537
  try {
459
538
  const raw = git(["config", "--get", "remote.origin.url"], { cwd: root, ignoreError: true });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether a command invocation is asking to be walked through a wizard, or has
3
+ * already said everything the wizard would ask.
4
+ *
5
+ * `agentctl task create --title X --prompt Y` states the whole task and then
6
+ * opened the interactive wizard anyway, which re-asked for the title and the
7
+ * prompt in a terminal and hung a CI job that had no terminal to answer with.
8
+ * Neither `--yes` nor `--non-interactive` was passed, so the CLI concluded
9
+ * "interactive" from the absence of a flag rather than from the presence of the
10
+ * answers.
11
+ *
12
+ * Kept as a pure function, out of the CLI script, because that script executes
13
+ * on import and so cannot be exercised from a test.
14
+ *
15
+ * @param {object} input
16
+ * @param {boolean} [input.interactive] - `--interactive` was passed explicitly
17
+ * @param {boolean} [input.nonInteractive] - `--non-interactive` / `--no-interactive`
18
+ * @param {boolean} [input.yes] - `--yes`
19
+ * @param {boolean} [input.fullySpecified] - every field the wizard would ask for is present
20
+ * @returns {boolean|undefined} true/false to force a mode, undefined to let the
21
+ * callee decide from whether stdin is a TTY
22
+ */
23
+ export function resolveWizardInteractivity(input = {}) {
24
+ // An explicit `--interactive` always wins: the flags may be intended as seeds
25
+ // to edit rather than as a complete invocation.
26
+ if (input.interactive === true) return true;
27
+ if (input.nonInteractive || input.yes || input.fullySpecified) return false;
28
+ return undefined;
29
+ }
@@ -70,35 +70,44 @@ export const COMMAND_REGISTRY = [
70
70
  shortcuts: ["d", "doc"],
71
71
  examples: [
72
72
  "agentctl doctor",
73
- "agentctl doctor --interactive",
74
- "agentctl doctor --fix safe --yes",
73
+ "agentctl doctor --probe",
75
74
  "agentctl doctor --json",
76
75
  ],
76
+ // `--interactive`, `--fix` and `--yes` were listed here and implemented
77
+ // nowhere: the report carries remediation entries, but nothing applies
78
+ // them. Advertising a flag the command silently ignores is the same defect
79
+ // as documenting `queue` as a read-only browser. They come back to this
80
+ // list when an apply step exists.
77
81
  flags: [
78
- { name: "interactive", type: "boolean", description: "Open full-screen diagnostic matrix" },
79
- { name: "fix", type: "string", description: "Apply fix classes (e.g. 'safe' or check IDs)" },
80
- { name: "probe", type: "boolean", description: "Enable active network/execution probes" },
82
+ { name: "probe", type: "boolean", description: "Actively start the provider CLI to check it answers, rather than only finding it on PATH" },
81
83
  { name: "json", type: "boolean", description: "Output structured JSON doctor report" },
82
- { name: "yes", type: "boolean", description: "Bypass interactive confirmation for safe fixes" },
83
84
  ],
84
85
  },
85
86
  {
86
87
  id: "queue",
88
+ // This described a passive viewer — "Browse and manage", `mutates: false`,
89
+ // `risk: low` — while the handler runs the queue: it dispatches every task
90
+ // to the provider and spends budget. Someone reading `--help` before their
91
+ // first run was told the opposite of what the command does.
87
92
  path: ["queue"],
88
93
  title: "queue",
89
- description: "Browse and manage canonical task queue",
94
+ description: "Execute pending task envelopes: dispatches each to the provider and moves it out of the queue",
90
95
  category: "Operate",
91
- mutates: false,
92
- risk: "low",
96
+ mutates: true,
97
+ risk: "moderate",
93
98
  interactive: "optional",
94
99
  requiresRepository: true,
95
100
  shortcuts: ["q"],
96
101
  examples: [
102
+ "agentctl queue --dry-run",
97
103
  "agentctl queue",
98
- "agentctl queue --interactive",
104
+ "agentctl queue --dag --concurrency 3",
99
105
  "agentctl queue --json",
100
106
  ],
101
107
  flags: [
108
+ { name: "dag", type: "boolean", description: "Resolve depends-on order via Kahn's algorithm before running" },
109
+ { name: "concurrency", type: "string", description: "Parallel worker slots (defaults to limits.concurrency)" },
110
+ { name: "dry-run", type: "boolean", description: "Report what would run without dispatching or moving anything" },
102
111
  { name: "interactive", type: "boolean", description: "Open full-screen queue dashboard" },
103
112
  { name: "json", type: "boolean", description: "Output structured JSON queue snapshot" },
104
113
  { name: "limit", type: "string", description: "Maximum tasks to include in snapshot" },
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
5
  import { loadConfig } from "../config.mjs";
6
- import { probeProvider, detectAvailableProviders } from "../provider-readiness.mjs";
6
+ import { probeProvider, detectAvailableProviders, probeProviderLiveness } from "../provider-readiness.mjs";
7
7
  import { resolveConcurrency } from "../budget.mjs";
8
8
 
9
9
  /**
@@ -443,31 +443,53 @@ export async function runDoctorChecks(options = {}) {
443
443
  // Fall back to the default; config.present already reports a broken config.
444
444
  }
445
445
  const providerProbe = probeProvider(configuredProvider);
446
+ // A green row here used to read as "the provider works", when all it ever
447
+ // checked was a name on PATH or a variable in the environment. An installed
448
+ // CLI whose account has no entitlement passes both and then fails on the
449
+ // first dispatch, so the row has to say what it actually verified.
450
+ const liveness = activeProbe ? probeProviderLiveness(configuredProvider) : null;
451
+ const scopeNote =
452
+ providerProbe.kind === "exec"
453
+ ? "Checked: the binary is on PATH. Not checked: whether the CLI is signed in — only a dispatch can show that."
454
+ : "Checked: a credential is present in the environment. Not checked: whether the provider accepts it.";
455
+ const livenessFailed = Boolean(liveness && liveness.attempted && !liveness.ok);
456
+
446
457
  addResult({
447
458
  id: "provider.key",
448
459
  category: "Provider",
449
460
  title: `Provider Readiness (${providerProbe.name})`,
450
- status: providerProbe.ready ? "pass" : "warn",
451
- severity: providerProbe.ready ? "info" : "high",
461
+ alwaysShowSummary: true,
462
+ status: providerProbe.ready && !livenessFailed ? "pass" : "warn",
463
+ severity: providerProbe.ready && !livenessFailed ? "info" : "high",
452
464
  // Naming the variable, not the value: an operator who wonders which key a
453
465
  // dispatch will use should not have to echo a secret to find out.
454
- summary: `${providerProbe.label} — ${providerProbe.reason}`,
455
- remediation: providerProbe.ready
456
- ? []
457
- : [
458
- {
459
- summary: providerProbe.remedy,
460
- risk: "low",
461
- automatic: false,
462
- requiresProbe: false,
463
- },
464
- ],
466
+ summary: [
467
+ `${providerProbe.label} — ${providerProbe.reason}`,
468
+ liveness && liveness.attempted ? liveness.detail : null,
469
+ providerProbe.ready ? scopeNote : null,
470
+ !activeProbe && providerProbe.kind === "exec" ? "Run `agentctl doctor --probe` to start the CLI and confirm it answers." : null,
471
+ ]
472
+ .filter(Boolean)
473
+ .join(" "),
474
+ remediation:
475
+ providerProbe.ready && !livenessFailed
476
+ ? []
477
+ : [
478
+ {
479
+ summary: livenessFailed ? `The CLI is installed but did not run cleanly: ${liveness.detail}` : providerProbe.remedy,
480
+ risk: "low",
481
+ automatic: false,
482
+ requiresProbe: !providerProbe.ready ? false : true,
483
+ },
484
+ ],
465
485
  evidence: [
466
486
  { label: "provider", value: providerProbe.name, sensitive: false },
467
487
  { label: "providerKind", value: providerProbe.kind, sensitive: false },
468
488
  { label: "ready", value: providerProbe.ready, sensitive: false },
469
489
  { label: "keySource", value: providerProbe.keySource || "", sensitive: false },
470
490
  { label: "binaryFound", value: Boolean(providerProbe.binPath), sensitive: false },
491
+ { label: "livenessProbed", value: Boolean(liveness && liveness.attempted), sensitive: false },
492
+ { label: "livenessOk", value: liveness ? liveness.ok : null, sensitive: false },
471
493
  ],
472
494
  });
473
495
 
@@ -11,14 +11,32 @@ export class IdeScaffoldError extends Error {
11
11
 
12
12
  /**
13
13
  * Scaffolds IDE integration configuration files for Cursor, VS Code, and Claude Desktop.
14
+ *
15
+ * These files land in directories a project may already be using (`.cursor/`,
16
+ * `.vscode/`), and the merge logic preserves what is there — but `--dry-run`
17
+ * was accepted by the CLI and never reached here, so the only way to find out
18
+ * what would be touched was to let it happen.
19
+ *
14
20
  * @param {string} target - 'cursor' | 'vscode' | 'claude' | 'all'
15
21
  * @param {object} [options]
22
+ * @param {string} [options.root]
23
+ * @param {boolean} [options.dryRun] - report the files without writing them
16
24
  * @returns {object} Summary of scaffolded files
17
25
  */
18
26
  export function scaffoldIdeConfig(target = "all", options = {}) {
19
27
  const root = options.root || resolveRoot();
28
+ const dryRun = Boolean(options.dryRun);
20
29
  const validTargets = new Set(["cursor", "vscode", "claude", "all"]);
21
30
 
31
+ /** Write unless this is a rehearsal; the directory is only made when writing. */
32
+ const writeUnlessRehearsing = (dir, file, contents) => {
33
+ if (dryRun) return;
34
+ try {
35
+ mkdirSync(dir, { recursive: true });
36
+ } catch (_) {}
37
+ writeFileSync(file, contents, "utf-8");
38
+ };
39
+
22
40
  const normTarget = (target || "all").toLowerCase();
23
41
  if (!validTargets.has(normTarget)) {
24
42
  throw new IdeScaffoldError(`Invalid target '${target}'. Allowed targets: cursor, vscode, claude, all`);
@@ -29,10 +47,6 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
29
47
  // Target: Cursor (.cursor/mcp.json)
30
48
  if (normTarget === "cursor" || normTarget === "all") {
31
49
  const cursorDir = join(root, ".cursor");
32
- try {
33
- mkdirSync(cursorDir, { recursive: true });
34
- } catch (_) {}
35
-
36
50
  const cursorConfigPath = join(cursorDir, "mcp.json");
37
51
  let existingConfig = {};
38
52
  if (existsSync(cursorConfigPath)) {
@@ -52,17 +66,13 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
52
66
  },
53
67
  };
54
68
 
55
- writeFileSync(cursorConfigPath, JSON.stringify(updatedCursorConfig, null, 2), "utf-8");
69
+ writeUnlessRehearsing(cursorDir, cursorConfigPath, JSON.stringify(updatedCursorConfig, null, 2));
56
70
  results.push({ target: "cursor", file: ".cursor/mcp.json", ok: true });
57
71
  }
58
72
 
59
73
  // Target: VS Code (.vscode/tasks.json)
60
74
  if (normTarget === "vscode" || normTarget === "all") {
61
75
  const vscodeDir = join(root, ".vscode");
62
- try {
63
- mkdirSync(vscodeDir, { recursive: true });
64
- } catch (_) {}
65
-
66
76
  const vscodeTasksPath = join(vscodeDir, "tasks.json");
67
77
  let existingConfig = { version: "2.0.0", tasks: [] };
68
78
  if (existsSync(vscodeTasksPath)) {
@@ -110,17 +120,13 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
110
120
  tasks: updatedTasks,
111
121
  };
112
122
 
113
- writeFileSync(vscodeTasksPath, JSON.stringify(updatedVsCodeConfig, null, 2), "utf-8");
123
+ writeUnlessRehearsing(vscodeDir, vscodeTasksPath, JSON.stringify(updatedVsCodeConfig, null, 2));
114
124
  results.push({ target: "vscode", file: ".vscode/tasks.json", ok: true });
115
125
  }
116
126
 
117
127
  // Target: Claude Desktop (claude_desktop_config.json snippet helper)
118
128
  if (normTarget === "claude" || normTarget === "all") {
119
129
  const agentDir = join(root, ".agent");
120
- try {
121
- mkdirSync(agentDir, { recursive: true });
122
- } catch (_) {}
123
-
124
130
  const snippetPath = join(agentDir, "claude_desktop_config.snippet.json");
125
131
  const claudeSnippet = {
126
132
  mcpServers: {
@@ -131,9 +137,9 @@ export function scaffoldIdeConfig(target = "all", options = {}) {
131
137
  },
132
138
  };
133
139
 
134
- writeFileSync(snippetPath, JSON.stringify(claudeSnippet, null, 2), "utf-8");
140
+ writeUnlessRehearsing(agentDir, snippetPath, JSON.stringify(claudeSnippet, null, 2));
135
141
  results.push({ target: "claude", file: ".agent/claude_desktop_config.snippet.json", ok: true });
136
142
  }
137
143
 
138
- return { ok: true, target: normTarget, results };
144
+ return { ok: true, target: normTarget, dryRun, results };
139
145
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Choose what to show the operator from a failed stage's captured streams.
3
+ *
4
+ * stderr is where a failing suite says what it expected; stdout is where
5
+ * several runners (node:test among them) put the whole report — assertion text
6
+ * and stack included.
7
+ *
8
+ * Preferring stderr outright discarded all of that whenever stderr held so much
9
+ * as the spawn wrapper's own "Command failed: npm test", which it always does.
10
+ * The operator was shown a line naming the command they had just typed, and
11
+ * nothing about which test failed or why. A wrapper line is not diagnostic
12
+ * output: when that is all stderr has, stdout is the report.
13
+ *
14
+ * Lives in src/ rather than in the CLI script so it can be imported and tested
15
+ * without executing the CLI, which runs on import.
16
+ *
17
+ * @param {{ stdout?: string, stderr?: string }} failure
18
+ * @returns {string}
19
+ */
20
+ export function selectFailureOutput(failure = {}) {
21
+ const rawStderr = (failure.stderr || "").trim();
22
+ const rawStdout = (failure.stdout || "").trim();
23
+ if (!rawStderr) return rawStdout;
24
+
25
+ const stderrIsWrapperOnly = rawStderr
26
+ .split("\n")
27
+ .every((line) => line.trim() === "" || /^(command failed|error: command failed|npm err!?)/i.test(line.trim()));
28
+
29
+ if (stderrIsWrapperOnly && rawStdout) return rawStdout;
30
+ if (rawStdout && rawStdout !== rawStderr) {
31
+ // Both carry something real, so show both rather than guessing which runner
32
+ // this is. stderr goes last on purpose: the caller keeps only the tail, and
33
+ // when stderr has real content it is nearly always the failure itself —
34
+ // putting the runner's banner after it would spend the cap on noise.
35
+ return `${rawStdout}\n${rawStderr}`;
36
+ }
37
+ return rawStderr;
38
+ }
@@ -1,5 +1,7 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { join, delimiter } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { resolveWindowsSpawn } from "./git.mjs";
3
5
 
4
6
  /**
5
7
  * What each provider actually needs before a dispatch can succeed.
@@ -172,6 +174,7 @@ export function probeProvider(name, opts = {}) {
172
174
  return {
173
175
  name: descriptor.name,
174
176
  kind: descriptor.kind,
177
+ bin: descriptor.bin,
175
178
  label: descriptor.label,
176
179
  ready,
177
180
  known: true,
@@ -182,6 +185,72 @@ export function probeProvider(name, opts = {}) {
182
185
  };
183
186
  }
184
187
 
188
+ /**
189
+ * Actually run the provider's CLI, rather than only finding it on PATH.
190
+ *
191
+ * `probeProvider` deliberately spawns nothing — it is called from a bare
192
+ * `agentctl` invocation and from `doctor`, both of which must stay instant. But
193
+ * a binary on PATH is a weak claim: a `gemini` that is installed and whose
194
+ * account has no access still reports ready, `doctor` still says 11 passed, and
195
+ * the first thing that disagrees is a dispatch that dies. This is the check
196
+ * that can disagree earlier, so it is opt-in (`agentctl doctor --probe`).
197
+ *
198
+ * It proves the binary starts and answers, not that the account is entitled —
199
+ * no CLI exposes "am I authorised" without doing work — but the common failures
200
+ * (broken install, wrong architecture, a CLI that refuses to start unauthenticated)
201
+ * surface here instead of mid-repair.
202
+ *
203
+ * @param {string} name
204
+ * @param {object} [opts]
205
+ * @param {NodeJS.ProcessEnv} [opts.env=process.env]
206
+ * @param {number} [opts.timeoutMs=8000]
207
+ * @returns {{ name: string, attempted: boolean, ok: boolean, detail: string }}
208
+ */
209
+ export function probeProviderLiveness(name, opts = {}) {
210
+ const env = opts.env || process.env;
211
+ const base = probeProvider(name, { env });
212
+ if (base.kind !== "exec" || !base.binPath) {
213
+ return {
214
+ name: base.name,
215
+ attempted: false,
216
+ ok: base.ready,
217
+ detail: base.kind === "http" ? "Hosted provider — a credential cannot be validated without spending a request." : base.reason,
218
+ };
219
+ }
220
+
221
+ try {
222
+ // A global npm install puts a `.cmd` shim on PATH, and since the fix for
223
+ // CVE-2024-27980 Node refuses to spawn one directly — it comes back EINVAL,
224
+ // which would report every Windows CLI as broken. `resolveWindowsSpawn` is
225
+ // the same routing `runCmd` already uses: native .exe direct, everything
226
+ // else through cmd.exe with the argv quoted the way cmd.exe parses it back.
227
+ const win = resolveWindowsSpawn(base.binPath, ["--version"], env);
228
+ const res = win
229
+ ? spawnSync(win.file, win.args, {
230
+ encoding: "utf-8",
231
+ timeout: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000,
232
+ env,
233
+ windowsVerbatimArguments: win.verbatim,
234
+ })
235
+ : spawnSync(base.binPath, ["--version"], {
236
+ encoding: "utf-8",
237
+ timeout: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000,
238
+ env,
239
+ });
240
+ if (res.error) {
241
+ return { name: base.name, attempted: true, ok: false, detail: `\`${base.bin || base.name} --version\` could not run: ${res.error.message}` };
242
+ }
243
+ if (res.status !== 0) {
244
+ const why = ((res.stderr || res.stdout || "").trim().split("\n")[0] || `exit ${res.status}`).slice(0, 200);
245
+ return { name: base.name, attempted: true, ok: false, detail: `\`--version\` exited ${res.status}: ${why}` };
246
+ }
247
+ const version = (res.stdout || "").trim().split("\n")[0].slice(0, 80);
248
+ return { name: base.name, attempted: true, ok: true, detail: version ? `CLI responds: ${version}` : "CLI responds." };
249
+ } catch (err) {
250
+ return { name: base.name, attempted: true, ok: false, detail: `Probe failed: ${err.message}` };
251
+ }
252
+ }
253
+
185
254
  /**
186
255
  * Probe every built-in provider, ready ones first, in preference order.
187
256
  *
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
4
- import { suggestProvider } from "./provider-readiness.mjs";
5
- import { PROFILE_NAMES } from "./profiles.mjs";
4
+ import { suggestProvider, detectAvailableProviders } from "./provider-readiness.mjs";
5
+ import { detectDefaultBranch } from "./git.mjs";
6
+ import { PROFILE_NAMES, PROFILE_DESCRIPTIONS } from "./profiles.mjs";
6
7
  import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
7
8
  import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
8
9
  import { KIT_VERSION } from "./version.mjs";
@@ -174,6 +175,11 @@ export function planInit(root = process.cwd(), options = {}) {
174
175
  const requestedProfile = String(options.profile || existingConfig.verify?.profile || "standard").toLowerCase();
175
176
  const profile = PROFILE_NAMES.includes(requestedProfile) ? requestedProfile : "standard";
176
177
 
178
+ // Detected, not assumed. A hardcoded `main` made the very first
179
+ // `agentctl check` fail with an unresolvable base ref in every repository
180
+ // whose git chose `master`, or whose team standardised on `develop`.
181
+ const baseBranch = options.baseBranch || existingConfig.base_branch || detectDefaultBranch(root);
182
+
177
183
  const limitsBlock = isCustomLimits
178
184
  ? `\nlimits:\n concurrency: ${limits.concurrency}\n daily_tasks: ${limits.daily_tasks}\n stagger_ms: ${limits.stagger_ms}\n diff_kb: ${limits.diff_kb}\n`
179
185
  : "";
@@ -183,7 +189,7 @@ export function planInit(root = process.cwd(), options = {}) {
183
189
  version: 1
184
190
  provider: ${provider}
185
191
  tier: ${tierName}
186
- base_branch: ${options.baseBranch || existingConfig.base_branch || "main"}
192
+ base_branch: ${baseBranch}
187
193
  branch_prefix: ${existingConfig.branch_prefix || "agent/"}
188
194
  ${limitsBlock}
189
195
  verify:
@@ -229,6 +235,7 @@ allow_paths: ${allowPaths.length > 0 ? "\n" + allowPaths.map((p) => ` - "${p}"`
229
235
  tier: tierName,
230
236
  provider,
231
237
  profile,
238
+ baseBranch,
232
239
  verify,
233
240
  limits,
234
241
  presets: selectedPresets,
@@ -292,6 +299,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
292
299
  }
293
300
 
294
301
  let selectedTier = options.tier || existingConfig.tier || FALLBACK_TIER;
302
+ let selectedProvider = options.provider || existingConfig.provider;
303
+ let selectedProfile = options.profile || existingConfig.verify?.profile;
295
304
  let testCmd = options.testCmd;
296
305
  let buildCmd = options.buildCmd;
297
306
  let selectedPresets = options.presets;
@@ -302,14 +311,51 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
302
311
  await new Promise((resolve) => setTimeout(resolve, 300));
303
312
  sp.stop(`Detected Stack: ${oracle.stack}`);
304
313
 
305
- const optionsList = tierOptions();
306
- const defaultTierIdx = Math.max(0, optionsList.findIndex((t) => t.value === selectedTier));
307
-
308
- selectedTier = await select(
309
- optionsList,
310
- "Which plan does your Jules account use? (limits are adjustable later)",
311
- { ...options, defaultIdx: defaultTierIdx }
314
+ // Which agent, before anything about one vendor's plans.
315
+ //
316
+ // The wizard's first question used to be "Which plan does your Jules
317
+ // account use?", asked of everyone — including people who came to drive
318
+ // Claude Code or Codex and were now left guessing whether a Jules
319
+ // subscription was a prerequisite. Ask what the repository is for first,
320
+ // and ask the plan question only of the provider it belongs to.
321
+ const probes = detectAvailableProviders({ env: options.env || process.env });
322
+ const providerOptions = probes.map((pr) => ({
323
+ label: `${pr.name}${pr.ready ? "" : " (not available here)"}`,
324
+ value: pr.name,
325
+ hint: pr.ready ? pr.label : `${pr.label} — ${pr.remedy}`,
326
+ }));
327
+ const defaultProviderIdx = Math.max(
328
+ 0,
329
+ providerOptions.findIndex((o) => o.value === (selectedProvider || probes.find((pr) => pr.ready)?.name))
312
330
  );
331
+ selectedProvider = await select(providerOptions, "Which agent should run the tasks?", {
332
+ ...options,
333
+ defaultIdx: defaultProviderIdx,
334
+ });
335
+
336
+ // Only the hosted provider meters work against an account plan; asking a
337
+ // local-CLI user about tiers is asking about something that does not exist
338
+ // for them.
339
+ if (selectedProvider === "jules") {
340
+ const optionsList = tierOptions();
341
+ const defaultTierIdx = Math.max(0, optionsList.findIndex((t) => t.value === selectedTier));
342
+ selectedTier = await select(
343
+ optionsList,
344
+ "Which plan does your Jules account use? (limits are adjustable later)",
345
+ { ...options, defaultIdx: defaultTierIdx }
346
+ );
347
+ }
348
+
349
+ const profileOptions = PROFILE_NAMES.map((n) => ({
350
+ label: n,
351
+ value: n,
352
+ hint: PROFILE_DESCRIPTIONS[n],
353
+ }));
354
+ const defaultProfileIdx = Math.max(0, profileOptions.findIndex((o) => o.value === (selectedProfile || "standard")));
355
+ selectedProfile = await select(profileOptions, "How hard should the gate verify agent work?", {
356
+ ...options,
357
+ defaultIdx: defaultProfileIdx,
358
+ });
313
359
 
314
360
  testCmd = await input("Verification Test Command", {
315
361
  defaultValue: testCmd || existingConfig.verify?.test || oracle.candidates.testCmd || "npm test",
@@ -357,6 +403,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
357
403
  const plan = planInit(root, {
358
404
  ...options,
359
405
  tier: selectedTier,
406
+ provider: selectedProvider,
407
+ profile: selectedProfile,
360
408
  testCmd,
361
409
  buildCmd,
362
410
  presets: selectedPresets,
@@ -257,7 +257,8 @@ ${fullPrompt}
257
257
  * Run interactive or headless task creation wizard.
258
258
  * @param {string} [root=process.cwd()]
259
259
  * @param {object} [options]
260
- * @returns {Promise<{ ok: boolean, taskFile: string, plan: object }>}
260
+ * @param {boolean} [options.dryRun] - synthesize and validate the envelope without writing it
261
+ * @returns {Promise<{ ok: boolean, dryRun: boolean, taskFile: string, written: boolean, plan: object }>}
261
262
  */
262
263
  export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
263
264
  const interactive = options.interactive !== false && isTTY(options.stdin || process.stdin);
@@ -338,20 +339,36 @@ export async function runTaskCreateWizard(root = process.cwd(), options = {}) {
338
339
 
339
340
  // Write Task File to canonical queue directory (getQueueDir)
340
341
  const queueDir = getQueueDir(root);
341
- if (!existsSync(queueDir)) {
342
- mkdirSync(queueDir, { recursive: true });
343
- }
344
-
345
342
  const taskFile = resolve(queueDir, `${plan.taskId}.md`);
346
343
  if (!taskFile.startsWith(resolve(queueDir))) {
347
344
  throw new Error("Task ID path traversal blocked.");
348
345
  }
349
346
 
347
+ // `--dry-run` was accepted by the CLI parser and then dropped on the floor:
348
+ // the envelope was written to the queue either way, so a rehearsal queued
349
+ // real work. A rehearsal returns the same plan and touches nothing — not even
350
+ // the queue directory, which would otherwise be created as a side effect.
351
+ if (options.dryRun) {
352
+ return {
353
+ ok: true,
354
+ dryRun: true,
355
+ taskFile,
356
+ written: false,
357
+ plan,
358
+ };
359
+ }
360
+
361
+ if (!existsSync(queueDir)) {
362
+ mkdirSync(queueDir, { recursive: true });
363
+ }
364
+
350
365
  writeFileSync(taskFile, plan.taskFileContent, "utf-8");
351
366
 
352
367
  return {
353
368
  ok: true,
369
+ dryRun: false,
354
370
  taskFile,
371
+ written: true,
355
372
  plan,
356
373
  };
357
374
  }
@@ -1,77 +0,0 @@
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
- }