@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 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. If
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), use `--no-push` for a read-only review or check out the source branch
29
- locally and pass `--dir`.
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 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
+ 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`, while a review launched from a repository continues to use that repository's
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. `--web` starts the console
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 --web https://github.com/owner/repo/pull/1 # review, with the console open on it
88
- jury --web --port 3099 --rounds 3 # the current branch, on another port
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 Claude Code.
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 Claude remains the default:
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 # Claude judges
103
- jury --judge codex https://github.com/owner/repo/pull/1 # Codex judges
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 main /usr/local/bin/claude
112
- ok codex reviewer /usr/local/bin/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
- claude ──▶ codex its 5 findings, resumed session
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 — run a pull request past several independent AI reviewers.
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 — run a pull request past several AI reviewers until they agree
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 review the current branch, no PR
50
- jury --web <pr-url> …and watch it in the browser
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> working/state root (default: Git cwd or ~/.jury)
55
- --rounds <n> stop after n rounds (default 10)
56
- --agents codex,grok only these reviewers (default: all installed)
57
- --judge codex one agent that triages and fixes (default: claude)
58
- --no-push fix locally, do not push
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> review a PR until every reviewer approves, one conversation per reviewer
73
- jury review-once [flags] a single round, no triage or reply
74
- jury web [flags] serve the console (default http://127.0.0.1:3080)
75
- jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
76
- jury reply [flags] send each reviewer your verdicts on ITS findings, one conversation each
77
- jury runs list every PR under review, with its slug for --run
78
- jury agents check which configured agents are installed
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 (drives itself; no operator between rounds)
84
+ review (triages, fixes, commits, and pushes automatically)
82
85
  jury https://github.com/owner/repo/pull/1
83
- jury --rounds 3 the current branch, no PR
84
-
85
- --dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
86
- --pr <url> same as the positional argument
87
- --trunk <branch> diff base branch (default: the remote's own HEAD)
88
- --title <text> what the change does (default: read from the PR)
89
- --summary <text> intent, passed to reviewers (default: the PR description)
90
- --rounds <n> maximum rounds (default 10)
91
- --agents a,b only these reviewers (default: all installed)
92
- --judge <agent> one agent that triages and fixes (default: claude)
93
- --resume <slug> continue an existing run instead of starting a new one
94
- --web open the console on this run (stays up when it ends)
95
- --port <n> console port, with --web (default 3080)
96
- --no-push commit fixes to the worktree without pushing
97
- --dry-run (internal) exercise the pipeline, spawn no agents. Always
98
- reports clean and triages nothing, so it says whether the
99
- plumbing runs and never whether the code is good.
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> repo/worktree (default: cwd if Git, otherwise ~/.jury)
103
- --pr <url> pull request URL, recorded on the run
104
- --title <text> what the change does, shown in the console
105
- --summary <text> a few lines of intent, passed to reviewers
106
- --trunk <branch> diff base branch (default master)
107
- --round <n> round number (default: next)
108
- --agents a,b only these reviewers (default: all enabled)
109
- --max-rounds <n> keep going until every reviewer approves, at most n (default 1)
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> run-state root (default: cwd if Git, otherwise ~/.jury)
113
- --port <n> default 3080, walks forward if busy
114
- --open open a browser
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 print the regenerated settled list
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 } = parseArgs({
500
- args: argv, allowPositionals: true,
501
- options: {
502
- dir: { type: "string" },
503
- pr: { type: "string" },
504
- title: { type: "string" },
505
- summary: { type: "string" },
506
- trunk: { type: "string" },
507
- rounds: { type: "string", default: "10" },
508
- agents: { type: "string" },
509
- judge: { type: "string" },
510
- push: { type: "boolean", default: true },
511
- "no-push": { type: "boolean", default: false },
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.push = values.push && !values["no-push"];
536
- const requestedWorktree = await commandDirectory(values.dir);
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, { allowPush: values.push, dir: requestedWorktree })
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" — configure one or pass --judge <agent>');
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
- const { url } = await serve({ port: Number(values.port), onLog: (m) => console.log(st.field("console", st.muted(m))) });
636
- liveConsole = url;
637
- console.log(st.field("console", `${url}${st.muted(" → the conversation streams live")}`));
638
- openBrowser(url);
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");
@@ -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 claude (main), codex and droid, so this file is",
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: "main",
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: "reviewer",
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
- byName.set(a.name, { ...(byName.get(a.name) ?? {}), ...a });
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
- if (name) return cfg.agents.find((a) => a.name === name) ?? null;
153
- return cfg.main ?? null;
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/<id>/<id>/<revision>, where the highest revision is the
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
- const prefix = `refs/merge-requests/${number}/${number}/`;
141
- const { stdout } = await exec("git", ["ls-remote", "--refs", "origin", `${prefix}*`], options);
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
- if (!/^[a-f\d]{40,64}$/i.test(sha ?? "") || !ref?.startsWith(prefix)) return [];
145
- const revision = ref.slice(prefix.length);
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, home = homedir(),
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(home, ".jury", "checkouts", `jury-mr-${number}-`));
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 --no-push review with every commit already on disk still failed the
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
- `resolved merge request !${number}, but its source branch is not uniquely available on origin — ` +
339
- "use --no-push for a read-only review, or check out the source branch and pass --dir",
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
- // git's own words, not a guess. --quiet used to hide them and only the
356
- // first line survived, so a pruned head ref the usual cause — was
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.split("\n")[0]} — ` +
365
+ `could not resolve ${target.display} merge request !${number}: ${said}\n` +
363
366
  (missingRef
364
- ? `${target.host} has no refs/merge-requests/${number}/head; hosts prune it once a request ` +
365
- "is merged or old. Check out the source branch and pass --dir."
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, home,
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
- "use --no-push for a read-only review",
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(home, ".jury", "checkouts", `jury-pr-${number}-`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdance/codejury",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Independent AI reviewers that iterate until your pull request is clean.",
5
5
  "keywords": [
6
6
  "code-review",
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">Claude on the left, the reviewer on the right. Each reviewer is a
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">