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 +4 -3
- package/bin/agentctl.mjs +157 -25
- package/package.json +1 -1
- package/src/config-edit.mjs +138 -0
- package/src/git.mjs +79 -0
- package/src/ops/cli-intent.mjs +29 -0
- package/src/ops/command-registry.mjs +19 -10
- package/src/ops/doctor-registry.mjs +36 -14
- package/src/ops/ide-scaffold.mjs +22 -16
- package/src/ops/verify-output.mjs +38 -0
- package/src/provider-readiness.mjs +69 -0
- package/src/wizard-init.mjs +58 -10
- package/src/wizard-task.mjs +22 -5
- package/src/profiles-io.mjs +0 -77
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 **
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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: ${
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
1516
|
-
const isInteractive =
|
|
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(
|
|
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
|
-
|
|
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 ||
|
|
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.
|
|
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 --
|
|
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: "
|
|
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: "
|
|
94
|
+
description: "Execute pending task envelopes: dispatches each to the provider and moves it out of the queue",
|
|
90
95
|
category: "Operate",
|
|
91
|
-
mutates:
|
|
92
|
-
risk: "
|
|
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 --
|
|
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
|
-
|
|
451
|
-
|
|
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:
|
|
455
|
-
|
|
456
|
-
?
|
|
457
|
-
:
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
package/src/ops/ide-scaffold.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/wizard-init.mjs
CHANGED
|
@@ -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 {
|
|
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: ${
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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,
|
package/src/wizard-task.mjs
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
}
|
package/src/profiles-io.mjs
DELETED
|
@@ -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
|
-
}
|