@agentsdance/codejury 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,13 @@ head commit, reviews an isolated temporary checkout, and pushes accepted fixes
21
21
  back to the source branch. It is safe to invoke from `master` or from outside
22
22
  the target repository; the caller's working tree is not switched or modified.
23
23
 
24
+ GitLab and compatible self-hosted services are supported through
25
+ `/merge_requests/<id>` URLs. Jury clones with Git and fetches the standard
26
+ `refs/merge-requests/<id>/head` ref, so existing Git credentials are used. If
27
+ the source branch is not uniquely visible on the target remote (commonly a
28
+ fork), use `--no-push` for a read-only review or check out the source branch
29
+ locally and pass `--dir`.
30
+
24
31
  Node 20 or newer. The reviewers are separate CLIs you install yourself — `jury` spawns whatever you
25
32
  have and skips the rest:
26
33
 
@@ -34,6 +41,17 @@ used. The former `cr.config.json` and `macr.config.json` names remain readable f
34
41
 
35
42
  The former `cr` command remains available as a compatibility alias.
36
43
 
44
+ ## Working and state directory
45
+
46
+ With no `--dir`, Jury keeps the current directory when it is a Git worktree. From anywhere else it
47
+ uses `~/.jury` and creates that directory automatically. An explicitly empty `--dir ""` also selects
48
+ `~/.jury`; a non-empty explicit path always wins. `~` is expanded consistently for review, web,
49
+ runs, findings, and reply commands.
50
+
51
+ Run records live in `<dir>/runs`. For example, `jury runs --dir ""` and `jury web --dir ""` read
52
+ `~/.jury/runs`, while a review launched from a repository continues to use that repository's
53
+ `runs` directory.
54
+
37
55
  ## The loop
38
56
 
39
57
  ```
@@ -76,12 +94,21 @@ standalone, against runs that already exist.
76
94
 
77
95
  ## Who writes, and who only reads
78
96
 
79
- Exactly one agent has role `main` **Claude Code**, the session driving the loop. It owns the working
80
- tree and the commit, triages every finding, and is the only writer. Its registry entry carries an
81
- empty `argv` because it is never spawned; `jury agents` shows it as in-process:
97
+ Exactly one agent has role `main`; it is the default **judge**. The built-in default is Claude Code.
98
+ The judge owns the working tree and commit, triages every finding, and is the only writer. Choose a
99
+ different enabled agent for one run with `--judge`; without that flag Claude remains the default:
100
+
101
+ ```bash
102
+ jury https://github.com/owner/repo/pull/1 # Claude judges
103
+ jury --judge codex https://github.com/owner/repo/pull/1 # Codex judges
104
+ ```
105
+
106
+ `--judge` accepts exactly one agent name. The selected judge is removed from that run's reviewer
107
+ pool, so it never reviews its own work.
108
+ `jury agents` shows the configured roles:
82
109
 
83
110
  ```
84
- ok claude main (in-process)
111
+ ok claude main /usr/local/bin/claude
85
112
  ok codex reviewer /usr/local/bin/codex
86
113
  ok agy reviewer /usr/local/bin/agy
87
114
  ```
@@ -89,7 +116,7 @@ ok agy reviewer /usr/local/bin/agy
89
116
  Reviewers only read and report. A registry with two `main` entries is refused outright — two agents
90
117
  both believing they own the commit corrupts a run rather than merely failing it.
91
118
 
92
- The main agent then holds **one conversation per reviewer**, concurrently, each about that reviewer's
119
+ The judge then holds **one conversation per reviewer**, concurrently, each about that reviewer's
93
120
  own findings and nothing else:
94
121
 
95
122
  ```
package/bin/jury.js CHANGED
@@ -10,16 +10,17 @@ import { execFile } from "node:child_process";
10
10
  import { readFileSync } from "node:fs";
11
11
  import { promisify } from "node:util";
12
12
  import path from "node:path";
13
- import { loadConfig, reviewers, mainAgent } from "../lib/config.js";
13
+ import { loadConfig, reviewers, judgeAgent } from "../lib/config.js";
14
14
  import { runAgent, probe } from "../lib/agents.js";
15
15
  import { threadFor, buildReply, replyArgv } from "../lib/reply.js";
16
16
  import { buildPrompt } from "../lib/prompt.js";
17
17
  import { serve } from "../lib/server.js";
18
18
  import { appendEvent, writeRun, readEvents, foldEvents, slugFor, attemptStamp, runsDir, writeArtifact, openArtifact } from "../lib/store.js";
19
19
  import { findingsIn, gate, settledList, VERDICTS } from "../lib/findings.js";
20
- import { MAX_TURNS, turnsFor, outstanding, deadlocked, refreshSettled, replyRound, record, sessionsIn, openFindings } from "../lib/loop.js";
20
+ import { MAX_TURNS, turnsFor, outstanding, deadlocked, refreshSettled, replyRound, record, sessionsIn, openFindings, currentJudge } from "../lib/loop.js";
21
21
  import { triageOne } from "../lib/triage.js";
22
- import { assertPrCheckout, resolvePrCheckout } from "../lib/repository.js";
22
+ import { assertPrCheckout, repositoryFromPrUrl, resolvePrCheckout } from "../lib/repository.js";
23
+ import { resolveJuryDirectory } from "../lib/directories.js";
23
24
  import * as st from "../lib/style.js";
24
25
 
25
26
  const run = promisify(execFile);
@@ -50,10 +51,11 @@ const USAGE = `jury — run a pull request past several AI reviewers until they
50
51
 
51
52
  Common flags
52
53
 
54
+ --dir <path> working/state root (default: Git cwd or ~/.jury)
53
55
  --rounds <n> stop after n rounds (default 10)
54
56
  --agents codex,grok only these reviewers (default: all installed)
57
+ --judge codex one agent that triages and fixes (default: claude)
55
58
  --no-push fix locally, do not push
56
- --dry-run exercise the pipeline, spawn nothing
57
59
 
58
60
  Other commands
59
61
 
@@ -67,7 +69,7 @@ Other commands
67
69
 
68
70
  const USAGE_FULL = `jury — multi-agent code review
69
71
 
70
- jury <pr-url> review a PR: rounds until convergence, one conversation per reviewer
72
+ jury <pr-url> review a PR until every reviewer approves, one conversation per reviewer
71
73
  jury review-once [flags] a single round, no triage or reply
72
74
  jury web [flags] serve the console (default http://127.0.0.1:3080)
73
75
  jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
@@ -80,39 +82,46 @@ review (drives itself; no operator between rounds)
80
82
  jury https://github.com/owner/repo/pull/1
81
83
  jury --rounds 3 the current branch, no PR
82
84
 
83
- --dir <path> repo/worktree (default .)
85
+ --dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
84
86
  --pr <url> same as the positional argument
85
87
  --trunk <branch> diff base branch (default: the remote's own HEAD)
86
88
  --title <text> what the change does (default: read from the PR)
87
89
  --summary <text> intent, passed to reviewers (default: the PR description)
88
90
  --rounds <n> maximum rounds (default 10)
89
91
  --agents a,b only these reviewers (default: all installed)
92
+ --judge <agent> one agent that triages and fixes (default: claude)
90
93
  --resume <slug> continue an existing run instead of starting a new one
91
94
  --web open the console on this run (stays up when it ends)
92
95
  --port <n> console port, with --web (default 3080)
93
96
  --no-push commit fixes to the worktree without pushing
94
- --dry-run exercise the pipeline, spawn nothing
97
+ --dry-run (internal) exercise the pipeline, spawn no agents. Always
98
+ reports clean and triages nothing, so it says whether the
99
+ plumbing runs and never whether the code is good.
95
100
 
96
101
  review-once flags
97
- --dir <path> repo/worktree the reviewers read (default .)
102
+ --dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
98
103
  --pr <url> pull request URL, recorded on the run
99
104
  --title <text> what the change does, shown in the console
100
105
  --summary <text> a few lines of intent, passed to reviewers
101
106
  --trunk <branch> diff base branch (default master)
102
107
  --round <n> round number (default: next)
103
108
  --agents a,b only these reviewers (default: all enabled)
104
- --max-rounds <n> keep going until convergence, at most n (default 1)
105
- --dry-run do not spawn anything; exercise the pipeline
109
+ --max-rounds <n> keep going until every reviewer approves, at most n (default 1)
106
110
 
107
111
  web flags
112
+ --dir <path> run-state root (default: cwd if Git, otherwise ~/.jury)
108
113
  --port <n> default 3080, walks forward if busy
109
114
  --open open a browser
110
115
 
111
- finding commands (--run <slug> picks the run)
116
+ finding commands (--dir picks state root; --run picks run)
112
117
  jury finding list
113
118
  jury finding reproduce <id> --evidence <text> [--test <text>]
114
119
  jury finding resolve <id> --verdict <${VERDICTS.join("|")}> [--reason <text>] [--test <text>]
115
120
  jury finding settled print the regenerated settled list
121
+
122
+ state commands
123
+ jury runs [--dir <path>]
124
+ jury reply [--dir <path>] [--run <slug>]
116
125
  `;
117
126
 
118
127
  let [, , cmd, ...rest] = process.argv;
@@ -147,7 +156,7 @@ try {
147
156
  case "web": await cmdWeb(rest); break;
148
157
  case "finding": case "findings": await cmdFinding(rest); break;
149
158
  case "reply": await cmdReply(rest); break;
150
- case "runs": await cmdRuns(); break;
159
+ case "runs": await cmdRuns(rest); break;
151
160
  case "agents": await cmdAgents(); break;
152
161
  case "version": case "-v": case "--version": console.log(`jury ${VERSION}`); break;
153
162
  case "help": case "-h": case "--help": case undefined:
@@ -168,11 +177,49 @@ try {
168
177
  process.exit(1);
169
178
  }
170
179
 
180
+ /** Resolve an explicit reviewer list or explain each ineligible name. */
181
+ function selectReviewers(cfg, requested, judge = null) {
182
+ const eligible = reviewers(cfg).filter((a) => a.name !== judge);
183
+ if (!requested) return eligible;
184
+
185
+ const names = [...new Set(requested.split(",").map((s) => s.trim()).filter(Boolean))];
186
+ if (!names.length) throw new Error("--agents needs at least one reviewer name");
187
+
188
+ const byName = new Map(cfg.agents.map((a) => [a.name, a]));
189
+ const allowed = new Set(eligible.map((a) => a.name));
190
+ const problems = names.filter((name) => !allowed.has(name)).map((name) => {
191
+ const agent = byName.get(name);
192
+ if (name === judge) return `"${name}" is the selected judge and cannot review its own work`;
193
+ if (agent) return `"${name}" has role "${agent.role ?? "reviewer"}", not "reviewer"`;
194
+ return `"${name}" is not configured or is disabled`;
195
+ });
196
+ if (problems.length) {
197
+ const available = eligible.map((a) => a.name).join(", ") || "none";
198
+ throw new Error(
199
+ `requested reviewer ${problems.join("; ")}. Add or enable it with role "reviewer" in ${path.basename(cfg.configFile)}. Available enabled reviewers: ${available}`,
200
+ );
201
+ }
202
+ return eligible.filter((a) => names.includes(a.name));
203
+ }
204
+
205
+ async function commandDirectory(value) {
206
+ return resolveJuryDirectory(value, {
207
+ isUsable: async (dir) => {
208
+ try {
209
+ await run("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"]);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ },
215
+ });
216
+ }
217
+
171
218
  async function cmdReview(argv) {
172
219
  const { values } = parseArgs({
173
220
  args: argv, allowPositionals: false,
174
221
  options: {
175
- dir: { type: "string", default: "." },
222
+ dir: { type: "string" },
176
223
  pr: { type: "string" },
177
224
  title: { type: "string" },
178
225
  summary: { type: "string", default: "" },
@@ -189,14 +236,9 @@ async function cmdReview(argv) {
189
236
  throw new Error("--max-rounds must be a positive integer");
190
237
  }
191
238
 
192
- const worktree = path.resolve(values.dir);
239
+ const worktree = await commandDirectory(values.dir);
193
240
  const cfg = await loadConfig(worktree);
194
- let pool = reviewers(cfg);
195
- if (values.agents) {
196
- const want = new Set(values.agents.split(",").map((s) => s.trim()));
197
- pool = pool.filter((a) => want.has(a.name));
198
- if (!pool.length) throw new Error(`no configured reviewer matches "${values.agents}"`);
199
- }
241
+ const pool = selectReviewers(cfg, values.agents);
200
242
 
201
243
  const git = await describe(worktree, values.trunk);
202
244
  if (values.pr) assertPrCheckout(values.pr, git.remote, worktree);
@@ -214,7 +256,7 @@ async function cmdReview(argv) {
214
256
  stateNote: "",
215
257
  };
216
258
 
217
- const dir = path.join(runsDir(), slugFor(target));
259
+ const dir = path.join(runsDir(worktree), slugFor(target));
218
260
  const prior = await readEvents(dir);
219
261
  const firstRound = values.round
220
262
  ? Number(values.round)
@@ -223,7 +265,7 @@ async function cmdReview(argv) {
223
265
  console.log(`target ${target.repo} ${target.id}`);
224
266
  console.log(`worktree ${worktree} @ ${git.sha}`);
225
267
  console.log(`agents ${pool.map((a) => a.name).join(", ")}${values["dry-run"] ? " (dry run)" : ""}`);
226
- if (maxRounds > 1) console.log(`rounds ${firstRound}..${firstRound + maxRounds - 1} (until convergence)`);
268
+ if (maxRounds > 1) console.log(`rounds ${firstRound}..${firstRound + maxRounds - 1} (until every reviewer approves)`);
227
269
  console.log("");
228
270
 
229
271
  for (let i = 0; i < maxRounds; i++) {
@@ -243,7 +285,7 @@ async function cmdReview(argv) {
243
285
  }
244
286
 
245
287
  /** One round: prompt, launch every reviewer, record what they said. */
246
- async function runRound({ dir, round, pool, cfg, target, worktree, values, sha }) {
288
+ async function runRound({ dir, round, pool, cfg, target, worktree, values, sha, announceCompletion = true }) {
247
289
  const git = { sha };
248
290
  console.log(st.info(st.bold(`round ${round}`)));
249
291
 
@@ -384,13 +426,17 @@ async function runRound({ dir, round, pool, cfg, target, worktree, values, sha }
384
426
  // The state the run was launched with says "review", which stops being true
385
427
  // the moment the round ends. Restate it, or the console keeps asserting a
386
428
  // review is running after it converged.
387
- const state = errored.length ? "human" : clean ? "converged" : "review";
429
+ const state = errored.length ? "human" : clean && announceCompletion ? "converged" : "review";
388
430
  const stateNote = errored.length
389
431
  ? `${errored.length} reviewer(s) failed to run`
390
432
  : clean
391
- ? `converged on ${git.sha}`
433
+ ? announceCompletion ? "Review complete" : `round ${round} clean — checking outstanding findings`
392
434
  : `round ${round} — ${raised || "unparsed"} finding(s) to triage`;
393
- await appendEvent(dir, { t: "target", target: { ...target, state, stateNote } });
435
+ const reviewedCommit = clean && !errored.length && announceCompletion ? git.sha : "";
436
+ await appendEvent(dir, {
437
+ t: "target",
438
+ target: { ...target, state, stateNote, ...(reviewedCommit ? { reviewedCommit } : {}) },
439
+ });
394
440
  await appendEvent(dir, { t: "round.end", n: round });
395
441
  await publish();
396
442
 
@@ -410,8 +456,10 @@ async function runRound({ dir, round, pool, cfg, target, worktree, values, sha }
410
456
  console.log(st.bad(`${st.count(errored.length, "reviewer")} failed to run — see the reports below.`));
411
457
  }
412
458
  console.log(clean && !errored.length
413
- ? st.ok(`CONVERGED — every reviewer emitted the stop token on ${git.sha}.`)
414
- : st.warn(`NOT converged. Triage, fix what reproduces, push, then round ${round + 1}.`));
459
+ ? announceCompletion
460
+ ? `${st.ok("REVIEW COMPLETE every reviewer approved.")}\n${st.muted(`Reviewed commit ${git.sha}.`)}`
461
+ : `${st.ok("ROUND CLEAN — every reviewer approved this commit.")}\n${st.muted(`Reviewed commit ${git.sha}.`)}`
462
+ : st.warn(`REVIEW INCOMPLETE — triage findings, fix what reproduces, push, then run round ${round + 1}.`));
415
463
  console.log(st.muted(`recorded: ${path.relative(process.cwd(), dir)}`));
416
464
 
417
465
  // Every reviewer's report, including the clean ones: a sign-off still says
@@ -446,16 +494,19 @@ async function runRound({ dir, round, pool, cfg, target, worktree, values, sha }
446
494
  * the round cap --rounds, default 10
447
495
  */
448
496
  async function cmdAgent(argv) {
497
+ const judgeFlags = argv.filter((a) => a === "--judge" || a.startsWith("--judge="));
498
+ if (judgeFlags.length > 1) throw new Error("--judge accepts exactly one agent");
449
499
  const { values, positionals } = parseArgs({
450
500
  args: argv, allowPositionals: true,
451
501
  options: {
452
- dir: { type: "string", default: "." },
502
+ dir: { type: "string" },
453
503
  pr: { type: "string" },
454
504
  title: { type: "string" },
455
505
  summary: { type: "string" },
456
506
  trunk: { type: "string" },
457
507
  rounds: { type: "string", default: "10" },
458
508
  agents: { type: "string" },
509
+ judge: { type: "string" },
459
510
  push: { type: "boolean", default: true },
460
511
  "no-push": { type: "boolean", default: false },
461
512
  resume: { type: "string" },
@@ -464,6 +515,7 @@ async function cmdAgent(argv) {
464
515
  port: { type: "string", default: "3080" },
465
516
  },
466
517
  });
518
+ if (values.judge?.includes(",")) throw new Error("--judge accepts exactly one agent, not a list");
467
519
 
468
520
  // The PR link is the argument. `--pr` still works, but a flag for the one
469
521
  // thing every invocation names is ceremony: `jury agent <url>` is what
@@ -481,9 +533,9 @@ async function cmdAgent(argv) {
481
533
  }
482
534
 
483
535
  values.push = values.push && !values["no-push"];
484
- const requestedWorktree = path.resolve(values.dir);
536
+ const requestedWorktree = await commandDirectory(values.dir);
485
537
  const resolved = values.pr
486
- ? await resolvePrCheckout(values.pr, { allowPush: values.push })
538
+ ? await resolvePrCheckout(values.pr, { allowPush: values.push, dir: requestedWorktree })
487
539
  : null;
488
540
  const worktree = resolved?.worktree ?? requestedWorktree;
489
541
  resolvedCheckoutCleanup = resolved?.cleanup ?? null;
@@ -491,21 +543,6 @@ async function cmdAgent(argv) {
491
543
  // A local ignored config belongs to the requested checkout. An automatic
492
544
  // clone intentionally starts from the repository's committed/default config.
493
545
  const cfg = await loadConfig(resolved ? worktree : requestedWorktree);
494
- const main = mainAgent(cfg);
495
- if (!main) {
496
- // A run with no main agent has no one to triage or fix — every finding
497
- // would stay open and every round would re-raise it. Better to say so than
498
- // to burn ten rounds discovering it.
499
- throw new Error('no agent has role "main" — the loop has nobody to triage or fix');
500
- }
501
-
502
- let pool = reviewers(cfg);
503
- if (values.agents) {
504
- const want = new Set(values.agents.split(",").map((s) => s.trim()));
505
- pool = pool.filter((a) => want.has(a.name));
506
- }
507
- if (!pool.length) throw new Error("no reviewers configured — nothing would review anything");
508
-
509
546
  const git = await describe(worktree);
510
547
 
511
548
  // Both asked for rather than assumed: the trunk from the remote's own HEAD,
@@ -550,20 +587,41 @@ async function cmdAgent(argv) {
550
587
  // into rounds 1-5 of a single run, and a killed run's open rounds interleaved
551
588
  // with the next one's.
552
589
  target.attempt = values.resume ? "" : attemptStamp();
590
+ const stateBase = runsDir(requestedWorktree);
553
591
  const dir = values.resume
554
- ? path.join(runsDir(), path.basename(values.resume))
555
- : path.join(runsDir(), slugFor(target));
592
+ ? path.join(stateBase, path.basename(values.resume))
593
+ : path.join(stateBase, slugFor(target));
556
594
  const prior = await readEvents(dir);
557
595
  if (values.resume && !prior.length) {
558
- throw new Error(`no run at ${path.relative(process.cwd(), dir)} — check \`jury runs\``);
596
+ throw new Error(
597
+ `no run at ${dir} — pass --dir <root-that-contains-runs>, then check \`jury runs --dir <root>\``,
598
+ );
599
+ }
600
+ const priorJudge = [...prior].reverse()
601
+ .find((e) => e.t === "target" && e.target?.judge)?.target.judge;
602
+ const requestedJudge = values.judge ?? (values.resume ? priorJudge : null);
603
+ const judge = judgeAgent(cfg, requestedJudge);
604
+ if (!judge) {
605
+ const available = cfg.agents.map((a) => a.name).join(", ") || "none";
606
+ if (requestedJudge) {
607
+ throw new Error(`judge "${requestedJudge}" is not an enabled configured agent — available: ${available}`);
608
+ }
609
+ throw new Error('no agent has role "main" — configure one or pass --judge <agent>');
559
610
  }
611
+ target.judge = judge.name;
612
+
613
+ // A judge cannot independently review its own work. Other configured main
614
+ // agents stay out of the pool rather than being silently demoted to writers
615
+ // with a read-only prompt.
616
+ const pool = selectReviewers(cfg, values.agents, judge.name);
617
+ if (!pool.length) throw new Error("no reviewers configured after excluding the judge");
560
618
  const first = Math.max(0, ...prior.filter((e) => e.t === "round.start").map((e) => e.n)) + 1;
561
619
 
562
620
  console.log(st.field("target", `${target.repo} ${st.bold(target.id)}`));
563
621
  console.log(st.field("worktree", st.muted(`${worktree} @ ${git.sha} (${resolved?.branch ?? git.branch})`)));
564
622
  console.log(st.field("trunk", `${trunk}${trunkGiven ? st.muted(" (detected)") : ""}`));
565
623
  if (target.title) console.log(st.field("title", target.title.slice(0, 72)));
566
- console.log(st.field("main", st.agent(main.name)));
624
+ console.log(st.field("judge", st.agent(judge.name)));
567
625
  console.log(st.field("agents", pool.map((a) => st.agent(a.name)).join(", ")
568
626
  + (values["dry-run"] ? st.warn(" (dry run)") : "")));
569
627
  console.log(st.field("rounds", st.muted(`${first}..${first + maxRounds - 1}, ${MAX_TURNS} turns per finding`)));
@@ -594,6 +652,9 @@ async function cmdAgent(argv) {
594
652
  const head = await describe(worktree, values.trunk);
595
653
  const converged = await runRound({
596
654
  dir, round, pool, cfg, target, worktree, values, sha: head.sha,
655
+ // A clean round is not necessarily a complete review: an earlier finding
656
+ // can still be open. This loop announces completion only after checking.
657
+ announceCompletion: false,
597
658
  });
598
659
  if (converged) {
599
660
  // Clean is not enough on its own. A finding left open by the previous
@@ -605,7 +666,11 @@ async function cmdAgent(argv) {
605
666
  // convergence.
606
667
  const open = await openFindings(dir);
607
668
  if (!open.length) {
608
- console.log(st.ok(`\nCONVERGED after ${st.count(round - first + 1, "round")}.`));
669
+ console.log(st.ok(`\nREVIEW COMPLETE after ${st.count(round - first + 1, "round")}.`));
670
+ console.log(st.muted(`Reviewed commit ${head.sha}.`));
671
+ await publishRun(dir, {
672
+ ...target, state: "converged", stateNote: "Review complete", reviewedCommit: head.sha,
673
+ });
609
674
  return;
610
675
  }
611
676
  console.log(st.warn(`\nevery reviewer is clean, but ${st.count(open.length, "finding")} from earlier ${open.length === 1 ? "is" : "are"} still open.`));
@@ -625,7 +690,7 @@ async function cmdAgent(argv) {
625
690
  return;
626
691
  }
627
692
 
628
- console.log(`\n${st.rule(`triage · ${st.count(todo.length, "finding")} → ${main.name}`)}`);
693
+ console.log(`\n${st.rule(`triage · ${st.count(todo.length, "finding")} → ${judge.name}`)}`);
629
694
  let fixed = 0;
630
695
  for (const f of todo) {
631
696
  // The turn limit ends an argument the loop cannot win. Both positions are
@@ -635,6 +700,7 @@ async function cmdAgent(argv) {
635
700
  const r = await record(dir, findings, f.id, {
636
701
  verdict: "deferred",
637
702
  reason: `argued ${MAX_TURNS} turns without agreement; both positions are in the log`,
703
+ who: judge.name,
638
704
  });
639
705
  console.log(` ${st.muted(f.id)} ${st.verdict("deferred")} ${st.muted(`(turn limit)${r.ok ? "" : ` — ${r.why}`}`)}`);
640
706
  continue;
@@ -648,10 +714,10 @@ async function cmdAgent(argv) {
648
714
  // Tagged with the reviewer whose finding this is: threads are per
649
715
  // reviewer, and a heartbeat filed under "claude" opened a claude thread
650
716
  // of its own, splitting one conversation in two.
651
- const heart = beat(dir, main.name, round, f.agent);
717
+ const heart = beat(dir, judge.name, round, f.agent);
652
718
  let v;
653
719
  try {
654
- v = await triageOne(main, f, {
720
+ v = await triageOne(judge, f, {
655
721
  worktree, trunk, stopToken: cfg.stopToken, dryRun: values["dry-run"],
656
722
  onLog: (m) => console.log(` ${st.muted(m)}`),
657
723
  });
@@ -672,6 +738,7 @@ async function cmdAgent(argv) {
672
738
  if (v.reproduced) {
673
739
  await appendEvent(dir, {
674
740
  t: "finding.reproduced", id: f.id, evidence: v.reproduced, test: v.test ?? null,
741
+ who: judge.name,
675
742
  });
676
743
  }
677
744
  // Refold BEFORE recording, not after. gate() checks the finding it is
@@ -681,7 +748,7 @@ async function cmdAgent(argv) {
681
748
  // described the right order; the code did not implement it.
682
749
  findings = await findingsIn(dir);
683
750
  const res = await record(dir, findings, f.id, {
684
- verdict: v.verdict, reason: v.reason, test: v.test,
751
+ verdict: v.verdict, reason: v.reason, test: v.test, who: judge.name,
685
752
  });
686
753
  if (!res.ok) {
687
754
  // The gate refusing is the gate working. Downgrade rather than crash:
@@ -698,6 +765,7 @@ async function cmdAgent(argv) {
698
765
  if (c?.sha) {
699
766
  await appendEvent(dir, {
700
767
  t: "commit.pushed", sha: c.sha, pushed: c.pushed, subject: `round ${round} fixes`,
768
+ who: judge.name,
701
769
  });
702
770
  console.log(`commit ${c.sha}${
703
771
  c.pushed === null ? "" : c.pushed ? ` → pushed to ${pushTarget.branch}` : " (NOT pushed)"}`);
@@ -712,7 +780,7 @@ async function cmdAgent(argv) {
712
780
  // concede or push back.
713
781
  const after = await findingsIn(dir);
714
782
  const answers = await replyRound({
715
- dir, pool, cfg, worktree, sha: head.sha, round, findings: after,
783
+ dir, pool, cfg, worktree, sha: head.sha, round, findings: after, judge: judge.name,
716
784
  sessions: sessionsIn(await readEvents(dir)),
717
785
  dryRun: values["dry-run"],
718
786
  // Prefixed like the launch line: without a name this printed a bare
@@ -739,17 +807,18 @@ async function cmdAgent(argv) {
739
807
  const stillOpen = [...settled.values()].filter((f) => f.status === "open");
740
808
  const broke = answers.filter((a) => a.failed);
741
809
  if (answers.every((a) => a.clean) && !stillOpen.length && !broke.length && !pushFailed) {
742
- console.log(st.ok(st.bold(`\nCONVERGED — every reviewer signed off after round ${round}.`)));
810
+ console.log(st.ok(st.bold(`\nREVIEW COMPLETE — every reviewer approved after round ${round}.`)));
811
+ console.log(st.muted(`Reviewed commit ${head.sha}.`));
743
812
  await publishRun(dir, {
744
- ...target, state: "converged", stateNote: `converged after round ${round}`,
813
+ ...target, state: "converged", stateNote: "Review complete", reviewedCommit: head.sha,
745
814
  });
746
815
  return;
747
816
  }
748
817
  if (stillOpen.length) {
749
- console.log(st.warn(`\n${st.count(stillOpen.length, "finding")} still open — not convergence.`));
818
+ console.log(st.warn(`\n${st.count(stillOpen.length, "finding")} still open — review incomplete.`));
750
819
  }
751
820
  if (broke.length) {
752
- console.log(st.bad(`${broke.map((a) => a.agent).join(", ")} could not run — this run cannot converge.`));
821
+ console.log(st.bad(`${broke.map((a) => a.agent).join(", ")} could not run — this review cannot complete.`));
753
822
  }
754
823
  if (pushFailed) {
755
824
  console.log(st.bad("a push failed — the reviewers are reading code the PR does not have."));
@@ -826,11 +895,16 @@ async function cleanupResolvedCheckout() {
826
895
  async function cmdWeb(argv) {
827
896
  const { values } = parseArgs({
828
897
  args: argv, allowPositionals: false,
829
- options: { port: { type: "string", default: "3080" }, open: { type: "boolean", default: false } },
898
+ options: {
899
+ dir: { type: "string" },
900
+ port: { type: "string", default: "3080" },
901
+ open: { type: "boolean", default: false },
902
+ },
830
903
  });
831
- const { url, port } = await serve({ port: Number(values.port) });
904
+ const root = await commandDirectory(values.dir);
905
+ const { url, port } = await serve({ port: Number(values.port), cwd: root });
832
906
  console.log(`console: ${url}`);
833
- console.log(`runs: ${path.relative(process.cwd(), runsDir()) || "runs"}/`);
907
+ console.log(`runs: ${runsDir(root)}/`);
834
908
  if (values.open) openBrowser(url);
835
909
  process.on("SIGINT", () => { console.log("\nstopped"); process.exit(0); });
836
910
  return new Promise(() => {}); // serve until interrupted
@@ -854,6 +928,7 @@ async function cmdFinding(argv) {
854
928
  args: rest, allowPositionals: true,
855
929
  options: {
856
930
  run: { type: "string" },
931
+ dir: { type: "string" },
857
932
  evidence: { type: "string" },
858
933
  test: { type: "string" },
859
934
  verdict: { type: "string" },
@@ -861,7 +936,10 @@ async function cmdFinding(argv) {
861
936
  },
862
937
  });
863
938
 
864
- const dir = await resolveRun(values.run);
939
+ const root = await commandDirectory(values.dir);
940
+ const dir = await resolveRun(values.run, root);
941
+ const events = await readEvents(dir);
942
+ const judge = currentJudge(events);
865
943
  const findings = await findingsIn(dir);
866
944
  const id = positionals[0];
867
945
 
@@ -881,7 +959,7 @@ async function cmdFinding(argv) {
881
959
  if (!findings.has(id)) throw new Error(`no such finding: ${id}`);
882
960
  if (!values.evidence) throw new Error("--evidence is required: what demonstrated the finding");
883
961
  await appendEvent(dir, {
884
- t: "finding.reproduced", id, evidence: values.evidence, test: values.test,
962
+ t: "finding.reproduced", id, evidence: values.evidence, test: values.test, who: judge,
885
963
  });
886
964
  await republish(dir);
887
965
  console.log(`${id}: reproduction recorded`);
@@ -895,7 +973,7 @@ async function cmdFinding(argv) {
895
973
  // survives the operator deciding to skip it.
896
974
  if (why) throw new Error(`refused: ${why}`);
897
975
  await appendEvent(dir, {
898
- t: "finding.resolved", id, verdict: values.verdict, reason: values.reason ?? "", test: values.test,
976
+ t: "finding.resolved", id, verdict: values.verdict, reason: values.reason ?? "", test: values.test, who: judge,
899
977
  });
900
978
  await republish(dir);
901
979
  console.log(`${id}: ${values.verdict}`);
@@ -927,23 +1005,21 @@ async function cmdReply(argv) {
927
1005
  args: argv, allowPositionals: false,
928
1006
  options: {
929
1007
  run: { type: "string" },
930
- dir: { type: "string", default: "." },
1008
+ dir: { type: "string" },
931
1009
  agents: { type: "string" },
932
1010
  "dry-run": { type: "boolean", default: false },
933
1011
  },
934
1012
  });
935
1013
 
936
- const cfg = await loadConfig();
937
- const dir = await resolveRun(values.run);
1014
+ const worktree = await commandDirectory(values.dir);
1015
+ const cfg = await loadConfig(worktree);
1016
+ const dir = await resolveRun(values.run, worktree);
1017
+ const events = await readEvents(dir);
1018
+ const judge = currentJudge(events);
938
1019
  const findings = await findingsIn(dir);
939
- const worktree = path.resolve(values.dir);
940
1020
  const { sha } = await describe(worktree, "master");
941
1021
 
942
- let pool = reviewers(cfg);
943
- if (values.agents) {
944
- const want = new Set(values.agents.split(",").map((s) => s.trim()));
945
- pool = pool.filter((a) => want.has(a.name));
946
- }
1022
+ let pool = selectReviewers(cfg, values.agents, judge);
947
1023
  // Only reviewers that actually said something, and only once you have
948
1024
  // answered them: a reply that says "still open" for every item is noise.
949
1025
  pool = pool.filter((a) => {
@@ -952,8 +1028,7 @@ async function cmdReply(argv) {
952
1028
  });
953
1029
  if (!pool.length) throw new Error("nothing to reply about — resolve some findings first");
954
1030
 
955
- const main = mainAgent(cfg);
956
- console.log(`main ${main?.name ?? "(unset)"}`);
1031
+ console.log(`judge ${judge}`);
957
1032
  console.log(`replying ${pool.map((a) => a.name).join(", ")} (separate conversations)`);
958
1033
  console.log("");
959
1034
 
@@ -976,7 +1051,7 @@ async function cmdReply(argv) {
976
1051
 
977
1052
  if (values["dry-run"]) return;
978
1053
 
979
- await appendEvent(dir, { t: "reply.sent", agent: a.name, sha, resumed: resumable, promptFile: file });
1054
+ await appendEvent(dir, { t: "reply.sent", agent: a.name, sha, resumed: resumable, promptFile: file, who: judge });
980
1055
  const spec = { ...a, argv: replyArgv(a, { promptText: text, worktree }).argv };
981
1056
  const replyRaw = `reply.${a.name}.stdout.txt`;
982
1057
  const sink = await openArtifact(dir, replyRaw);
@@ -996,8 +1071,8 @@ async function cmdReply(argv) {
996
1071
  }
997
1072
 
998
1073
  /** Which run a finding command applies to; unambiguous by default. */
999
- async function resolveRun(slug) {
1000
- const base = runsDir();
1074
+ async function resolveRun(slug, root = process.cwd()) {
1075
+ const base = runsDir(root);
1001
1076
  if (slug) return path.join(base, slug);
1002
1077
  const { readdir } = await import("node:fs/promises");
1003
1078
  let dirs = [];
@@ -1020,9 +1095,14 @@ async function republish(dir) {
1020
1095
  * optional, so there has to be a way to see the slugs that does not require
1021
1096
  * triggering the ambiguity error to find them out.
1022
1097
  */
1023
- async function cmdRuns() {
1098
+ async function cmdRuns(argv) {
1099
+ const { values } = parseArgs({
1100
+ args: argv, allowPositionals: false,
1101
+ options: { dir: { type: "string" } },
1102
+ });
1103
+ const root = await commandDirectory(values.dir);
1024
1104
  const { listRuns } = await import("../lib/store.js");
1025
- const { runs, skipped } = await listRuns(runsDir());
1105
+ const { runs, skipped } = await listRuns(runsDir(root));
1026
1106
  if (!runs.length) {
1027
1107
  // Report the unreadable ones even when nothing succeeded: "no runs yet"
1028
1108
  // over a directory full of broken runs is a lie that reads as "nothing was
@@ -1139,7 +1219,7 @@ async function describe(dir, trunk) {
1139
1219
  // Declarations, not const arrows: the dispatch switch above runs at module
1140
1220
  // top level, so anything it reaches must be hoisted.
1141
1221
  function repoFromUrl(u) {
1142
- return u.match(/\/([^/]+\/[^/]+)\/(?:merge_requests|pull)\//)?.[1] ?? "";
1222
+ return repositoryFromPrUrl(u)?.path ?? "";
1143
1223
  }
1144
1224
 
1145
1225
  function idFromUrl(u) {