@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 +32 -5
- package/bin/jury.js +163 -83
- package/lib/config.js +3 -2
- package/lib/directories.js +27 -0
- package/lib/findings.js +5 -1
- package/lib/loop.js +24 -10
- package/lib/repository.js +326 -9
- package/lib/store.js +5 -1
- package/package.json +1 -1
- package/web/index.html +55 -50
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
|
|
80
|
-
tree and
|
|
81
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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 =
|
|
239
|
+
const worktree = await commandDirectory(values.dir);
|
|
193
240
|
const cfg = await loadConfig(worktree);
|
|
194
|
-
|
|
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
|
|
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
|
-
? `
|
|
433
|
+
? announceCompletion ? "Review complete" : `round ${round} clean — checking outstanding findings`
|
|
392
434
|
: `round ${round} — ${raised || "unparsed"} finding(s) to triage`;
|
|
393
|
-
|
|
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
|
-
?
|
|
414
|
-
|
|
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"
|
|
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 =
|
|
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(
|
|
555
|
-
: path.join(
|
|
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(
|
|
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("
|
|
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(`\
|
|
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")} → ${
|
|
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,
|
|
717
|
+
const heart = beat(dir, judge.name, round, f.agent);
|
|
652
718
|
let v;
|
|
653
719
|
try {
|
|
654
|
-
v = await triageOne(
|
|
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(`\
|
|
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:
|
|
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 —
|
|
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
|
|
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: {
|
|
898
|
+
options: {
|
|
899
|
+
dir: { type: "string" },
|
|
900
|
+
port: { type: "string", default: "3080" },
|
|
901
|
+
open: { type: "boolean", default: false },
|
|
902
|
+
},
|
|
830
903
|
});
|
|
831
|
-
const
|
|
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: ${
|
|
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
|
|
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"
|
|
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
|
|
937
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1222
|
+
return repositoryFromPrUrl(u)?.path ?? "";
|
|
1143
1223
|
}
|
|
1144
1224
|
|
|
1145
1225
|
function idFromUrl(u) {
|