Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Add `jury agents jury` to save default reviewers globally in `~/.jury/config.json`, beside the
global judge: `jury agents jury claude,droid`, `--reset`, and a checkbox picker when run with no
arguments in a terminal. Runs without `--jury` use them; repository `reviewer` roles and
`--reviewer`/`--jury` still take precedence. `jury agents` marks the saved defaults (#98).
- Add TRAE CLI (`traecli`) as an opt-in built-in agent named `trae`, usable as a reviewer (`--jury trae`) or the main agent (`--judge trae`). Reviews run in its read-only sandbox (#96).

## 0.7.0 — 2026-09-18
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,21 @@ is excluded from the reviewer pool.
Without an explicit selection, enabled agents with the reviewer role are used; disable unavailable ones
or select installed reviewers explicitly.

Or save default reviewers once for all repositories:

```bash
jury agents jury claude # only claude reviews by default
jury agents jury claude,droid,amp # several; opt-in agents allowed
jury agents jury # show them, or pick with a checkbox list in a terminal
jury agents jury --reset # back to the built-in reviewer pool
```

Saved in `~/.jury/config.json`, next to the global judge. Precedence: `--reviewer`/`--jury`,
repository `reviewer` roles, saved default reviewers, then the built-in pool. A saved list turns
off automatic role assignment, as `--jury` does. The judge is left out of its own review. If a saved
reviewer is later uninstalled or disabled in a repository, the run stops with an error naming the
saved setting rather than reviewing with fewer agents.

### Built-in agents

| name | product | install | default |
Expand Down
101 changes: 93 additions & 8 deletions bin/jury.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { readFileSync } from "node:fs";
import { promisify } from "node:util";
import path from "node:path";
import { automaticRoles, automaticReviewers } from "../lib/roles.js";
import { loadConfig, reviewers, judgeAgent, knownAgents, readGlobalConfig, saveGlobalJudge, globalConfigPath } from "../lib/config.js";
import { loadConfig, reviewers, judgeAgent, knownAgents, readGlobalConfig, saveGlobalJudge, saveGlobalReviewers, defaultReviewers, savedReviewersNote, globalConfigPath } from "../lib/config.js";
import { runAgent, probe } from "../lib/agents.js";
import { threadFor, buildReply, replyArgv } from "../lib/reply.js";
import { buildPrompt } from "../lib/prompt.js";
Expand All @@ -26,6 +26,7 @@ import { assertPrCheckout, repositoryFromPrUrl, resolvePrCheckout } from "../lib
import { resolveJuryDirectory } from "../lib/directories.js";
import * as st from "../lib/style.js";
import { parseReviewArgs, reviewerOptions, requestedReviewers } from "../lib/cli-options.js";
import { pick } from "../lib/picker.js";
import { startReviewConsole } from "../lib/review-console.js";

const run = promisify(execFile);
Expand Down Expand Up @@ -60,7 +61,7 @@ Common flags

--dir <path> working/state root (default: Git cwd or ~/.jury)
--rounds <n> stop after n rounds (default: 10)
--reviewer <name> only these reviewers, repeatable or comma-separated (default: configured reviewers)
--reviewer <name> only these reviewers, repeatable or comma-separated (default: configured or saved reviewers)
--jury <name> same as --reviewer
--judge codex one agent that triages and fixes (default: configured, auto for 1–2 installed CLIs, then codex)
--push <true|false> commit and push fixes (default: true)
Expand All @@ -70,6 +71,7 @@ Other commands

jury agents which reviewers are installed
jury agents judge <agent> set the global default judge
jury agents jury <agents> set the default reviewers
jury runs every PR under review
jury version

Expand All @@ -84,6 +86,7 @@ const USAGE_FULL = `jury — review a pull request with multiple AI reviewers un
jury runs list every PR under review, with its slug for --run
jury agents check which configured agents are installed
jury agents judge <agent> set the global default judge
jury agents jury <agents> set the default reviewers (picker in a terminal)
jury version

Related PRs: jury review <pr-url-1> <pr-url-2>
Expand All @@ -100,7 +103,7 @@ review (triages, fixes, commits, and pushes automatica
--title <text> what the change does (default: read from the PR)
--summary <text> intent, passed to reviewers (default: the PR description)
--rounds <n> maximum rounds (default: 10)
--reviewer <name> only these reviewers, repeatable or comma-separated (default: configured reviewers)
--reviewer <name> only these reviewers, repeatable or comma-separated (default: configured or saved reviewers)
--jury <name> same as --reviewer
--judge <agent> one agent that triages and fixes (default: configured, auto for 1–2 installed CLIs, then codex)
--resume <slug> continue an existing run instead of starting a new one
Expand Down Expand Up @@ -198,8 +201,8 @@ try {

/** Resolve an explicit reviewer list or explain each ineligible name. */
function selectReviewers(cfg, requested, judge = null) {
if (!requested) return defaultReviewers(cfg, judge);
const eligible = reviewers(cfg).filter((a) => a.name !== judge);
if (!requested) return eligible;

const names = requested;
if (!names.length) throw new Error("--reviewer needs at least one reviewer name");
Expand Down Expand Up @@ -608,7 +611,10 @@ async function cmdAgent(argv) {
if (missing.length) {
const reason = `reviewers not installed: ${missing.map(a => `${a.name} (${a.bin})`).join(", ")}`;
await publishRun(dir, { ...target, state: "human", stateNote: reason });
throw new Error(`${reason}. Install them, disable them in jury.config.json, or select installed reviewers with --reviewer <name>. No agents were started.`);
const fromSaved = !roles && !requestedReviewers(values) && cfg.savedReviewers;
throw new Error(fromSaved
? `${reason}, from the ${savedReviewersNote}. Install them, save installed reviewers, or select reviewers with --reviewer <name>. No agents were started.`
: `${reason}. Install them, disable them in jury.config.json, or select installed reviewers with --reviewer <name>. No agents were started.`);
}
}
const first = Math.max(0, ...prior.filter((e) => e.t === "round.start").map((e) => e.n)) + 1;
Expand Down Expand Up @@ -1191,8 +1197,9 @@ async function cmdRuns(argv) {
}

async function cmdAgents(args = []) {
if (["jury", "reviewer", "reviewers"].includes(args[0])) return cmdDefaultReviewers(args.slice(1));
if (args.length) {
if (args[0] !== "judge" || args.length > 2) throw new Error("Usage: jury agents judge [<agent>|--reset]");
if (args[0] !== "judge" || args.length > 2) throw new Error("Usage: jury agents judge [<agent>|--reset] | jury agents jury [<agent>,...|--reset]");
const name = args[1];
if (name === "--help" || name === "-h") {
process.stdout.write(commandHelp("agents", USAGE_FULL));
Expand All @@ -1219,6 +1226,7 @@ async function cmdAgents(args = []) {
return;
}
const cfg = await loadConfig();
const saved = new Set(cfg.savedReviewers ?? []);
// Every known agent is listed, not just the default pool: an opt-in built-in
// that is never shown is an agent nobody discovers. They are marked so the
// listing still says which ones actually run without being asked for.
Expand All @@ -1230,9 +1238,15 @@ async function cmdAgents(args = []) {
const role = roleOf.get(p.name);
console.log(
`${p.ok ? "ok " : "MISSING"} ${p.name.padEnd(9)} ${role.padEnd(8)} ${p.path ?? p.bin}`
+ (byName.get(p.name)?.enabled === false ? " (opt-in)" : ""),
+ (byName.get(p.name)?.enabled === false ? " (opt-in)" : "")
+ (saved.has(p.name) ? " (default reviewer)" : ""),
);
}
if (cfg.savedReviewers) {
console.log(`\nDefault reviewers: ${cfg.savedReviewers.join(", ")} (saved in ${globalConfigPath()}; change with jury agents jury)`);
} else if (cfg.globalReviewers) {
console.log(`\nSaved default reviewers (${cfg.globalReviewers.join(", ")}) are overridden by reviewer roles in ${path.basename(cfg.configFile)}.`);
}

// An agent with no read-only mode still reviews, but only the prompt is
// keeping it from editing the worktree. That is a real difference in what a
Expand All @@ -1252,7 +1266,11 @@ async function cmdAgents(args = []) {
// Only the default pool decides the exit status. An opt-in agent nobody asked
// for is not a broken install, and failing on it would make `jury agents`
// red on every machine that has not installed every supported CLI.
const missing = found.filter((p) => !p.ok && byName.get(p.name)?.enabled !== false);
// With saved default reviewers, those are the pool; the judge still counts.
const inPool = (p) => cfg.savedReviewers
? saved.has(p.name) || roleOf.get(p.name) === "main"
: byName.get(p.name)?.enabled !== false;
const missing = found.filter((p) => !p.ok && inPool(p));
if (missing.length) {
console.log(`\n${missing.length} agent(s) not installed. Install them, or disable in jury.config.json.`);
for (const p of missing) {
Expand All @@ -1263,6 +1281,73 @@ async function cmdAgents(args = []) {
}
}

/**
* `jury agents jury`: the saved default reviewers, used when a run names none.
*
* Mirrors `jury agents judge`. With no arguments on a TTY it opens a checkbox
* picker; anywhere else it prints the current setting, so a script piping
* `jury agents jury` never blocks waiting for keys.
*/
async function cmdDefaultReviewers(args) {
const usage = "Usage: jury agents jury [<agent>[,<agent>...]|--reset]";
if (args.includes("--help") || args.includes("-h")) {
process.stdout.write(commandHelp("agents", USAGE_FULL));
return;
}
if (args.includes("--reset")) {
if (args.length > 1) throw new Error(usage);
await saveGlobalReviewers(null);
console.log("Default reviewers reset; repository roles or the built-in reviewer pool apply.");
return;
}
if (args.some(a => a.startsWith("-"))) throw new Error(usage);
const cfg = await loadConfig();
const known = knownAgents(cfg);
const names = [...new Set(args.flatMap(a => a.split(",")).map(n => n.trim()).filter(Boolean))];
if (args.length && !names.length) throw new Error(usage);

if (!names.length && !(process.stdin.isTTY && process.stdout.isTTY)) {
const settings = await readGlobalConfig();
console.log(`Default reviewers: ${settings.reviewers?.join(", ") ?? "not set (built-in reviewer pool)"}`);
if (settings.reviewers && !cfg.savedReviewers) {
console.log(`Overridden here by reviewer roles in ${path.basename(cfg.configFile)}.`);
}
return;
}

let chosen = names;
if (!chosen.length) {
const judge = judgeAgent(cfg)?.name;
const found = await Promise.all(known.map(probe));
const items = known.map((a, i) => ({
name: a.name,
status: found[i].ok ? "ok" : "MISSING",
notes: [
...(a.enabled === false ? ["opt-in"] : []),
...(a.name === judge ? ["judge — excluded"] : []),
],
}));
const current = cfg.globalReviewers ?? defaultReviewers(cfg, judge).map(a => a.name);
chosen = await pick(items, current, { title: "Default reviewers" });
if (!chosen) {
console.log("Cancelled; default reviewers unchanged.");
return;
}
}

// The whole known set, so an opt-in built-in can be a default reviewer
// without first being enabled in whichever repository is the cwd right now.
const unknown = chosen.filter(n => !known.some(a => a.name === n));
if (unknown.length) {
throw new Error(`Unknown or disabled reviewer ${unknown.map(n => `"${n}"`).join(", ")}. Choose: ${known.map(a => a.name).join(", ")}`);
}
await saveGlobalReviewers(chosen);
console.log(`Default reviewers: ${chosen.join(", ")} (${globalConfigPath()})`);
const judge = judgeAgent(cfg)?.name;
if (chosen.includes(judge)) console.log(`${judge} is the current judge and is left out of runs it judges.`);
console.log("Repository reviewer roles and --reviewer/--jury override this default.");
}

/**
* The repository's default branch, asked of the remote rather than assumed.
*
Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ The example configuration keeps Codex as judge and Claude as reviewer:
}
```

Reviewers used when a run names none resolve in this order: `--reviewer`/`--jury`, any enabled
agent this file gives `"role": "reviewer"`, the default reviewers saved with `jury agents jury`
(`"reviewers"` in `~/.jury/config.json`), then every enabled built-in reviewer. A saved name that
this file disables, or that is no longer an agent, stops the run with an error naming the setting.

The CLI checks executable availability before starting selected reviewers. It cannot preflight
provider login, credits, or quota without invoking the provider. Runtime failures stop the loop
and remain visible in the saved reports.
Expand Down
57 changes: 54 additions & 3 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export async function readGlobalConfig(file = globalConfigPath()) {
|| (settings.judge !== undefined && (typeof settings.judge !== "string" || !settings.judge.trim()))) {
throw new Error("expected an object with a nonempty judge name");
}
if (settings.reviewers !== undefined && (!Array.isArray(settings.reviewers) || !settings.reviewers.length
|| settings.reviewers.some(n => typeof n !== "string" || !n.trim()))) {
throw new Error("expected reviewers to be a nonempty list of agent names");
}
return settings;
} catch (err) {
if (err.code === "ENOENT") return {};
Expand All @@ -46,9 +50,19 @@ export async function readGlobalConfig(file = globalConfigPath()) {
}

export async function saveGlobalJudge(name, file = globalConfigPath()) {
await saveGlobalSetting("judge", name, file);
}

/** Save the default reviewer list, or remove it with null. */
export async function saveGlobalReviewers(names, file = globalConfigPath()) {
if (names !== null && !names.length) throw new Error("at least one default reviewer is required");
await saveGlobalSetting("reviewers", names, file);
}

async function saveGlobalSetting(key, value, file) {
const settings = await readGlobalConfig(file);
if (name === null) delete settings.judge;
else settings.judge = name;
if (value === null) delete settings[key];
else settings[key] = value;
await mkdir(path.dirname(file), { recursive: true });
const temp = `${file}.${randomUUID()}.tmp`;
try {
Expand Down Expand Up @@ -131,10 +145,19 @@ export async function loadConfig(dir = process.cwd(), { globalFile = globalConfi
}
const main = globalJudge ? selectable.find(a => a.name === globalJudge) ?? null : mains[0] ?? null;

// Saved default reviewers sit below the repository's own roles: a repository
// that names a reviewer explicitly has chosen its jury, and a global setting
// made for some other project must not silently replace it.
const repoReviewers = (user.agents ?? []).some(a => a.role === "reviewer" && a.enabled !== false);
const savedReviewers = !repoReviewers && global.reviewers ? [...global.reviewers] : null;

return {
explicitRoles: Boolean(global.judge || (user.agents ?? []).some(a =>
explicitRoles: Boolean(global.judge || global.reviewers || (user.agents ?? []).some(a =>
Object.hasOwn(a, "role") || Object.hasOwn(a, "enabled"))),
globalJudge: global.judge ?? null,
globalReviewers: global.reviewers ?? null,
savedReviewers,
disabledByRepo: [...offByUser],
stopToken: user.stopToken ?? DEFAULTS.stopToken,
agents,
available: selectable,
Expand Down Expand Up @@ -165,3 +188,31 @@ export function knownAgents(cfg) {
export function reviewers(cfg) {
return cfg.agents.filter((a) => (a.role ?? "reviewer") === "reviewer");
}

const SAVED = "saved default reviewers (~/.jury/config.json; change with `jury agents jury`)";

/**
* The reviewers a run uses when none are named on the command line.
*
* Saved defaults reach the whole known set, like --jury, so an opt-in built-in
* can be a default. A saved name that no longer resolves is an error naming the
* setting rather than a silent drop: a jury that quietly shrank is a review
* with fewer eyes than the user believes it has. The judge is left out, as it
* never reviews its own work.
*/
export function defaultReviewers(cfg, judge = null) {
if (!cfg.savedReviewers) return reviewers(cfg).filter(a => a.name !== judge);
const byName = new Map(knownAgents(cfg).map(a => [a.name, a]));
const problems = cfg.savedReviewers.filter(name => !byName.has(name)).map(name =>
(cfg.disabledByRepo ?? []).includes(name)
? `"${name}" is disabled in ${path.basename(cfg.configFile)}`
: `"${name}" is not a configured agent`);
if (problems.length) throw new Error(`${SAVED}: ${problems.join("; ")}`);
const pool = cfg.savedReviewers.filter(name => name !== judge).map(name => byName.get(name));
if (!pool.length) {
throw new Error(`${SAVED} list only "${judge}", which is the selected judge; save another reviewer or pass --reviewer <name>`);
}
return pool;
}

export const savedReviewersNote = SAVED;
2 changes: 1 addition & 1 deletion lib/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function commandHelp(topic, full) {
finding: section("finding commands", "With a PR URL"),
reply: `jury reply [--dir <path>] [--run <slug>]\n\nReply to each reviewer about its answered findings.\n${dir}\n${[...new Set(reviewers)].join("\n")}`,
runs: `jury runs [--dir <path>]\n\nList recorded review runs and their slugs.\n${dir}`,
agents: "jury agents\n\nCheck which configured agents are installed and show their roles.\n\njury agents judge show the global default\njury agents judge <agent> set the global default\njury agents judge --reset remove the global override\n\nSaved in ~/.jury/config.json. Precedence: --judge, repository main role, global judge, built-in default.",
agents: "jury agents\n\nCheck which configured agents are installed and show their roles.\n\njury agents judge show the global default\njury agents judge <agent> set the global default\njury agents judge --reset remove the global override\n\nSaved in ~/.jury/config.json. Precedence: --judge, repository main role, global judge, built-in default.\n\njury agents jury show the default reviewers (a picker in a terminal)\njury agents jury <agent>[,<agent>] set the default reviewers\njury agents jury --reset back to the built-in reviewer pool\n\nSaved in ~/.jury/config.json. Precedence: --reviewer/--jury, repository reviewer roles, saved default reviewers, built-in pool.",
version: "jury version\n\nPrint the installed package version.",
help: "jury help [command]\njury help --all\n\nShow command help or the complete command reference.",
};
Expand Down
Loading
Loading