@agentsdance/codejury 0.1.0 → 0.1.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 +32 -5
- package/bin/jury.js +159 -79
- 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 +84 -2
- 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,8 +51,10 @@ 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
59
|
--dry-run exercise the pipeline, spawn nothing
|
|
57
60
|
|
|
@@ -67,7 +70,7 @@ Other commands
|
|
|
67
70
|
|
|
68
71
|
const USAGE_FULL = `jury — multi-agent code review
|
|
69
72
|
|
|
70
|
-
jury <pr-url> review a PR
|
|
73
|
+
jury <pr-url> review a PR until every reviewer approves, one conversation per reviewer
|
|
71
74
|
jury review-once [flags] a single round, no triage or reply
|
|
72
75
|
jury web [flags] serve the console (default http://127.0.0.1:3080)
|
|
73
76
|
jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
|
|
@@ -80,13 +83,14 @@ review (drives itself; no operator between rounds)
|
|
|
80
83
|
jury https://github.com/owner/repo/pull/1
|
|
81
84
|
jury --rounds 3 the current branch, no PR
|
|
82
85
|
|
|
83
|
-
--dir <path> repo/worktree
|
|
86
|
+
--dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
|
|
84
87
|
--pr <url> same as the positional argument
|
|
85
88
|
--trunk <branch> diff base branch (default: the remote's own HEAD)
|
|
86
89
|
--title <text> what the change does (default: read from the PR)
|
|
87
90
|
--summary <text> intent, passed to reviewers (default: the PR description)
|
|
88
91
|
--rounds <n> maximum rounds (default 10)
|
|
89
92
|
--agents a,b only these reviewers (default: all installed)
|
|
93
|
+
--judge <agent> one agent that triages and fixes (default: claude)
|
|
90
94
|
--resume <slug> continue an existing run instead of starting a new one
|
|
91
95
|
--web open the console on this run (stays up when it ends)
|
|
92
96
|
--port <n> console port, with --web (default 3080)
|
|
@@ -94,25 +98,30 @@ review (drives itself; no operator between rounds)
|
|
|
94
98
|
--dry-run exercise the pipeline, spawn nothing
|
|
95
99
|
|
|
96
100
|
review-once flags
|
|
97
|
-
--dir <path> repo/worktree
|
|
101
|
+
--dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
|
|
98
102
|
--pr <url> pull request URL, recorded on the run
|
|
99
103
|
--title <text> what the change does, shown in the console
|
|
100
104
|
--summary <text> a few lines of intent, passed to reviewers
|
|
101
105
|
--trunk <branch> diff base branch (default master)
|
|
102
106
|
--round <n> round number (default: next)
|
|
103
107
|
--agents a,b only these reviewers (default: all enabled)
|
|
104
|
-
--max-rounds <n> keep going until
|
|
108
|
+
--max-rounds <n> keep going until every reviewer approves, at most n (default 1)
|
|
105
109
|
--dry-run do not spawn anything; exercise the pipeline
|
|
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,7 +533,7 @@ 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
538
|
? await resolvePrCheckout(values.pr, { allowPush: values.push })
|
|
487
539
|
: 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) {
|
package/lib/config.js
CHANGED
|
@@ -147,8 +147,9 @@ export async function loadConfig(dir = process.cwd()) {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
/**
|
|
151
|
-
export function
|
|
150
|
+
/** Select the agent that judges findings and owns the working tree. */
|
|
151
|
+
export function judgeAgent(cfg, name) {
|
|
152
|
+
if (name) return cfg.agents.find((a) => a.name === name) ?? null;
|
|
152
153
|
return cfg.main ?? null;
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// One directory policy for every command. An explicit path wins; an empty path
|
|
2
|
+
// or an unsuitable implicit cwd falls back to ~/.jury and creates it.
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export function expandDirectory(value, { cwd = process.cwd(), home = homedir() } = {}) {
|
|
8
|
+
const raw = String(value ?? "").trim();
|
|
9
|
+
if (raw === "~") return home;
|
|
10
|
+
if (/^~[\\/]/.test(raw)) return path.join(home, raw.slice(2));
|
|
11
|
+
return path.resolve(cwd, raw);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveJuryDirectory(
|
|
15
|
+
value,
|
|
16
|
+
{ cwd = process.cwd(), home = homedir(), isUsable = async () => true } = {},
|
|
17
|
+
) {
|
|
18
|
+
if (typeof value === "string" && value.trim()) {
|
|
19
|
+
return expandDirectory(value, { cwd, home });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (value === undefined && await isUsable(cwd)) return path.resolve(cwd);
|
|
23
|
+
|
|
24
|
+
const fallback = path.join(home, ".jury");
|
|
25
|
+
await mkdir(fallback, { recursive: true });
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
package/lib/findings.js
CHANGED
|
@@ -11,8 +11,12 @@ export const VERDICTS = ["accepted", "deferred", "rejected", "superseded"];
|
|
|
11
11
|
/** Fold the log down to one record per finding id. */
|
|
12
12
|
export async function findingsIn(dir) {
|
|
13
13
|
const byId = new Map();
|
|
14
|
+
let judge = "claude"; // Backward compatibility for runs without judge metadata.
|
|
14
15
|
for (const e of await readEvents(dir)) {
|
|
15
16
|
switch (e.t) {
|
|
17
|
+
case "target":
|
|
18
|
+
judge = e.target?.judge ?? judge;
|
|
19
|
+
break;
|
|
16
20
|
case "finding.raised":
|
|
17
21
|
byId.set(e.id, {
|
|
18
22
|
id: e.id, round: e.round, agent: e.agent, claim: e.claim,
|
|
@@ -56,7 +60,7 @@ export async function findingsIn(dir) {
|
|
|
56
60
|
f.turns = (f.turns ?? 0) + 1;
|
|
57
61
|
// Only a reviewer pushing back re-opens the argument; our own turn in
|
|
58
62
|
// the thread is not a rebuttal against ourselves.
|
|
59
|
-
if (e.who && e.who !==
|
|
63
|
+
if (e.who && e.who !== judge) f.contested = true;
|
|
60
64
|
}
|
|
61
65
|
break;
|
|
62
66
|
}
|
package/lib/loop.js
CHANGED
|
@@ -23,6 +23,15 @@ import { appendEvent, writeArtifact } from "./store.js";
|
|
|
23
23
|
// recorded rather than resolved by fiat.
|
|
24
24
|
export const MAX_TURNS = 3;
|
|
25
25
|
|
|
26
|
+
/** The judge in force at the end of a run, with a legacy-run fallback. */
|
|
27
|
+
export function currentJudge(events) {
|
|
28
|
+
let judge = "claude";
|
|
29
|
+
for (const e of events) {
|
|
30
|
+
if (e.t === "target" && e.target?.judge) judge = e.target.judge;
|
|
31
|
+
}
|
|
32
|
+
return judge;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
/**
|
|
27
36
|
* One finding's state across the whole run, which is what decides whether the
|
|
28
37
|
* loop may stop. `turns` counts round-trips on this specific claim, not rounds.
|
|
@@ -70,9 +79,13 @@ export function conversation(events) {
|
|
|
70
79
|
return t;
|
|
71
80
|
};
|
|
72
81
|
const claims = new Map();
|
|
82
|
+
let judge = "claude"; // Runs created before --judge existed were Claude-led.
|
|
73
83
|
|
|
74
84
|
for (const e of events) {
|
|
75
85
|
switch (e.t) {
|
|
86
|
+
case "target":
|
|
87
|
+
judge = e.target?.judge ?? judge;
|
|
88
|
+
break;
|
|
76
89
|
case "finding.raised":
|
|
77
90
|
// Deliberately NOT a turn of its own. The reviewer already said this in
|
|
78
91
|
// its report, verbatim and in its own shape; a parsed copy underneath
|
|
@@ -87,14 +100,14 @@ export function conversation(events) {
|
|
|
87
100
|
break;
|
|
88
101
|
case "finding.reproduced":
|
|
89
102
|
thread(claims.get(e.id)?.agent ?? "?").turns.push({
|
|
90
|
-
who:
|
|
103
|
+
who: e.who ?? judge, kind: "reproduced", id: e.id,
|
|
91
104
|
claim: claims.get(e.id)?.claim ?? "",
|
|
92
105
|
text: typeof e.evidence === "string" ? e.evidence : "reproduced", ts: e.ts,
|
|
93
106
|
});
|
|
94
107
|
break;
|
|
95
108
|
case "finding.resolved":
|
|
96
109
|
thread(claims.get(e.id)?.agent ?? "?").turns.push({
|
|
97
|
-
who:
|
|
110
|
+
who: e.who ?? judge, kind: "verdict", id: e.id,
|
|
98
111
|
claim: claims.get(e.id)?.claim ?? "",
|
|
99
112
|
verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts,
|
|
100
113
|
});
|
|
@@ -116,7 +129,7 @@ export function conversation(events) {
|
|
|
116
129
|
who, kind: "rebuttal", id: e.id,
|
|
117
130
|
claim: claims.get(e.id)?.claim ?? "", ts: e.ts,
|
|
118
131
|
};
|
|
119
|
-
if (who ===
|
|
132
|
+
if (who === judge) turn.text = e.text ?? "";
|
|
120
133
|
thread(claims.get(e.id)?.agent ?? e.agent ?? "?").turns.push(turn);
|
|
121
134
|
}
|
|
122
135
|
break;
|
|
@@ -179,7 +192,7 @@ export function conversation(events) {
|
|
|
179
192
|
break;
|
|
180
193
|
case "reply.sent":
|
|
181
194
|
thread(e.agent).turns.push({
|
|
182
|
-
who:
|
|
195
|
+
who: e.who ?? judge, kind: "reply", text: e.text ?? "", resumed: e.resumed, ts: e.ts,
|
|
183
196
|
});
|
|
184
197
|
break;
|
|
185
198
|
case "reply.answered":
|
|
@@ -190,7 +203,7 @@ export function conversation(events) {
|
|
|
190
203
|
break;
|
|
191
204
|
case "commit.pushed":
|
|
192
205
|
for (const t of threads.values()) {
|
|
193
|
-
t.turns.push({ who:
|
|
206
|
+
t.turns.push({ who: e.who ?? judge, kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
|
|
194
207
|
}
|
|
195
208
|
break;
|
|
196
209
|
}
|
|
@@ -208,7 +221,7 @@ export function conversation(events) {
|
|
|
208
221
|
* records the reproduction attempt *before* the verdict, so an "accepted" with
|
|
209
222
|
* nothing behind it is refused by findings.gate rather than believed.
|
|
210
223
|
*/
|
|
211
|
-
export async function triage(finding, { ask, dir, worktree, round }) {
|
|
224
|
+
export async function triage(finding, { ask, dir, worktree, round, judge }) {
|
|
212
225
|
const verdict = await ask({ kind: "triage", finding, worktree, round });
|
|
213
226
|
// Order matters. gate() reads the folded log, so the reproduction has to be
|
|
214
227
|
// on disk before the resolve is checked against it.
|
|
@@ -216,6 +229,7 @@ export async function triage(finding, { ask, dir, worktree, round }) {
|
|
|
216
229
|
await appendEvent(dir, {
|
|
217
230
|
t: "finding.reproduced", id: finding.id,
|
|
218
231
|
evidence: verdict.reproduced, test: verdict.test ?? null,
|
|
232
|
+
...(judge ? { who: judge } : {}),
|
|
219
233
|
});
|
|
220
234
|
}
|
|
221
235
|
return verdict;
|
|
@@ -229,11 +243,11 @@ export async function triage(finding, { ask, dir, worktree, round }) {
|
|
|
229
243
|
* open finding again, which the next round will re-raise. Crashing here would
|
|
230
244
|
* throw away a whole round of reviewer time over one badly-formed answer.
|
|
231
245
|
*/
|
|
232
|
-
export async function record(dir, findings, id, { verdict, reason, test }) {
|
|
246
|
+
export async function record(dir, findings, id, { verdict, reason, test, who }) {
|
|
233
247
|
const f = findings.get(id);
|
|
234
248
|
const why = gate(f, { verdict, test });
|
|
235
249
|
if (why) return { ok: false, why };
|
|
236
|
-
await appendEvent(dir, { t: "finding.resolved", id, verdict, reason: reason ?? "", test: test ?? null });
|
|
250
|
+
await appendEvent(dir, { t: "finding.resolved", id, verdict, reason: reason ?? "", test: test ?? null, ...(who ? { who } : {}) });
|
|
237
251
|
return { ok: true };
|
|
238
252
|
}
|
|
239
253
|
|
|
@@ -278,7 +292,7 @@ export async function refreshSettled(dir) {
|
|
|
278
292
|
* not, and its rebuttal is recorded against the findings it concerns so the
|
|
279
293
|
* turn counter can eventually end the argument.
|
|
280
294
|
*/
|
|
281
|
-
export async function replyRound({ dir, pool, cfg, worktree, sha, round, findings, sessions, dryRun, onLog, onChunk }) {
|
|
295
|
+
export async function replyRound({ dir, pool, cfg, worktree, sha, round, findings, sessions, dryRun, judge, onLog, onChunk }) {
|
|
282
296
|
const names = pool.map((a) => a.name);
|
|
283
297
|
const out = await Promise.all(pool.map(async (a) => {
|
|
284
298
|
const thread = threadFor(a.name, findings);
|
|
@@ -298,7 +312,7 @@ export async function replyRound({ dir, pool, cfg, worktree, sha, round, finding
|
|
|
298
312
|
// lands in that conversation rather than whichever ran most recently.
|
|
299
313
|
const sessionId = sessions?.get(a.name) ?? null;
|
|
300
314
|
const { argv, resumed } = replyArgv(a, { promptText: text, worktree, sha, sessionId });
|
|
301
|
-
await appendEvent(dir, { t: "reply.sent", agent: a.name, resumed, sessionId, text });
|
|
315
|
+
await appendEvent(dir, { t: "reply.sent", agent: a.name, resumed, sessionId, text, ...(judge ? { who: judge } : {}) });
|
|
302
316
|
|
|
303
317
|
const r = await runAgent({ ...a, argv }, {
|
|
304
318
|
worktree, prompt: text, stopToken: cfg.stopToken, dryRun,
|
package/lib/repository.js
CHANGED
|
@@ -89,6 +89,81 @@ function githubNumber(prUrl) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
function mergeRequestNumber(prUrl) {
|
|
93
|
+
try {
|
|
94
|
+
const parts = new URL(prUrl).pathname.split("/").filter(Boolean);
|
|
95
|
+
const marker = parts.indexOf("merge_requests");
|
|
96
|
+
return marker >= 0 && /^\d+$/.test(parts[marker + 1] ?? "") ? parts[marker + 1] : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cloneUrlForMergeRequest(prUrl, repoPath) {
|
|
103
|
+
const url = new URL(prUrl);
|
|
104
|
+
url.pathname = `/${repoPath}.git`;
|
|
105
|
+
url.search = "";
|
|
106
|
+
url.hash = "";
|
|
107
|
+
return url.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function branchAtHead(stdout, sha) {
|
|
111
|
+
const branches = String(stdout ?? "").split("\n").flatMap((line) => {
|
|
112
|
+
const [hash, ref] = line.trim().split(/\s+/, 2);
|
|
113
|
+
return hash === sha && ref?.startsWith("refs/heads/") ? [ref.slice(11)] : [];
|
|
114
|
+
});
|
|
115
|
+
return branches.length === 1 ? branches[0] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class MergeRequestCheckoutError extends Error {}
|
|
119
|
+
|
|
120
|
+
async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
121
|
+
allowPush, exec, makeTemp, remove,
|
|
122
|
+
}) {
|
|
123
|
+
const worktree = await makeTemp(path.join(tmpdir(), `jury-mr-${number}-`));
|
|
124
|
+
try {
|
|
125
|
+
await exec("git", ["clone", "--quiet", "--no-checkout", cloneUrlForMergeRequest(prUrl, target.path), worktree]);
|
|
126
|
+
await exec("git", ["fetch", "--quiet", "origin", `refs/merge-requests/${number}/head`], { cwd: worktree });
|
|
127
|
+
await exec("git", ["checkout", "--quiet", "-b", `jury-mr-${number}`, "FETCH_HEAD"], { cwd: worktree });
|
|
128
|
+
|
|
129
|
+
const { stdout: actualOut } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
|
|
130
|
+
const sha = actualOut.trim();
|
|
131
|
+
const { stdout: trunkOut } = await exec(
|
|
132
|
+
"git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: worktree },
|
|
133
|
+
);
|
|
134
|
+
const trunk = trunkOut.trim().replace(/^origin\//, "");
|
|
135
|
+
if (!trunk) throw new MergeRequestCheckoutError(
|
|
136
|
+
`merge request !${number} repository has no default branch`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const { stdout: headsOut } = await exec("git", ["ls-remote", "--heads", "origin"], { cwd: worktree });
|
|
140
|
+
const branch = branchAtHead(headsOut, sha);
|
|
141
|
+
if (allowPush && !branch) {
|
|
142
|
+
throw new MergeRequestCheckoutError(
|
|
143
|
+
`resolved merge request !${number}, but its source branch is not uniquely available on origin — ` +
|
|
144
|
+
"use --no-push for a read-only review, or check out the source branch and pass --dir",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
worktree,
|
|
150
|
+
state: "OPEN",
|
|
151
|
+
trunk,
|
|
152
|
+
branch: branch ?? `merge-request/${number}`,
|
|
153
|
+
sha,
|
|
154
|
+
pushTarget: branch ? { remote: "origin", branch } : null,
|
|
155
|
+
cleanup: () => remove(worktree),
|
|
156
|
+
};
|
|
157
|
+
} catch (err) {
|
|
158
|
+
await remove(worktree);
|
|
159
|
+
if (err instanceof MergeRequestCheckoutError) throw err;
|
|
160
|
+
throw new Error(
|
|
161
|
+
`could not resolve ${target.display} merge request !${number}: ${String(err.message).split("\n")[0]} — ` +
|
|
162
|
+
"check Git authentication and refs/merge-requests support, or check out the source branch and pass --dir",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
92
167
|
function remoteForRepository(origin, repoPath) {
|
|
93
168
|
const scp = String(origin).match(/^((?:[^@/\s]+@)?[^:/\s]+):(.+)$/);
|
|
94
169
|
if (scp && !/^[a-z][a-z\d+.-]*:\/\//i.test(origin)) return `${scp[1]}:${repoPath}.git`;
|
|
@@ -100,7 +175,7 @@ function remoteForRepository(origin, repoPath) {
|
|
|
100
175
|
}
|
|
101
176
|
|
|
102
177
|
/**
|
|
103
|
-
* Resolve a GitHub PR to an isolated checkout of its exact head.
|
|
178
|
+
* Resolve a GitHub PR or GitLab-style MR to an isolated checkout of its exact head.
|
|
104
179
|
*
|
|
105
180
|
* The checkout is a clone rather than a mutation of the caller's repository:
|
|
106
181
|
* running `jury <url>` from master (or from an unrelated repo) must not switch
|
|
@@ -115,10 +190,17 @@ export async function resolvePrCheckout(prUrl, {
|
|
|
115
190
|
} = {}) {
|
|
116
191
|
const target = repositoryFromPrUrl(prUrl);
|
|
117
192
|
const number = githubNumber(prUrl);
|
|
193
|
+
const mrNumber = mergeRequestNumber(prUrl);
|
|
194
|
+
if (target && mrNumber) {
|
|
195
|
+
return resolveMergeRequestCheckout(prUrl, target, mrNumber, {
|
|
196
|
+
allowPush, exec, makeTemp, remove,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
118
199
|
if (!target || !number) {
|
|
119
200
|
throw new Error(
|
|
120
201
|
`cannot resolve the PR head from "${prUrl}" automatically — ` +
|
|
121
|
-
"
|
|
202
|
+
"supported URL forms are GitHub /pull/<id> and GitLab-style /merge_requests/<id>; " +
|
|
203
|
+
"otherwise run from its checked-out branch with --dir",
|
|
122
204
|
);
|
|
123
205
|
}
|
|
124
206
|
|
package/lib/store.js
CHANGED
|
@@ -183,6 +183,9 @@ export function foldEvents(events, seed = {}) {
|
|
|
183
183
|
const lanes = new Map(); // agent -> lane, kept in launch order
|
|
184
184
|
let t0 = null;
|
|
185
185
|
let lastMin = 0;
|
|
186
|
+
// Events are replayed in order. Starting from the seed's latest judge would
|
|
187
|
+
// misattribute old Claude turns when a resumed run switches judges.
|
|
188
|
+
let judge = "claude";
|
|
186
189
|
|
|
187
190
|
const laneFor = (agent) => {
|
|
188
191
|
let lane = lanes.get(agent);
|
|
@@ -248,7 +251,7 @@ export function foldEvents(events, seed = {}) {
|
|
|
248
251
|
case "reply.sent":
|
|
249
252
|
for (const f of findings.values()) {
|
|
250
253
|
if (f.with === e.agent && f.res !== "open") {
|
|
251
|
-
f.turns.push({ who:
|
|
254
|
+
f.turns.push({ who: e.who ?? judge, kind: "reply", resumed: e.resumed, at: min });
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
257
|
break;
|
|
@@ -268,6 +271,7 @@ export function foldEvents(events, seed = {}) {
|
|
|
268
271
|
}
|
|
269
272
|
case "target":
|
|
270
273
|
run.target = { ...run.target, ...e.target };
|
|
274
|
+
judge = e.target?.judge ?? judge;
|
|
271
275
|
break;
|
|
272
276
|
}
|
|
273
277
|
}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
|
-
<title>Code Jury —
|
|
2
|
+
<title>Code Jury — Review Console</title>
|
|
3
3
|
<style>
|
|
4
4
|
:root {
|
|
5
5
|
--ground:#F4F6F8; --surface:#FFFFFF; --surface-alt:#EDF0F4;
|
|
@@ -237,7 +237,7 @@
|
|
|
237
237
|
.io { background:var(--t-bg); border:1px solid var(--t-line); border-radius:6px;
|
|
238
238
|
overflow:hidden; box-shadow:var(--shadow); font-family:var(--mono); }
|
|
239
239
|
.io-body { padding:13px 16px 18px; font-size:12.6px; line-height:1.6; color:var(--t-fg);
|
|
240
|
-
|
|
240
|
+
overflow-x:auto; }
|
|
241
241
|
|
|
242
242
|
/* One column per reviewer. They are side by side to make the independence
|
|
243
243
|
legible at a glance; below the breakpoint they stack, which is the same
|
|
@@ -258,7 +258,6 @@
|
|
|
258
258
|
.convo-switch .pr-chip[aria-pressed="true"]:hover { color:#fff; }
|
|
259
259
|
.convo-switch .pr-chip[aria-pressed="false"] { opacity:.7; }
|
|
260
260
|
.convo-note { margin:0 0 10px; font-size:12.5px; color:var(--muted); }
|
|
261
|
-
.convo .io-body { max-height:none; }
|
|
262
261
|
/* A long unbroken report must scroll inside its own column rather than
|
|
263
262
|
widening the grid and pushing the other conversation off-screen. */
|
|
264
263
|
.convo .stream { white-space:pre-wrap; overflow-wrap:anywhere; }
|
|
@@ -362,12 +361,12 @@
|
|
|
362
361
|
.convo > header .d { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; }
|
|
363
362
|
.convo > header .d[data-live="1"] { background:var(--warn); animation:blip 1.4s ease-in-out infinite; }
|
|
364
363
|
@keyframes blip { 0%,100%{opacity:1} 50%{opacity:.25} }
|
|
365
|
-
/*
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
/* Completed threads grow with their content. The page owns vertical
|
|
365
|
+
scrolling; neither the thread nor an individual message hides findings in
|
|
366
|
+
a nested scroll region. */
|
|
368
367
|
.msgs { padding:14px 16px; display:flex; flex-direction:column; gap:10px;
|
|
369
|
-
|
|
370
|
-
.convo-grid[data-stack="1"] .msgs {
|
|
368
|
+
min-height:min(34em, 58vh); }
|
|
369
|
+
.convo-grid[data-stack="1"] .msgs { min-height:0; }
|
|
371
370
|
/* Wider than before: bubbles now carry the reviewer's report in full, and a
|
|
372
371
|
narrow column turns a report into a ribbon. */
|
|
373
372
|
.msg { max-width:min(88%, 940px); display:flex; flex-direction:column; gap:4px; min-width:0; }
|
|
@@ -437,9 +436,8 @@
|
|
|
437
436
|
.bub[data-k="streaming"]::after {
|
|
438
437
|
content:"▍"; animation:blip 1s steps(2) infinite; color:var(--accent);
|
|
439
438
|
}
|
|
440
|
-
/*
|
|
441
|
-
|
|
442
|
-
without a control to click. */
|
|
439
|
+
/* Only a live transcript is compact. Once the report completes, it is
|
|
440
|
+
rendered directly in the conversation at full height. */
|
|
443
441
|
.bub .tall { max-height:26em; overflow-y:auto; overscroll-behavior:contain;
|
|
444
442
|
scrollbar-width:thin; border-radius:3px;
|
|
445
443
|
background:
|
|
@@ -522,7 +520,8 @@ let RUN = {
|
|
|
522
520
|
url: "https://github.com/acme/worker-pool/pull/128",
|
|
523
521
|
title: "Grow the CreateSandbox retry wait on repeated failures",
|
|
524
522
|
branch: "fix/sandbox-manager-create-backoff", trunk: "master",
|
|
525
|
-
|
|
523
|
+
judge: "claude",
|
|
524
|
+
diffstat: "2 files · +418 −13", state: "merged", stateNote: "Merged", reviewedCommit: "ae473ab",
|
|
526
525
|
summary: "Two reviewers — codex and droid — read each pushed commit independently. The main agent triages what they find, fixes what survives verification, answers every finding including the ones it rejects, and pushes again. Three rounds until both reported nothing new."
|
|
527
526
|
},
|
|
528
527
|
totalMin: 100,
|
|
@@ -594,7 +593,7 @@ let RUN = {
|
|
|
594
593
|
{ who:"codex", ts:"round 1 · T+51m", body:"Accepted. The producer reservation code is byte-for-byte outside this commit's diff, so the issue is pre-existing. The new backoff does not materially widen the concurrent-producer window; cancellation-aware worker exit arguably reduces old/new manager overlap. I agree it belongs in a separate PR." } ] }
|
|
595
594
|
],
|
|
596
595
|
ship: [
|
|
597
|
-
{ n:"3", k:"rounds", s:"
|
|
596
|
+
{ n:"3", k:"rounds", s:"until complete" },
|
|
598
597
|
{ n:"5", k:"exchanges", s:"3 fixed · 1 deferred · 1 rejected" },
|
|
599
598
|
{ n:"11", k:"turns", s:"every finding answered" },
|
|
600
599
|
{ n:"0", k:"prod bugs", s:"found in these 3 rounds" },
|
|
@@ -760,7 +759,7 @@ function draw(){
|
|
|
760
759
|
${(x.turns ?? []).map(tt => `
|
|
761
760
|
<div class="ln" data-who="${tt.who}">
|
|
762
761
|
<span class="at">${when(tt)}</span>
|
|
763
|
-
<span class="nm">${icon(tt.who)}${esc(tt.who)}</span>
|
|
762
|
+
<span class="nm"${tt.who === judgeName() ? ' style="color:var(--t-claude)"' : ""}>${icon(tt.who)}${esc(tt.who)}</span>
|
|
764
763
|
<span class="say">${rich(tt.body)}
|
|
765
764
|
${tt.quote ? `<div class="q">${esc(tt.quote)}</div>` : ""}
|
|
766
765
|
${tt.ask ? `<div class="ask">${esc(tt.ask)}</div>` : ""}
|
|
@@ -796,7 +795,7 @@ function draw(){
|
|
|
796
795
|
return `
|
|
797
796
|
<div class="xgroup">
|
|
798
797
|
<div class="rule" data-who="${esc(agent)}">
|
|
799
|
-
<span class="lbl">${icon(agent)}
|
|
798
|
+
<span class="lbl">${icon(agent)}${esc(judgeName())} ⇄ ${esc(agent)}</span><i></i>
|
|
800
799
|
<span class="rt">${mine.length} of ${shown} finding(s)</span>
|
|
801
800
|
</div>
|
|
802
801
|
${mine.map(card).join("")}
|
|
@@ -845,9 +844,11 @@ function renderHeader(){
|
|
|
845
844
|
const branch = t.branch ? `<span>${esc(t.branch)} → ${esc(t.trunk ?? "master")}</span>` : "";
|
|
846
845
|
const stat = t.diffstat ? `<span>${esc(t.diffstat)}</span>` : "";
|
|
847
846
|
const cls = BADGE[t.state] ?? "badge-mute";
|
|
847
|
+
const commit = reviewedCommit(t);
|
|
848
848
|
const badge = t.state
|
|
849
|
-
? `<span class="${cls}"><span class="d"></span>${esc(t
|
|
850
|
-
|
|
849
|
+
? `<span class="${cls}"><span class="d"></span>${esc(statusLabel(t))}</span>` : "";
|
|
850
|
+
const reviewed = commit ? `<span>Reviewed commit <code>${esc(commit)}</code></span>` : "";
|
|
851
|
+
$("t-meta").innerHTML = [link, branch, stat, badge, reviewed].filter(Boolean).join("");
|
|
851
852
|
|
|
852
853
|
// Only worth showing when there is something to switch between.
|
|
853
854
|
//
|
|
@@ -934,7 +935,7 @@ function runSummary(r) {
|
|
|
934
935
|
const t = r.target ?? {};
|
|
935
936
|
if (isLive(r) && t.stateNote) return t.stateNote;
|
|
936
937
|
const n = (r.exchanges ?? []).length;
|
|
937
|
-
if (t.state === "converged") return "
|
|
938
|
+
if (t.state === "converged") return "Review complete";
|
|
938
939
|
const found = n ? `${n} finding${n === 1 ? "" : "s"}` : "";
|
|
939
940
|
// A stale "review" is a run that stopped without finishing. Saying "review"
|
|
940
941
|
// would repeat the claim that misled the grouping in the first place.
|
|
@@ -942,6 +943,17 @@ function runSummary(r) {
|
|
|
942
943
|
return found || t.stateNote || t.state || "";
|
|
943
944
|
}
|
|
944
945
|
|
|
946
|
+
function reviewedCommit(t) {
|
|
947
|
+
if (t?.reviewedCommit) return t.reviewedCommit;
|
|
948
|
+
return (t?.stateNote?.match(/\bconverged on ([0-9a-f]{7,40})\b/i)?.[1] ?? "").toLowerCase();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function statusLabel(t) {
|
|
952
|
+
if (t?.state === "converged") return "Review complete";
|
|
953
|
+
if (t?.state === "merged" && /converged/i.test(t.stateNote ?? "")) return "Merged";
|
|
954
|
+
return t?.stateNote || t?.state || "";
|
|
955
|
+
}
|
|
956
|
+
|
|
945
957
|
function drawSwitcher() {
|
|
946
958
|
const el = $("switcher");
|
|
947
959
|
if (!el) return;
|
|
@@ -1125,14 +1137,15 @@ function render(){
|
|
|
1125
1137
|
folded run; a conversation needs the events in the order they were spoken,
|
|
1126
1138
|
and folding throws that order away.
|
|
1127
1139
|
|
|
1128
|
-
Side is fixed by speaker, never by turn order:
|
|
1140
|
+
Side is fixed by speaker, never by turn order: judge left, reviewer right.
|
|
1129
1141
|
A reader should be able to tell who is talking from across the room. */
|
|
1130
1142
|
let THREADS = []; // [{agent, turns:[…]}]
|
|
1131
1143
|
let CONVO_STACK = false; // one thread full width, or every thread stacked
|
|
1132
1144
|
let CONVO_PICK = null; // which thread, when not stacked
|
|
1133
1145
|
const CONVO_SHUT = new Set(); // collapsed threads, by agent, while stacked
|
|
1134
1146
|
|
|
1135
|
-
const
|
|
1147
|
+
const judgeName = () => RUN.target?.judge ?? "claude";
|
|
1148
|
+
const SIDE = (who) => (who === judgeName() ? "l" : "r");
|
|
1136
1149
|
|
|
1137
1150
|
// A stable colour per agent. codex and droid have tokens already; anything else
|
|
1138
1151
|
// is hashed to a hue so a five-reviewer run does not render five identical
|
|
@@ -1155,22 +1168,12 @@ const VERDICT_TEXT = {
|
|
|
1155
1168
|
superseded: "Superseded",
|
|
1156
1169
|
};
|
|
1157
1170
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1171
|
+
// A live transcript can be thousands of lines before it becomes a completed
|
|
1172
|
+
// report. Keep only that in-progress state compact; completed messages expand.
|
|
1160
1173
|
const CLAMP = 700;
|
|
1161
|
-
/* The collapsing half of a bubble, on its own so that bubbles which lead with a
|
|
1162
|
-
header — a verdict, a named claim — can clamp their body without clamping the
|
|
1163
|
-
header along with it. */
|
|
1164
1174
|
/**
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
* The reason a clamp existed is still real — evidence arrives verbatim from
|
|
1169
|
-
* `jury finding reproduce --evidence` and is routinely thousands of lines, and
|
|
1170
|
-
* one such turn inlined at full height buries every turn after it. So a long
|
|
1171
|
-
* message scrolls WITHIN ITS OWN BOX instead of being cut: the whole text is in
|
|
1172
|
-
* the DOM, selectable and searchable with the browser's own find, while the
|
|
1173
|
-
* thread below it stays reachable. Bounded height, not bounded content.
|
|
1175
|
+
* Compact an in-progress transcript without truncating its DOM content.
|
|
1176
|
+
* Completed reports bypass this helper and expand to their full height.
|
|
1174
1177
|
*/
|
|
1175
1178
|
function clamped(text) {
|
|
1176
1179
|
const t = text ?? "";
|
|
@@ -1179,7 +1182,7 @@ function clamped(text) {
|
|
|
1179
1182
|
aria-label="long message, ${bytes(t.length)}, scrollable">${esc(t)}</div>`;
|
|
1180
1183
|
}
|
|
1181
1184
|
function bubble(text, k, attrs = "") {
|
|
1182
|
-
return `<div class="bub" data-k="${k}" ${attrs}>${
|
|
1185
|
+
return `<div class="bub" data-k="${k}" ${attrs}>${esc(text ?? "")}</div>`;
|
|
1183
1186
|
}
|
|
1184
1187
|
/** m:ss, or h:mm:ss once a reviewer has been going that long. */
|
|
1185
1188
|
function hms(sec) {
|
|
@@ -1235,19 +1238,17 @@ function stampFor(t) {
|
|
|
1235
1238
|
|
|
1236
1239
|
function turnHTML(t, agent) {
|
|
1237
1240
|
const side = SIDE(t.who);
|
|
1238
|
-
const nm = t.who ===
|
|
1241
|
+
const nm = t.who === judgeName() ? judgeName() : agent;
|
|
1239
1242
|
let body = "";
|
|
1240
1243
|
switch (t.kind) {
|
|
1241
1244
|
case "finding":
|
|
1242
1245
|
body = `<div class="bub" data-k="finding"><span class="hd">${esc(t.claim ?? "")}</span
|
|
1243
|
-
>${t.loc ? `<span class="loc">${esc(t.loc)}</span>\n` : ""}${esc(
|
|
1246
|
+
>${t.loc ? `<span class="loc">${esc(t.loc)}</span>\n` : ""}${esc(t.body ?? "")}</div>`;
|
|
1244
1247
|
break;
|
|
1245
1248
|
case "reproduced":
|
|
1246
|
-
// Evidence is
|
|
1247
|
-
// --evidence`, which is routinely a wall of captured command output. It
|
|
1248
|
-
// collapses, or one reproduction buries every turn below it.
|
|
1249
|
+
// Evidence is shown inline in full once reproduction has completed.
|
|
1249
1250
|
body = `<div class="bub" data-k="reproduced"
|
|
1250
|
-
>${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}Reproduced. ${
|
|
1251
|
+
>${t.claim ? `<span class="re">${esc(t.claim)}</span>\n` : ""}Reproduced. ${esc(t.text ?? "")}</div>`;
|
|
1251
1252
|
break;
|
|
1252
1253
|
case "verdict":
|
|
1253
1254
|
// The claim is named here because the findings are no longer bubbles of
|
|
@@ -1262,7 +1263,7 @@ function turnHTML(t, agent) {
|
|
|
1262
1263
|
// being re-argued, so it names the claim and leaves the words to the
|
|
1263
1264
|
// report. Claude's turn in the thread does carry text, and shows it.
|
|
1264
1265
|
body = `<div class="bub" data-k="rebuttal"
|
|
1265
|
-
><span class="hd">${t.who ===
|
|
1266
|
+
><span class="hd">${t.who === judgeName() ? "still open" : "not convinced"}</span
|
|
1266
1267
|
>${t.claim ? `<span class="re">${esc(t.claim)}</span>` : ""}${t.text ? `\n${esc(t.text)}` : ""}</div>`;
|
|
1267
1268
|
break;
|
|
1268
1269
|
case "commit":
|
|
@@ -1299,7 +1300,7 @@ function turnHTML(t, agent) {
|
|
|
1299
1300
|
body = bubble(t.text ?? "", esc(t.kind ?? "turn"));
|
|
1300
1301
|
}
|
|
1301
1302
|
return `<div class="msg" data-side="${side}"><span class="nm"
|
|
1302
|
-
><span class="ic" style="color:${nm ===
|
|
1303
|
+
><span class="ic" style="color:${nm === judgeName() ? "var(--main-hue)" : hueFor(nm)}">${icon(nm)}</span>${esc(nm)}${stampFor(t)}</span>${body}</div>`;
|
|
1303
1304
|
}
|
|
1304
1305
|
|
|
1305
1306
|
/**
|
|
@@ -1380,7 +1381,7 @@ function drawConvo() {
|
|
|
1380
1381
|
return `<div class="convo" data-a="${esc(t.agent)}" data-open="${open ? 1 : 0}">
|
|
1381
1382
|
<header data-fold-agent="${esc(t.agent)}"><span class="tw">${open ? "▾" : "▸"}</span
|
|
1382
1383
|
><span class="d" data-live="${l ? 1 : 0}"></span>
|
|
1383
|
-
<span class="who"><span class="ic" style="color:var(--main-hue)">${icon(
|
|
1384
|
+
<span class="who"><span class="ic" style="color:var(--main-hue)">${icon(judgeName())}</span>${esc(judgeName())}
|
|
1384
1385
|
<span class="sw">⇄</span>
|
|
1385
1386
|
<span class="ic" style="color:${hueFor(t.agent)}">${icon(t.agent)}</span>${esc(t.agent)}</span>
|
|
1386
1387
|
<span class="st">${t.turns.length} turn${t.turns.length === 1 ? "" : "s"}${l ? " · live" : ""}</span>
|
|
@@ -1502,8 +1503,12 @@ function foldConversation(events) {
|
|
|
1502
1503
|
};
|
|
1503
1504
|
const owner = new Map();
|
|
1504
1505
|
const claimOf = new Map();
|
|
1506
|
+
let judge = "claude";
|
|
1505
1507
|
for (const e of events) {
|
|
1506
1508
|
switch (e.t) {
|
|
1509
|
+
case "target":
|
|
1510
|
+
judge = e.target?.judge ?? judge;
|
|
1511
|
+
break;
|
|
1507
1512
|
case "finding.raised":
|
|
1508
1513
|
owner.set(e.id, e.agent);
|
|
1509
1514
|
// Deliberately NOT a turn of its own. The reviewer already said this in
|
|
@@ -1518,12 +1523,12 @@ function foldConversation(events) {
|
|
|
1518
1523
|
thread(e.agent);
|
|
1519
1524
|
break;
|
|
1520
1525
|
case "finding.reproduced":
|
|
1521
|
-
thread(owner.get(e.id) ?? "?").turns.push({ who:
|
|
1526
|
+
thread(owner.get(e.id) ?? "?").turns.push({ who: e.who ?? judge, kind: "reproduced",
|
|
1522
1527
|
id: e.id, claim: claimOf.get(e.id) ?? "",
|
|
1523
1528
|
text: typeof e.evidence === "string" ? e.evidence : "", ts: e.ts });
|
|
1524
1529
|
break;
|
|
1525
1530
|
case "finding.resolved":
|
|
1526
|
-
thread(owner.get(e.id) ?? "?").turns.push({ who:
|
|
1531
|
+
thread(owner.get(e.id) ?? "?").turns.push({ who: e.who ?? judge, kind: "verdict",
|
|
1527
1532
|
id: e.id, claim: claimOf.get(e.id) ?? "",
|
|
1528
1533
|
verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts });
|
|
1529
1534
|
break;
|
|
@@ -1532,12 +1537,12 @@ function foldConversation(events) {
|
|
|
1532
1537
|
// and that reply is already rendered whole as its `answer` turn — so the
|
|
1533
1538
|
// text is deliberately dropped, exactly as a raised finding's is. What
|
|
1534
1539
|
// survives is the linkage: which claim is being re-argued, and by whom.
|
|
1535
|
-
//
|
|
1540
|
+
// The judge's own turn is the exception: it is not an excerpt of anything
|
|
1536
1541
|
// shown elsewhere, so dropping its text would lose it entirely.
|
|
1537
1542
|
const who = e.who ?? "?";
|
|
1538
1543
|
const turn = { who, kind: "rebuttal", id: e.id,
|
|
1539
1544
|
claim: claimOf.get(e.id) ?? "", ts: e.ts };
|
|
1540
|
-
if (who ===
|
|
1545
|
+
if (who === judge) turn.text = e.text ?? "";
|
|
1541
1546
|
thread(owner.get(e.id) ?? e.agent ?? "?").turns.push(turn);
|
|
1542
1547
|
break;
|
|
1543
1548
|
}
|
|
@@ -1589,7 +1594,7 @@ function foldConversation(events) {
|
|
|
1589
1594
|
break;
|
|
1590
1595
|
}
|
|
1591
1596
|
case "reply.sent":
|
|
1592
|
-
thread(e.agent).turns.push({ who:
|
|
1597
|
+
thread(e.agent).turns.push({ who: e.who ?? judge, kind: "reply", text: e.text ?? "", ts: e.ts });
|
|
1593
1598
|
break;
|
|
1594
1599
|
case "reply.answered":
|
|
1595
1600
|
thread(e.agent).turns.push({ who: e.agent, kind: "answer",
|
|
@@ -1597,7 +1602,7 @@ function foldConversation(events) {
|
|
|
1597
1602
|
break;
|
|
1598
1603
|
case "commit.pushed":
|
|
1599
1604
|
for (const t of threads.values()) {
|
|
1600
|
-
t.turns.push({ who:
|
|
1605
|
+
t.turns.push({ who: e.who ?? judge, kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
|
|
1601
1606
|
}
|
|
1602
1607
|
break;
|
|
1603
1608
|
}
|