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