@agentsdance/codejury 0.1.2 → 0.2.0
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 +42 -20
- package/bin/jury.js +84 -72
- package/jury.config.example.json +1 -1
- package/lib/cli-options.js +21 -0
- package/lib/config.js +22 -18
- package/lib/repository.js +24 -20
- package/lib/review-console.js +13 -0
- package/package.json +1 -1
- package/web/index.html +1 -1
package/README.md
CHANGED
|
@@ -23,10 +23,14 @@ the target repository; the caller's working tree is not switched or modified.
|
|
|
23
23
|
|
|
24
24
|
GitLab and compatible self-hosted services are supported through
|
|
25
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.
|
|
26
|
+
`refs/merge-requests/<id>/head` ref, so existing Git credentials are used.
|
|
27
|
+
When that ref is missing, Jury discovers `refs/merge-requests/<shard>/<id>/<revision>`
|
|
28
|
+
refs and fetches the highest numeric revision, verifying the advertised commit.
|
|
29
|
+
The shard is discovered independently of the MR number. If
|
|
27
30
|
the source branch is not uniquely visible on the target remote (commonly a
|
|
28
|
-
fork),
|
|
29
|
-
locally and
|
|
31
|
+
fork), check out the source branch
|
|
32
|
+
locally, omit the MR URL, and run
|
|
33
|
+
`jury --dir /path/to/checkout --trunk <target-branch>`.
|
|
30
34
|
|
|
31
35
|
Node 20 or newer. The reviewers are separate CLIs you install yourself — `jury` spawns whatever you
|
|
32
36
|
have and skips the rest:
|
|
@@ -43,14 +47,17 @@ The former `cr` command remains available as a compatibility alias.
|
|
|
43
47
|
|
|
44
48
|
## Working and state directory
|
|
45
49
|
|
|
46
|
-
With
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
With an MR/PR URL, Jury always resolves the URL into an isolated checkout. By default,
|
|
51
|
+
checkouts live under `~/.jury/checkouts/` and run records under `~/.jury/runs/`, regardless
|
|
52
|
+
of the current directory. `--dir <path>` instead uses `<path>/checkouts/` and `<path>/runs/`.
|
|
53
|
+
The integrated `--web` console reads records from the same root.
|
|
54
|
+
|
|
55
|
+
Without a URL, Jury reviews the current repository, or the repository selected by `--dir`.
|
|
56
|
+
Other commands keep the current directory when it is a Git worktree and otherwise use
|
|
57
|
+
`~/.jury`. An explicitly empty `--dir ""` selects `~/.jury`. `~` is expanded consistently.
|
|
50
58
|
|
|
51
59
|
Run records live in `<dir>/runs`. For example, `jury runs --dir ""` and `jury web --dir ""` read
|
|
52
|
-
`~/.jury/runs
|
|
53
|
-
`runs` directory.
|
|
60
|
+
`~/.jury/runs`. Use those commands to inspect URL-based reviews launched with the default root.
|
|
54
61
|
|
|
55
62
|
## The loop
|
|
56
63
|
|
|
@@ -79,37 +86,52 @@ Two properties make it terminate rather than churn:
|
|
|
79
86
|
|
|
80
87
|
## Watching it happen
|
|
81
88
|
|
|
82
|
-
A round is a reviewer talking for several minutes and then a wall of text.
|
|
89
|
+
A round is a reviewer talking for several minutes and then a wall of text. Reviews start the console by default
|
|
83
90
|
in the same process as the run, so the conversation streams as it is spoken rather than arriving at
|
|
84
91
|
the end:
|
|
85
92
|
|
|
86
93
|
```
|
|
87
|
-
jury
|
|
88
|
-
jury --
|
|
94
|
+
jury https://github.com/owner/repo/pull/1 # review, with the console open on it
|
|
95
|
+
jury --port 3099 --rounds 3 # the current branch, on another port
|
|
89
96
|
```
|
|
90
97
|
|
|
98
|
+
Use `--push=false` to keep fixes local; pushing is enabled by default.
|
|
99
|
+
|
|
100
|
+
Use `--web=false` (or `--web false`) to disable the console and exit when review finishes.
|
|
101
|
+
`--web`, `--web=true`, and `--web true` enable it explicitly.
|
|
102
|
+
|
|
103
|
+
The browser opens with `?run=<current-run>` so it shows the review just started,
|
|
104
|
+
including when the server falls back to another port.
|
|
105
|
+
|
|
91
106
|
The console outlives the loop — it stays up until you ctrl-c, which is the point: the run finishing
|
|
92
107
|
is when there is finally something worth reading. `jury web` still serves the same console
|
|
93
108
|
standalone, against runs that already exist.
|
|
94
109
|
|
|
95
110
|
## Who writes, and who only reads
|
|
96
111
|
|
|
97
|
-
Exactly one agent has role `main`; it is the default **judge**. The built-in default is
|
|
112
|
+
Exactly one agent has role `main`; it is the default **judge**. The built-in default is Codex.
|
|
98
113
|
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
|
|
114
|
+
different enabled agent for one run with `--judge`; without that flag the configured main agent is used (Codex by default):
|
|
100
115
|
|
|
101
116
|
```bash
|
|
102
|
-
jury https://github.com/owner/repo/pull/1 #
|
|
103
|
-
jury --judge
|
|
117
|
+
jury https://github.com/owner/repo/pull/1 # Codex judges
|
|
118
|
+
jury --judge claude https://github.com/owner/repo/pull/1 # Claude judges
|
|
104
119
|
```
|
|
105
120
|
|
|
121
|
+
When upgrading from 0.1.x, a configuration that disables Codex or marks it as a reviewer
|
|
122
|
+
needs an explicit judge: use `--judge claude`, or assign role `main` to the intended
|
|
123
|
+
agent in `jury.config.json`. For scripts, use `--web=false` to exit after review.
|
|
124
|
+
|
|
106
125
|
`--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.
|
|
126
|
+
pool, so it never reviews its own work. Codex uses a read-only sandbox for reviews
|
|
127
|
+
and a workspace-write sandbox for judging. Claude uses plan mode for reviews and
|
|
128
|
+
acceptEdits for judging. Custom agent `argv` remains authoritative; `judgeArgv` can
|
|
129
|
+
provide a separate judge command when needed.
|
|
108
130
|
`jury agents` shows the configured roles:
|
|
109
131
|
|
|
110
132
|
```
|
|
111
|
-
ok claude
|
|
112
|
-
ok codex
|
|
133
|
+
ok claude reviewer /usr/local/bin/claude
|
|
134
|
+
ok codex main /usr/local/bin/codex
|
|
113
135
|
ok agy reviewer /usr/local/bin/agy
|
|
114
136
|
```
|
|
115
137
|
|
|
@@ -120,7 +142,7 @@ The judge then holds **one conversation per reviewer**, concurrently, each about
|
|
|
120
142
|
own findings and nothing else:
|
|
121
143
|
|
|
122
144
|
```
|
|
123
|
-
|
|
145
|
+
codex ──▶ claude its 5 findings, fresh run with its own words quoted back
|
|
124
146
|
└──▶ agy its 5 findings, fresh run with its own words quoted back
|
|
125
147
|
```
|
|
126
148
|
|
package/bin/jury.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// jury —
|
|
2
|
+
// jury — review a pull request with multiple independent AI reviewers.
|
|
3
3
|
//
|
|
4
4
|
// The CLI owns the mechanics: worktrees, spawning agents, capturing what they
|
|
5
5
|
// said, recording it, serving the console. It deliberately does NOT triage —
|
|
@@ -22,6 +22,8 @@ import { triageOne } from "../lib/triage.js";
|
|
|
22
22
|
import { assertPrCheckout, repositoryFromPrUrl, resolvePrCheckout } from "../lib/repository.js";
|
|
23
23
|
import { resolveJuryDirectory } from "../lib/directories.js";
|
|
24
24
|
import * as st from "../lib/style.js";
|
|
25
|
+
import { parseReviewArgs } from "../lib/cli-options.js";
|
|
26
|
+
import { startReviewConsole } from "../lib/review-console.js";
|
|
25
27
|
|
|
26
28
|
const run = promisify(execFile);
|
|
27
29
|
// Read from the manifest rather than restated here, where it drifted: the CLI
|
|
@@ -43,19 +45,20 @@ let resolvedCheckoutCleanup = null;
|
|
|
43
45
|
* of it by default buried the one command that matters in a wall of options —
|
|
44
46
|
* `jury help --all` still prints everything.
|
|
45
47
|
*/
|
|
46
|
-
const USAGE = `jury —
|
|
48
|
+
const USAGE = `jury — review a pull request with multiple AI reviewers until they agree
|
|
47
49
|
|
|
48
50
|
jury <pr-url> review a pull request
|
|
49
|
-
jury --rounds 3
|
|
50
|
-
jury
|
|
51
|
+
jury <pr-url> --rounds 3 review a pull request for up to 3 rounds
|
|
52
|
+
jury <pr-url> --web=false review without the browser console
|
|
51
53
|
|
|
52
54
|
Common flags
|
|
53
55
|
|
|
54
|
-
--dir <path>
|
|
55
|
-
--rounds <n>
|
|
56
|
-
--agents
|
|
57
|
-
--judge codex
|
|
58
|
-
--
|
|
56
|
+
--dir <path> working/state root (default: Git cwd or ~/.jury)
|
|
57
|
+
--rounds <n> stop after n rounds (default: 10)
|
|
58
|
+
--agents claude,grok only these reviewers (default: configured reviewers)
|
|
59
|
+
--judge codex one agent that triages and fixes (default: codex)
|
|
60
|
+
--push <true|false> commit and push fixes (default: true)
|
|
61
|
+
--web <true|false> open the browser console (default: true)
|
|
59
62
|
|
|
60
63
|
Other commands
|
|
61
64
|
|
|
@@ -69,55 +72,55 @@ Other commands
|
|
|
69
72
|
|
|
70
73
|
const USAGE_FULL = `jury — multi-agent code review
|
|
71
74
|
|
|
72
|
-
jury <pr-url>
|
|
73
|
-
jury review-once [flags]
|
|
74
|
-
jury web [flags]
|
|
75
|
-
jury finding <cmd>
|
|
76
|
-
jury reply [flags]
|
|
77
|
-
jury runs
|
|
78
|
-
jury agents
|
|
75
|
+
jury <pr-url> review a PR until every reviewer approves, one conversation per reviewer
|
|
76
|
+
jury review-once [flags] a single round, no triage or reply
|
|
77
|
+
jury web [flags] serve the console (default http://127.0.0.1:3080)
|
|
78
|
+
jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
|
|
79
|
+
jury reply [flags] send each reviewer your verdicts on ITS findings, one conversation each
|
|
80
|
+
jury runs list every PR under review, with its slug for --run
|
|
81
|
+
jury agents check which configured agents are installed
|
|
79
82
|
jury version
|
|
80
83
|
|
|
81
|
-
review (
|
|
84
|
+
review (triages, fixes, commits, and pushes automatically)
|
|
82
85
|
jury https://github.com/owner/repo/pull/1
|
|
83
|
-
jury --rounds 3
|
|
84
|
-
|
|
85
|
-
--dir <path>
|
|
86
|
-
--pr <url>
|
|
87
|
-
--trunk <branch>
|
|
88
|
-
--title <text>
|
|
89
|
-
--summary <text>
|
|
90
|
-
--rounds <n>
|
|
91
|
-
--agents a,b
|
|
92
|
-
--judge <agent>
|
|
93
|
-
--resume <slug>
|
|
94
|
-
--web
|
|
95
|
-
--port <n>
|
|
96
|
-
--
|
|
97
|
-
--dry-run
|
|
98
|
-
|
|
99
|
-
|
|
86
|
+
jury <pr-url> --rounds 3 review a pull request for up to 3 rounds
|
|
87
|
+
|
|
88
|
+
--dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
|
|
89
|
+
--pr <url> same as the positional argument
|
|
90
|
+
--trunk <branch> diff base branch (default: the remote's own HEAD)
|
|
91
|
+
--title <text> what the change does (default: read from the PR)
|
|
92
|
+
--summary <text> intent, passed to reviewers (default: the PR description)
|
|
93
|
+
--rounds <n> maximum rounds (default 10)
|
|
94
|
+
--agents a,b only these reviewers (default: configured reviewers)
|
|
95
|
+
--judge <agent> one agent that triages and fixes (default: codex)
|
|
96
|
+
--resume <slug> continue an existing run instead of starting a new one
|
|
97
|
+
--web <true|false> open the console; stays up after review (default: true)
|
|
98
|
+
--port <n> console port (default 3080)
|
|
99
|
+
--push <true|false> commit and push fixes (default: true)
|
|
100
|
+
--dry-run (internal) exercise the pipeline, spawn no agents. Always
|
|
101
|
+
reports clean and triages nothing, so it says whether the
|
|
102
|
+
plumbing runs and never whether the code is good.
|
|
100
103
|
|
|
101
104
|
review-once flags
|
|
102
|
-
--dir <path>
|
|
103
|
-
--pr <url>
|
|
104
|
-
--title <text>
|
|
105
|
-
--summary <text>
|
|
106
|
-
--trunk <branch>
|
|
107
|
-
--round <n>
|
|
108
|
-
--agents a,b
|
|
109
|
-
--max-rounds <n>
|
|
105
|
+
--dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
|
|
106
|
+
--pr <url> pull request URL, recorded on the run
|
|
107
|
+
--title <text> what the change does, shown in the console
|
|
108
|
+
--summary <text> a few lines of intent, passed to reviewers
|
|
109
|
+
--trunk <branch> diff base branch (default master)
|
|
110
|
+
--round <n> round number (default: next)
|
|
111
|
+
--agents a,b only these reviewers (default: all enabled)
|
|
112
|
+
--max-rounds <n> keep going until every reviewer approves, at most n (default 1)
|
|
110
113
|
|
|
111
114
|
web flags
|
|
112
|
-
--dir <path>
|
|
113
|
-
--port <n>
|
|
114
|
-
--open
|
|
115
|
+
--dir <path> run-state root (default: cwd if Git, otherwise ~/.jury)
|
|
116
|
+
--port <n> default 3080, walks forward if busy
|
|
117
|
+
--open open a browser
|
|
115
118
|
|
|
116
119
|
finding commands (--dir picks state root; --run picks run)
|
|
117
120
|
jury finding list
|
|
118
121
|
jury finding reproduce <id> --evidence <text> [--test <text>]
|
|
119
122
|
jury finding resolve <id> --verdict <${VERDICTS.join("|")}> [--reason <text>] [--test <text>]
|
|
120
|
-
jury finding settled
|
|
123
|
+
jury finding settled print the regenerated settled list
|
|
121
124
|
|
|
122
125
|
state commands
|
|
123
126
|
jury runs [--dir <path>]
|
|
@@ -496,24 +499,19 @@ async function runRound({ dir, round, pool, cfg, target, worktree, values, sha,
|
|
|
496
499
|
async function cmdAgent(argv) {
|
|
497
500
|
const judgeFlags = argv.filter((a) => a === "--judge" || a.startsWith("--judge="));
|
|
498
501
|
if (judgeFlags.length > 1) throw new Error("--judge accepts exactly one agent");
|
|
499
|
-
const { values, positionals } =
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
resume: { type: "string" },
|
|
513
|
-
"dry-run": { type: "boolean", default: false },
|
|
514
|
-
web: { type: "boolean", default: false },
|
|
515
|
-
port: { type: "string", default: "3080" },
|
|
516
|
-
},
|
|
502
|
+
const { values, positionals } = parseReviewArgs(argv, {
|
|
503
|
+
dir: { type: "string" },
|
|
504
|
+
pr: { type: "string" },
|
|
505
|
+
title: { type: "string" },
|
|
506
|
+
summary: { type: "string" },
|
|
507
|
+
trunk: { type: "string" },
|
|
508
|
+
rounds: { type: "string", default: "10" },
|
|
509
|
+
agents: { type: "string" },
|
|
510
|
+
judge: { type: "string" },
|
|
511
|
+
push: { type: "boolean", default: true },
|
|
512
|
+
resume: { type: "string" },
|
|
513
|
+
"dry-run": { type: "boolean", default: false },
|
|
514
|
+
port: { type: "string", default: "3080" },
|
|
517
515
|
});
|
|
518
516
|
if (values.judge?.includes(",")) throw new Error("--judge accepts exactly one agent, not a list");
|
|
519
517
|
|
|
@@ -532,10 +530,18 @@ async function cmdAgent(argv) {
|
|
|
532
530
|
throw new Error("--rounds must be a positive integer");
|
|
533
531
|
}
|
|
534
532
|
|
|
535
|
-
values.
|
|
536
|
-
|
|
533
|
+
const requestedWorktree = await commandDirectory(values.pr ? (values.dir ?? "") : values.dir);
|
|
534
|
+
// Two different questions, and conflating them cost the local path entirely.
|
|
535
|
+
// `root` is where the isolated checkout and run records are written; `dir` is
|
|
536
|
+
// the repository to resolve the request FROM. A caller sitting in the very
|
|
537
|
+
// repository under review already has its commits, and asking them for the
|
|
538
|
+
// one ref the host may have pruned — rather than reading what is on disk —
|
|
539
|
+
// is what issue #30 was.
|
|
540
|
+
const resolveFrom = values.dir ? path.resolve(values.dir) : process.cwd();
|
|
537
541
|
const resolved = values.pr
|
|
538
|
-
? await resolvePrCheckout(values.pr, {
|
|
542
|
+
? await resolvePrCheckout(values.pr, {
|
|
543
|
+
allowPush: values.push, root: requestedWorktree, dir: resolveFrom,
|
|
544
|
+
})
|
|
539
545
|
: null;
|
|
540
546
|
const worktree = resolved?.worktree ?? requestedWorktree;
|
|
541
547
|
resolvedCheckoutCleanup = resolved?.cleanup ?? null;
|
|
@@ -606,7 +612,11 @@ async function cmdAgent(argv) {
|
|
|
606
612
|
if (requestedJudge) {
|
|
607
613
|
throw new Error(`judge "${requestedJudge}" is not an enabled configured agent — available: ${available}`);
|
|
608
614
|
}
|
|
609
|
-
throw new Error('no agent has role "main" —
|
|
615
|
+
throw new Error('no agent has role "main" — Codex is the default judge; enable it or pass --judge <agent>');
|
|
616
|
+
}
|
|
617
|
+
if (!values["dry-run"]) {
|
|
618
|
+
const installed = await probe(judge);
|
|
619
|
+
if (!installed.ok) throw new Error(`judge "${judge.name}" is not installed (${installed.bin}); install it or select --judge <agent>`);
|
|
610
620
|
}
|
|
611
621
|
target.judge = judge.name;
|
|
612
622
|
|
|
@@ -632,10 +642,12 @@ async function cmdAgent(argv) {
|
|
|
632
642
|
// directory and nothing else: the server re-reads it per request and tails
|
|
633
643
|
// the event log, so it sees each round land without the loop telling it.
|
|
634
644
|
if (values.web) {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
645
|
+
liveConsole = await startReviewConsole({
|
|
646
|
+
dir, target, cwd: requestedWorktree, port: Number(values.port),
|
|
647
|
+
onLog: (m) => console.log(st.field("console", st.muted(m))),
|
|
648
|
+
});
|
|
649
|
+
console.log(st.field("console", `${liveConsole}${st.muted(" → the conversation streams live")}`));
|
|
650
|
+
openBrowser(liveConsole);
|
|
639
651
|
console.log("");
|
|
640
652
|
} else {
|
|
641
653
|
console.log(st.field("watch", st.muted("jury web → the conversation streams live")) + "\n");
|
package/jury.config.example.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"and gitignored: which reviewers you have installed is a property of your box,",
|
|
5
5
|
"not of the project.",
|
|
6
6
|
"",
|
|
7
|
-
"Built-in defaults already cover
|
|
7
|
+
"Built-in defaults already cover codex (main), claude and droid, so this file is",
|
|
8
8
|
"only needed to add an agent or disable one.",
|
|
9
9
|
"",
|
|
10
10
|
"Placeholders: {{worktree}} {{promptFile}} {{promptText}} {{sha}}"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
/** Accept default-on boolean flags with optional true/false values. */
|
|
4
|
+
export function parseReviewArgs(args, options) {
|
|
5
|
+
const { tokens } = parseArgs({ args, options: { ...options, web: { type: "boolean" }, push: { type: "boolean" } },
|
|
6
|
+
allowPositionals: true, strict: false, tokens: true });
|
|
7
|
+
const normalized = [...args];
|
|
8
|
+
for (const token of tokens) {
|
|
9
|
+
if (token.kind !== "option" || !["web", "push"].includes(token.name) || token.inlineValue) continue;
|
|
10
|
+
const next = tokens.find((t) => t.index === token.index + 1);
|
|
11
|
+
if (next?.kind === "positional" && ["true", "false"].includes(next.value)) continue;
|
|
12
|
+
normalized[token.index] = `--${token.name}=true`;
|
|
13
|
+
}
|
|
14
|
+
const parsed = parseArgs({ args: normalized, allowPositionals: true,
|
|
15
|
+
options: { ...options, web: { type: "string", default: "true" }, push: { type: "string", default: "true" } } });
|
|
16
|
+
for (const name of ["web", "push"]) {
|
|
17
|
+
if (!["true", "false"].includes(parsed.values[name])) throw new Error(`--${name} must be true or false`);
|
|
18
|
+
parsed.values[name] = parsed.values[name] === "true";
|
|
19
|
+
}
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -15,37 +15,32 @@ export const DEFAULTS = {
|
|
|
15
15
|
{
|
|
16
16
|
name: "claude",
|
|
17
17
|
product: "Anthropic Claude Code",
|
|
18
|
-
role: "
|
|
19
|
-
// The main agent used to carry empty argv, on the theory that it was the
|
|
20
|
-
// session driving the loop rather than something spawned. That made the
|
|
21
|
-
// loop unable to run without a person in the chair: nothing triaged, so
|
|
22
|
-
// nothing was ever fixed and every round re-read the same commit. It is
|
|
23
|
-
// spawned like any other agent now — the difference is that it is the
|
|
24
|
-
// only one permitted to touch the tree.
|
|
18
|
+
role: "reviewer",
|
|
25
19
|
promptDelivery: "argv",
|
|
26
20
|
cwd: "worktree",
|
|
27
21
|
argv: [
|
|
22
|
+
"claude", "-p", "{{promptText}}",
|
|
23
|
+
"--permission-mode", "plan",
|
|
24
|
+
"--disallowedTools", "Edit,Write,NotebookEdit",
|
|
25
|
+
"--add-dir", "{{worktree}}",
|
|
26
|
+
],
|
|
27
|
+
judgeArgv: [
|
|
28
28
|
"claude", "-p", "{{promptText}}",
|
|
29
29
|
"--permission-mode", "acceptEdits",
|
|
30
30
|
"--add-dir", "{{worktree}}",
|
|
31
31
|
],
|
|
32
32
|
resume: { supported: false, reason: "each finding is judged on its own merits" },
|
|
33
33
|
report: "whole",
|
|
34
|
-
// Triage is the longest step in a round: read, reproduce, fix, run the
|
|
35
|
-
// suite. It was 0 back when this agent was never spawned, which made its
|
|
36
|
-
// timeout 0 and killed it the instant it started.
|
|
37
34
|
expectSeconds: 900,
|
|
38
|
-
notes:
|
|
39
|
-
"Owns the working tree and the commit. Reproduces a finding before fixing it, proves the " +
|
|
40
|
-
"regression test fails without the fix, and replies to every finding including rejections.",
|
|
41
35
|
},
|
|
42
36
|
{
|
|
43
37
|
name: "codex",
|
|
44
38
|
product: "OpenAI Codex",
|
|
45
|
-
role: "
|
|
39
|
+
role: "main",
|
|
46
40
|
promptDelivery: "argv",
|
|
47
41
|
cwd: "worktree",
|
|
48
|
-
argv: ["codex", "exec", "--skip-git-repo-check", "{{promptText}}"],
|
|
42
|
+
argv: ["codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check", "{{promptText}}"],
|
|
43
|
+
judgeArgv: ["codex", "exec", "--sandbox", "workspace-write", "--skip-git-repo-check", "{{promptText}}"],
|
|
49
44
|
// `--last` means "whatever session ran most recently in this worktree",
|
|
50
45
|
// which is only the review being answered if nothing else ran in between.
|
|
51
46
|
// Any codex session started between the review and the reply steals the
|
|
@@ -123,9 +118,18 @@ export async function loadConfig(dir = process.cwd()) {
|
|
|
123
118
|
}
|
|
124
119
|
|
|
125
120
|
const byName = new Map(DEFAULTS.agents.map((a) => [a.name, { ...a }]));
|
|
121
|
+
const explicitMain = (user.agents ?? []).find((a) => a.role === "main" && a.enabled !== false);
|
|
122
|
+
if (explicitMain) {
|
|
123
|
+
for (const agent of byName.values()) {
|
|
124
|
+
if (agent.role === "main" && agent.name !== explicitMain.name) agent.role = "reviewer";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
126
127
|
for (const a of user.agents ?? []) {
|
|
127
128
|
if (!a.name) throw new Error(`each agent in ${found} needs a name`);
|
|
128
|
-
|
|
129
|
+
const merged = { ...(byName.get(a.name) ?? {}), ...a };
|
|
130
|
+
// A custom executable must also be used for judging unless explicitly overridden.
|
|
131
|
+
if (a.argv && !Object.hasOwn(a, "judgeArgv")) delete merged.judgeArgv;
|
|
132
|
+
byName.set(a.name, merged);
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
const agents = [...byName.values()].filter((a) => a.enabled !== false);
|
|
@@ -149,8 +153,8 @@ export async function loadConfig(dir = process.cwd()) {
|
|
|
149
153
|
|
|
150
154
|
/** Select the agent that judges findings and owns the working tree. */
|
|
151
155
|
export function judgeAgent(cfg, name) {
|
|
152
|
-
|
|
153
|
-
return
|
|
156
|
+
const agent = name ? cfg.agents.find((a) => a.name === name) : cfg.main;
|
|
157
|
+
return agent ? { ...agent, argv: agent.judgeArgv ?? agent.argv } : null;
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
export function reviewers(cfg) {
|
package/lib/repository.js
CHANGED
|
@@ -122,7 +122,7 @@ class MergeRequestCheckoutError extends Error {}
|
|
|
122
122
|
*
|
|
123
123
|
* Not every GitLab-compatible host publishes refs/merge-requests/<id>/head.
|
|
124
124
|
* Some expose the individual revisions instead, as
|
|
125
|
-
* refs/merge-requests/<
|
|
125
|
+
* refs/merge-requests/<shard>/<id>/<revision>, where the highest revision is the
|
|
126
126
|
* current head. Returns the advertised sha when that fallback was used, so the
|
|
127
127
|
* caller can check the fetched commit against what the remote said: a request
|
|
128
128
|
* updated mid-fetch would otherwise be reviewed as though it were the head.
|
|
@@ -137,18 +137,23 @@ async function fetchMergeRequestHead(exec, worktree, number) {
|
|
|
137
137
|
// transport failure keeps its own error, or every network problem would be
|
|
138
138
|
// reported as an unusual ref layout.
|
|
139
139
|
if (!/couldn't find remote ref/i.test(String(err.stderr ?? "") + String(err.message))) throw err;
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
// The shard can differ from the MR id (and can contain leading zeroes).
|
|
141
|
+
// Discover it rather than assuming a particular host's sharding scheme.
|
|
142
|
+
const { stdout } = await exec("git", ["ls-remote", "--refs", "origin", `refs/merge-requests/*/${number}/*`], options);
|
|
142
143
|
const revisions = String(stdout).split("\n").flatMap((line) => {
|
|
143
144
|
const [sha, ref] = line.trim().split(/\s+/);
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
const match = ref?.match(/^refs\/merge-requests\/\d+\/(\d+)\/([1-9]\d*)$/);
|
|
146
|
+
if (!/^(?:[a-f\d]{40}|[a-f\d]{64})$/i.test(sha ?? "") || match?.[1] !== number) return [];
|
|
147
|
+
const revision = match[2];
|
|
146
148
|
// Numeric, so revision 10 sorts above revision 9 rather than beside 1.
|
|
147
149
|
return /^[1-9]\d*$/.test(revision) ? [{ sha, ref, revision: BigInt(revision) }] : [];
|
|
148
150
|
});
|
|
149
151
|
revisions.sort((a, b) => (a.revision > b.revision ? -1 : a.revision < b.revision ? 1 : 0));
|
|
150
152
|
if (!revisions.length) throw err; // no revisions either: the original error is the true one
|
|
151
153
|
const latest = revisions[0];
|
|
154
|
+
if (revisions.some(r => r.revision === latest.revision && r.sha !== latest.sha)) {
|
|
155
|
+
throw new MergeRequestCheckoutError(`merge request !${number} has conflicting latest revision refs`);
|
|
156
|
+
}
|
|
152
157
|
await exec("git", ["fetch", "--quiet", "origin", latest.ref], options);
|
|
153
158
|
return latest.sha;
|
|
154
159
|
}
|
|
@@ -248,13 +253,13 @@ async function localHead(dir, target, number, { exec, namespace, branch = null }
|
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
251
|
-
allowPush, exec, makeTemp, remove, dir,
|
|
256
|
+
allowPush, exec, makeTemp, remove, dir, root,
|
|
252
257
|
}) {
|
|
253
258
|
// Where the clone lands when one is needed: the same ~/.jury the rest of the
|
|
254
259
|
// CLI already uses, rather than the system temp directory. The checkout is
|
|
255
260
|
// still removed when the review ends — this puts jury's working files under
|
|
256
261
|
// one predictable, inspectable root, and does not make them persist.
|
|
257
|
-
const worktree = await makeTemp(path.join(
|
|
262
|
+
const worktree = await makeTemp(path.join(root, "checkouts", `jury-mr-${number}-`));
|
|
258
263
|
let advertisedSha = null;
|
|
259
264
|
// Refresh the cached ref before trusting it. A ref fetched before the author
|
|
260
265
|
// pushed again names an obsolete head, and nothing about matching repository
|
|
@@ -325,7 +330,7 @@ async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
|
325
330
|
);
|
|
326
331
|
|
|
327
332
|
// Only when a push target is actually needed. This ran unconditionally, so
|
|
328
|
-
// a
|
|
333
|
+
// a resolution with allowPush false and every commit on disk still failed the
|
|
329
334
|
// moment the network was unreachable — the one case the local path exists
|
|
330
335
|
// to serve.
|
|
331
336
|
let branch = null;
|
|
@@ -335,8 +340,8 @@ async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
|
335
340
|
}
|
|
336
341
|
if (allowPush && !branch) {
|
|
337
342
|
throw new MergeRequestCheckoutError(
|
|
338
|
-
`
|
|
339
|
-
"
|
|
343
|
+
`Can't determine where to push fixes for merge request !${number}.\n` +
|
|
344
|
+
"Check out the source branch and run jury --dir <checkout> --trunk <target-branch> without the MR URL.",
|
|
340
345
|
);
|
|
341
346
|
}
|
|
342
347
|
|
|
@@ -352,17 +357,15 @@ async function resolveMergeRequestCheckout(prUrl, target, number, {
|
|
|
352
357
|
} catch (err) {
|
|
353
358
|
await remove(worktree);
|
|
354
359
|
if (err instanceof MergeRequestCheckoutError) throw err;
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
// reported as a probable authentication problem. Whatever git said is the
|
|
358
|
-
// one thing that distinguishes the cases.
|
|
360
|
+
// Preserve the diagnostic even when a connection warning precedes it.
|
|
361
|
+
// Missing refs alone do not establish whether an MR is open or closed.
|
|
359
362
|
const said = [err.stderr, err.message].map((t) => String(t ?? "").trim()).find(Boolean) ?? "";
|
|
360
363
|
const missingRef = /couldn't find remote ref|no matching remote head/i.test(said);
|
|
361
364
|
throw new Error(
|
|
362
|
-
`could not resolve ${target.display} merge request !${number}: ${said
|
|
365
|
+
`could not resolve ${target.display} merge request !${number}: ${said}\n` +
|
|
363
366
|
(missingRef
|
|
364
|
-
?
|
|
365
|
-
"
|
|
367
|
+
? "No usable merge request ref was found. Check out the source branch locally, omit the MR URL, " +
|
|
368
|
+
"and run jury --dir /path/to/checkout --trunk <target-branch>."
|
|
366
369
|
: "check Git authentication and refs/merge-requests support, or check out the source branch and pass --dir"),
|
|
367
370
|
);
|
|
368
371
|
}
|
|
@@ -400,13 +403,14 @@ export async function resolvePrCheckout(prUrl, {
|
|
|
400
403
|
// repository that already holds the request's commits needs no head ref.
|
|
401
404
|
dir = null,
|
|
402
405
|
home = homedir(),
|
|
406
|
+
root = path.join(home, ".jury"),
|
|
403
407
|
} = {}) {
|
|
404
408
|
const target = repositoryFromPrUrl(prUrl);
|
|
405
409
|
const number = githubNumber(prUrl);
|
|
406
410
|
const mrNumber = mergeRequestNumber(prUrl);
|
|
407
411
|
if (target && mrNumber) {
|
|
408
412
|
return resolveMergeRequestCheckout(prUrl, target, mrNumber, {
|
|
409
|
-
allowPush, exec, makeTemp, remove, dir,
|
|
413
|
+
allowPush, exec, makeTemp, remove, dir, root,
|
|
410
414
|
});
|
|
411
415
|
}
|
|
412
416
|
if (!target || !number) {
|
|
@@ -438,11 +442,11 @@ export async function resolvePrCheckout(prUrl, {
|
|
|
438
442
|
if (allowPush && details.state !== "OPEN") {
|
|
439
443
|
throw new Error(
|
|
440
444
|
`pull request #${number} is ${String(details.state).toLowerCase()} — ` +
|
|
441
|
-
"
|
|
445
|
+
"check out the PR locally and use jury review-once --dir <checkout>",
|
|
442
446
|
);
|
|
443
447
|
}
|
|
444
448
|
|
|
445
|
-
const worktree = await makeTemp(path.join(
|
|
449
|
+
const worktree = await makeTemp(path.join(root, "checkouts", `jury-pr-${number}-`));
|
|
446
450
|
// `gh` already gave the authoritative head oid, so that — not a cached ref —
|
|
447
451
|
// is what to look for locally. Consulting refs/pull/<id>/head first checked
|
|
448
452
|
// out whatever it pointed at when it was last fetched, which then failed the
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { serve } from "./server.js";
|
|
3
|
+
import { appendEvent, readEvents, foldEvents, writeRun } from "./store.js";
|
|
4
|
+
|
|
5
|
+
/** Publish the selected run before the server can answer the browser's first request. */
|
|
6
|
+
export async function startReviewConsole({ dir, target, cwd, port, onLog }, serveConsole = serve) {
|
|
7
|
+
await appendEvent(dir, { t: "target", target: { ...target, stateNote: "starting review" } });
|
|
8
|
+
await writeRun(dir, foldEvents(await readEvents(dir), { target }));
|
|
9
|
+
const { url } = await serveConsole({ port, cwd, onLog });
|
|
10
|
+
const currentRunUrl = new URL(url);
|
|
11
|
+
currentRunUrl.searchParams.set("run", path.basename(dir));
|
|
12
|
+
return currentRunUrl.href;
|
|
13
|
+
}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -485,7 +485,7 @@
|
|
|
485
485
|
|
|
486
486
|
<section id="convo-sec">
|
|
487
487
|
<h2>The conversation</h2>
|
|
488
|
-
<p class="note" id="c-note">
|
|
488
|
+
<p class="note" id="c-note">The judge on the left, the reviewer on the right. Each reviewer is a
|
|
489
489
|
separate thread — they never see each other's findings, which is the only reason running more
|
|
490
490
|
than one counts as evidence.</p>
|
|
491
491
|
<div class="convo-bar">
|