@agentsdance/codejury 0.1.1 → 0.1.3

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
31
  fork), use `--no-push` for a read-only review or check out the source branch
29
- locally and pass `--dir`.
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
 
package/bin/jury.js CHANGED
@@ -56,7 +56,6 @@ Common flags
56
56
  --agents codex,grok only these reviewers (default: all installed)
57
57
  --judge codex one agent that triages and fixes (default: claude)
58
58
  --no-push fix locally, do not push
59
- --dry-run exercise the pipeline, spawn nothing
60
59
 
61
60
  Other commands
62
61
 
@@ -95,7 +94,9 @@ review (drives itself; no operator between rounds)
95
94
  --web open the console on this run (stays up when it ends)
96
95
  --port <n> console port, with --web (default 3080)
97
96
  --no-push commit fixes to the worktree without pushing
98
- --dry-run exercise the pipeline, spawn nothing
97
+ --dry-run (internal) exercise the pipeline, spawn no agents. Always
98
+ reports clean and triages nothing, so it says whether the
99
+ plumbing runs and never whether the code is good.
99
100
 
100
101
  review-once flags
101
102
  --dir <path> repo/worktree (default: cwd if Git, otherwise ~/.jury)
@@ -106,7 +107,6 @@ review-once flags
106
107
  --round <n> round number (default: next)
107
108
  --agents a,b only these reviewers (default: all enabled)
108
109
  --max-rounds <n> keep going until every reviewer approves, at most n (default 1)
109
- --dry-run do not spawn anything; exercise the pipeline
110
110
 
111
111
  web flags
112
112
  --dir <path> run-state root (default: cwd if Git, otherwise ~/.jury)
@@ -533,9 +533,18 @@ async function cmdAgent(argv) {
533
533
  }
534
534
 
535
535
  values.push = values.push && !values["no-push"];
536
- const requestedWorktree = await commandDirectory(values.dir);
536
+ const requestedWorktree = await commandDirectory(values.pr ? (values.dir ?? "") : values.dir);
537
+ // Two different questions, and conflating them cost the local path entirely.
538
+ // `root` is where the isolated checkout and run records are written; `dir` is
539
+ // the repository to resolve the request FROM. A caller sitting in the very
540
+ // repository under review already has its commits, and asking them for the
541
+ // one ref the host may have pruned — rather than reading what is on disk —
542
+ // is what issue #30 was.
543
+ const resolveFrom = values.dir ? path.resolve(values.dir) : process.cwd();
537
544
  const resolved = values.pr
538
- ? await resolvePrCheckout(values.pr, { allowPush: values.push })
545
+ ? await resolvePrCheckout(values.pr, {
546
+ allowPush: values.push, root: requestedWorktree, dir: resolveFrom,
547
+ })
539
548
  : null;
540
549
  const worktree = resolved?.worktree ?? requestedWorktree;
541
550
  resolvedCheckoutCleanup = resolved?.cleanup ?? null;
@@ -632,7 +641,7 @@ async function cmdAgent(argv) {
632
641
  // directory and nothing else: the server re-reads it per request and tails
633
642
  // the event log, so it sees each round land without the loop telling it.
634
643
  if (values.web) {
635
- const { url } = await serve({ port: Number(values.port), onLog: (m) => console.log(st.field("console", st.muted(m))) });
644
+ const { url } = await serve({ port: Number(values.port), cwd: requestedWorktree, onLog: (m) => console.log(st.field("console", st.muted(m))) });
636
645
  liveConsole = url;
637
646
  console.log(st.field("console", `${url}${st.muted(" → the conversation streams live")}`));
638
647
  openBrowser(url);
package/lib/repository.js CHANGED
@@ -5,8 +5,8 @@
5
5
  // PR's name. Keep this check independent of a hosting CLI so it also works for
6
6
  // self-hosted GitHub and GitLab instances.
7
7
  import { execFile } from "node:child_process";
8
- import { mkdtemp, rm } from "node:fs/promises";
9
- import { tmpdir } from "node:os";
8
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
9
+ import { homedir, tmpdir } from "node:os";
10
10
  import path from "node:path";
11
11
  import { promisify } from "node:util";
12
12
 
@@ -117,31 +117,231 @@ function branchAtHead(stdout, sha) {
117
117
 
118
118
  class MergeRequestCheckoutError extends Error {}
119
119
 
120
+ /**
121
+ * Fetch a merge request's head, falling back to numbered revision refs.
122
+ *
123
+ * Not every GitLab-compatible host publishes refs/merge-requests/<id>/head.
124
+ * Some expose the individual revisions instead, as
125
+ * refs/merge-requests/<shard>/<id>/<revision>, where the highest revision is the
126
+ * current head. Returns the advertised sha when that fallback was used, so the
127
+ * caller can check the fetched commit against what the remote said: a request
128
+ * updated mid-fetch would otherwise be reviewed as though it were the head.
129
+ */
130
+ async function fetchMergeRequestHead(exec, worktree, number) {
131
+ const options = { cwd: worktree };
132
+ try {
133
+ await exec("git", ["fetch", "origin", `refs/merge-requests/${number}/head`], options);
134
+ return null;
135
+ } catch (err) {
136
+ // Only a genuinely absent ref justifies looking elsewhere. An auth or
137
+ // transport failure keeps its own error, or every network problem would be
138
+ // reported as an unusual ref layout.
139
+ if (!/couldn't find remote ref/i.test(String(err.stderr ?? "") + String(err.message))) throw err;
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);
143
+ const revisions = String(stdout).split("\n").flatMap((line) => {
144
+ const [sha, ref] = line.trim().split(/\s+/);
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];
148
+ // Numeric, so revision 10 sorts above revision 9 rather than beside 1.
149
+ return /^[1-9]\d*$/.test(revision) ? [{ sha, ref, revision: BigInt(revision) }] : [];
150
+ });
151
+ revisions.sort((a, b) => (a.revision > b.revision ? -1 : a.revision < b.revision ? 1 : 0));
152
+ if (!revisions.length) throw err; // no revisions either: the original error is the true one
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
+ }
157
+ await exec("git", ["fetch", "--quiet", "origin", latest.ref], options);
158
+ return latest.sha;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Make origin/<trunk> resolvable in a clone taken from local disk. Best effort:
164
+ * offline the review may still fail, but it fails saying so rather than
165
+ * appearing to resolve and then diffing against a ref that is not there.
166
+ */
167
+ async function ensureTrunkRef(worktree, trunk, { exec }) {
168
+ try {
169
+ await exec("git", ["rev-parse", "--verify", `refs/remotes/origin/${trunk}^{commit}`], { cwd: worktree });
170
+ return;
171
+ } catch { /* not present: try to bring it in */ }
172
+ try {
173
+ await exec("git", ["fetch", "--quiet", "origin",
174
+ `refs/heads/${trunk}:refs/remotes/origin/${trunk}`], { cwd: worktree });
175
+ } catch { /* offline: leave it, the review reports the real failure */ }
176
+ }
177
+
178
+ /**
179
+ * Whether a repository already contains a specific commit — and is the
180
+ * repository the request actually names. The identity check is not optional:
181
+ * an unrelated checkout holding the same commit would be reviewed under this
182
+ * request's name.
183
+ */
184
+ async function hasCommit(dir, sha, { exec, target }) {
185
+ if (!dir || !sha || !await sameRepository(dir, target, { exec })) return false;
186
+ try {
187
+ await exec("git", ["-C", dir, "rev-parse", "--verify", `${sha}^{commit}`]);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The commit a merge request points at, if this machine already has it.
196
+ *
197
+ * The head ref is the fragile part of resolving a merge request: GitLab prunes
198
+ * refs/merge-requests/<id>/head once a request is merged or old, so fetching it
199
+ * fails for requests that are otherwise perfectly reviewable. A repository that
200
+ * already contains the commits does not need that ref at all — and reviewing a
201
+ * branch you already have is the common case, not the exception.
202
+ *
203
+ * Read-only by construction: this resolves a revision and never checks anything
204
+ * out, because `jury <url>` run from master must not move the caller's tree.
205
+ */
206
+ /**
207
+ * Whether a directory is a checkout of the repository the URL names.
208
+ *
209
+ * The URL names one repository; a local checkout of a DIFFERENT one that
210
+ * happens to hold the same ref or commit would review unrelated code under this
211
+ * request's name. Every local shortcut passes through here first.
212
+ */
213
+ async function sameRepository(dir, target, { exec }) {
214
+ if (!dir || !target) return false;
215
+ try {
216
+ const { stdout } = await exec("git", ["-C", dir, "remote", "-v"]);
217
+ return String(stdout).split("\n").some((line) => {
218
+ const url = line.split(/\s+/)[1];
219
+ return url && repositoryFromRemote(url)?.display === target.display;
220
+ });
221
+ } catch {
222
+ return false; // not a repository, or no remotes: nothing to match against
223
+ }
224
+ }
225
+
226
+ async function localHead(dir, target, number, { exec, namespace, branch = null }) {
227
+ if (!dir || !await sameRepository(dir, target, { exec })) return null;
228
+
229
+ // Only the numbered ref. FETCH_HEAD was also consulted here, and it is not
230
+ // evidence of anything: it holds whatever the last fetch left behind, which
231
+ // after `git fetch origin main` is main. That resolved the wrong commit under
232
+ // this request's name, and — since branchAtHead then matched main on the
233
+ // remote — could make main the push target of a review that never examined it.
234
+ try {
235
+ const { stdout } = await exec(
236
+ "git", ["-C", dir, "rev-parse", "--verify", `${namespace}/${number}/head^{commit}`],
237
+ );
238
+ if (stdout.trim()) return stdout.trim();
239
+ } catch { /* an ordinary clone has no numbered ref; try the branch below */ }
240
+
241
+ // The numbered ref is the exception, not the rule: a normal clone or a
242
+ // source-branch checkout has none of them, only branches. Requiring it made
243
+ // this path unreachable for exactly the people the error told to "check out
244
+ // the source branch and pass --dir" — advice that could not work.
245
+ if (!branch) return null;
246
+ for (const rev of [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]) {
247
+ try {
248
+ const { stdout } = await exec("git", ["-C", dir, "rev-parse", "--verify", `${rev}^{commit}`]);
249
+ if (stdout.trim()) return stdout.trim();
250
+ } catch { /* try the next candidate */ }
251
+ }
252
+ return null;
253
+ }
254
+
120
255
  async function resolveMergeRequestCheckout(prUrl, target, number, {
121
- allowPush, exec, makeTemp, remove,
256
+ allowPush, exec, makeTemp, remove, dir, root,
122
257
  }) {
123
- const worktree = await makeTemp(path.join(tmpdir(), `jury-mr-${number}-`));
258
+ // Where the clone lands when one is needed: the same ~/.jury the rest of the
259
+ // CLI already uses, rather than the system temp directory. The checkout is
260
+ // still removed when the review ends — this puts jury's working files under
261
+ // one predictable, inspectable root, and does not make them persist.
262
+ const worktree = await makeTemp(path.join(root, "checkouts", `jury-mr-${number}-`));
263
+ let advertisedSha = null;
264
+ // Refresh the cached ref before trusting it. A ref fetched before the author
265
+ // pushed again names an obsolete head, and nothing about matching repository
266
+ // identity makes it current — a silent review of superseded code. When the
267
+ // refresh fails (offline, pruned) the cached ref is still better than nothing,
268
+ // which is the whole point of the local path.
269
+ if (dir && await sameRepository(dir, target, { exec })) {
270
+ try {
271
+ await exec("git", ["-C", dir, "fetch", "--quiet", "origin",
272
+ `refs/merge-requests/${number}/head:refs/merge-requests/${number}/head`, "--force"]);
273
+ } catch { /* keep whatever is cached */ }
274
+ }
275
+ const local = await localHead(dir, target, number, { exec, namespace: "refs/merge-requests" });
124
276
  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 });
277
+ if (local) {
278
+ // The commits are already here. Clone from disk rather than the network:
279
+ // no credentials, no head ref, and nothing the host may have pruned.
280
+ await exec("git", ["clone", "--quiet", "--no-checkout", dir, worktree]);
281
+ await exec("git", ["checkout", "--quiet", "-b", `jury-mr-${number}`, local], { cwd: worktree });
282
+ // Cloning from disk points origin at a filesystem path. Left alone, the
283
+ // branch lookup below would read the local clone and pushTarget would
284
+ // push into it — a review that never reaches the real remote. Repoint
285
+ // origin at the repository the URL actually names.
286
+ await exec("git", ["remote", "set-url", "origin",
287
+ cloneUrlForMergeRequest(prUrl, target.path)], { cwd: worktree });
288
+ } else {
289
+ await exec("git", ["clone", "--quiet", "--no-checkout", cloneUrlForMergeRequest(prUrl, target.path), worktree]);
290
+ advertisedSha = await fetchMergeRequestHead(exec, worktree, number);
291
+ await exec("git", ["checkout", "--quiet", "-b", `jury-mr-${number}`, "FETCH_HEAD"], { cwd: worktree });
292
+ }
128
293
 
129
294
  const { stdout: actualOut } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
130
295
  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(
296
+ if (advertisedSha && sha !== advertisedSha) {
297
+ throw new MergeRequestCheckoutError(
298
+ `merge request !${number} changed while it was being fetched; run the review again`,
299
+ );
300
+ }
301
+ // origin/HEAD comes from the clone SOURCE. Cloning the caller's repository
302
+ // copies whatever they had checked out, so a user sitting on the request's
303
+ // own source branch got that branch reported as trunk — and the review then
304
+ // diffed the branch against itself and saw nothing. Ask the real remote
305
+ // when the clone came from disk; fall back to the copied ref offline.
306
+ let trunk = "";
307
+ if (local) {
308
+ try {
309
+ const { stdout } = await exec("git", ["ls-remote", "--symref", "origin", "HEAD"], { cwd: worktree });
310
+ trunk = String(stdout).match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m)?.[1] ?? "";
311
+ } catch { /* offline: fall through to the local ref */ }
312
+ }
313
+ if (!trunk && !local) {
314
+ // Only for a network clone, where origin/HEAD came from the host. In a
315
+ // clone taken from disk it was copied from whatever the caller had
316
+ // checked out — often this request's own source branch, which would make
317
+ // the review diff the branch against itself and report an empty change.
318
+ const { stdout: trunkOut } = await exec(
319
+ "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: worktree },
320
+ );
321
+ trunk = trunkOut.trim().replace(/^origin\//, "");
322
+ }
323
+ // A network clone with no default branch is a broken repository, and saying
324
+ // so is right. A local clone that simply could not reach the host is not:
325
+ // the CLI applies its own --trunk after this returns, so failing here would
326
+ // refuse a run the user had already told where trunk is. Leave it empty and
327
+ // let that flag win; the CLI reports a missing trunk itself if there is none.
328
+ if (!trunk && !local) throw new MergeRequestCheckoutError(
136
329
  `merge request !${number} repository has no default branch`,
137
330
  );
138
331
 
139
- const { stdout: headsOut } = await exec("git", ["ls-remote", "--heads", "origin"], { cwd: worktree });
140
- const branch = branchAtHead(headsOut, sha);
332
+ // Only when a push target is actually needed. This ran unconditionally, so
333
+ // a --no-push review with every commit already on disk still failed the
334
+ // moment the network was unreachable — the one case the local path exists
335
+ // to serve.
336
+ let branch = null;
337
+ if (allowPush) {
338
+ const { stdout: headsOut } = await exec("git", ["ls-remote", "--heads", "origin"], { cwd: worktree });
339
+ branch = branchAtHead(headsOut, sha);
340
+ }
141
341
  if (allowPush && !branch) {
142
342
  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",
343
+ `Can't determine where to push fixes for merge request !${number}.\n` +
344
+ "Add --no-push to review it without pushing changes.",
145
345
  );
146
346
  }
147
347
 
@@ -157,9 +357,16 @@ async function resolveMergeRequestCheckout(prUrl, target, number, {
157
357
  } catch (err) {
158
358
  await remove(worktree);
159
359
  if (err instanceof MergeRequestCheckoutError) throw err;
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.
362
+ const said = [err.stderr, err.message].map((t) => String(t ?? "").trim()).find(Boolean) ?? "";
363
+ const missingRef = /couldn't find remote ref|no matching remote head/i.test(said);
160
364
  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",
365
+ `could not resolve ${target.display} merge request !${number}: ${said}\n` +
366
+ (missingRef
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>."
369
+ : "check Git authentication and refs/merge-requests support, or check out the source branch and pass --dir"),
163
370
  );
164
371
  }
165
372
  }
@@ -185,15 +392,25 @@ function remoteForRepository(origin, repoPath) {
185
392
  export async function resolvePrCheckout(prUrl, {
186
393
  allowPush = true,
187
394
  exec = run,
188
- makeTemp = mkdtemp,
395
+ // Creates the parent too: a checkout under ~/.jury has a directory that may
396
+ // not exist yet, and a caller that injects this must not need the real one.
397
+ makeTemp = async (prefix) => {
398
+ await mkdir(path.dirname(prefix), { recursive: true });
399
+ return mkdtemp(prefix);
400
+ },
189
401
  remove = (dir) => rm(dir, { recursive: true, force: true }),
402
+ // The caller's checkout, when it has one. Consulted before the network: a
403
+ // repository that already holds the request's commits needs no head ref.
404
+ dir = null,
405
+ home = homedir(),
406
+ root = path.join(home, ".jury"),
190
407
  } = {}) {
191
408
  const target = repositoryFromPrUrl(prUrl);
192
409
  const number = githubNumber(prUrl);
193
410
  const mrNumber = mergeRequestNumber(prUrl);
194
411
  if (target && mrNumber) {
195
412
  return resolveMergeRequestCheckout(prUrl, target, mrNumber, {
196
- allowPush, exec, makeTemp, remove,
413
+ allowPush, exec, makeTemp, remove, dir, root,
197
414
  });
198
415
  }
199
416
  if (!target || !number) {
@@ -229,11 +446,33 @@ export async function resolvePrCheckout(prUrl, {
229
446
  );
230
447
  }
231
448
 
232
- const worktree = await makeTemp(path.join(tmpdir(), `jury-pr-${number}-`));
449
+ const worktree = await makeTemp(path.join(root, "checkouts", `jury-pr-${number}-`));
450
+ // `gh` already gave the authoritative head oid, so that — not a cached ref —
451
+ // is what to look for locally. Consulting refs/pull/<id>/head first checked
452
+ // out whatever it pointed at when it was last fetched, which then failed the
453
+ // assertion below: a stale ref blocked a review whose real head was sitting
454
+ // in the same repository.
455
+ // `gh` gives both the oid and the branch name, so a plain checkout of the PR
456
+ // branch resolves here without any refs/pull ref existing.
457
+ const local = dir && await hasCommit(dir, details.headRefOid, { exec, target })
458
+ ? details.headRefOid
459
+ : await localHead(dir, target, number, {
460
+ exec, namespace: "refs/pull", branch: details.headRefName,
461
+ });
233
462
  try {
234
- await exec("gh", ["repo", "clone", target.display, worktree, "--", "--quiet"]);
235
- await exec("git", ["fetch", "--quiet", "origin", `refs/pull/${number}/head`], { cwd: worktree });
236
- await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, "FETCH_HEAD"], { cwd: worktree });
463
+ if (local) {
464
+ await exec("git", ["clone", "--quiet", "--no-checkout", dir, worktree]);
465
+ await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, local], { cwd: worktree });
466
+ await exec("git", ["remote", "set-url", "origin", cloneUrlForMergeRequest(prUrl, target.path)],
467
+ { cwd: worktree });
468
+ // Same as the merge request path: a clone of a developer's checkout has
469
+ // their local branches, not origin/<base>, and every review prompt needs it.
470
+ await ensureTrunkRef(worktree, details.baseRefName, { exec });
471
+ } else {
472
+ await exec("gh", ["repo", "clone", target.display, worktree, "--", "--quiet"]);
473
+ await exec("git", ["fetch", "origin", `refs/pull/${number}/head`], { cwd: worktree });
474
+ await exec("git", ["checkout", "--quiet", "-b", `jury-pr-${number}`, "FETCH_HEAD"], { cwd: worktree });
475
+ }
237
476
 
238
477
  const { stdout: actual } = await exec("git", ["rev-parse", "HEAD"], { cwd: worktree });
239
478
  if (actual.trim() !== details.headRefOid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdance/codejury",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Independent AI reviewers that iterate until your pull request is clean.",
5
5
  "keywords": [
6
6
  "code-review",