prreviewbuddy 0.12.1 → 0.12.16

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.
Files changed (101) hide show
  1. package/dist/{log-PmeEgome.js → log-CFa7EzgZ.js} +465 -165
  2. package/dist/main.js +19 -7
  3. package/dist/server.js +852 -127
  4. package/package.json +1 -1
  5. package/static/chunk-5rwdhcVY.js +1 -0
  6. package/static/chunk-7inUl5Il.js +1 -0
  7. package/static/chunk-9-3VOpWX.js +24 -0
  8. package/static/chunk-B0xoFuVi.js +1 -0
  9. package/static/chunk-B80S6zbH.js +44 -0
  10. package/static/chunk-B8V5WPf1.js +292 -0
  11. package/static/chunk-BFRx3GcN.js +1 -0
  12. package/static/chunk-BFoDCU3G.js +1 -0
  13. package/static/chunk-BHBwEv5G.js +1 -0
  14. package/static/chunk-BOfOlrLQ.js +1 -0
  15. package/static/chunk-BSUrFSfn.js +2 -0
  16. package/static/chunk-BVDh-dl-.js +88 -0
  17. package/static/chunk-BVm3MgB2.js +139 -0
  18. package/static/chunk-BVqE4wlY.js +1 -0
  19. package/static/chunk-BZFc_oT5.js +1 -0
  20. package/static/chunk-Bd8oAEBF.js +62 -0
  21. package/static/chunk-Bdvr7G0w.js +1 -0
  22. package/static/chunk-BfpeM2_Y.js +1 -0
  23. package/static/chunk-BifhSqXX.js +1 -0
  24. package/static/chunk-Bk4d_k6M.js +62 -0
  25. package/static/chunk-BmkZxg03.js +1 -0
  26. package/static/chunk-BoTbRR8S.js +4 -0
  27. package/static/chunk-BokiwdgS.js +40 -0
  28. package/static/chunk-BqFMUyVw.js +168 -0
  29. package/static/chunk-Bs98n_vr.js +1 -0
  30. package/static/chunk-BvO9qrHb.js +1 -0
  31. package/static/chunk-BxpAJfRF.js +36 -0
  32. package/static/chunk-C-OQMol4.js +1 -0
  33. package/static/chunk-C26MXXwi.js +1 -0
  34. package/static/chunk-C6X32c5Y.js +1 -0
  35. package/static/chunk-CGd-uY3x.js +321 -0
  36. package/static/chunk-CMKpUUG0.js +1 -0
  37. package/static/chunk-CNXFALfL.js +89 -0
  38. package/static/chunk-CQfKfv79.js +1 -0
  39. package/static/chunk-CQhDWCuO.js +1 -0
  40. package/static/chunk-CU_yc7c0.js +7 -0
  41. package/static/chunk-CUsQ7lH1.js +34 -0
  42. package/static/chunk-CXMH3UgJ.js +257 -0
  43. package/static/chunk-CZus5tIC.js +1 -0
  44. package/static/chunk-CeiETtR3.js +127 -0
  45. package/static/chunk-ChQTsUB2.js +30 -0
  46. package/static/chunk-CiB0fz5M.js +1 -0
  47. package/static/chunk-CmOPaNov.js +1 -0
  48. package/static/chunk-CoIQ0XcM.js +8 -0
  49. package/static/chunk-Cp5EDur4.js +38 -0
  50. package/static/chunk-CqMjOmHM.js +1 -0
  51. package/static/chunk-CqbP8tav.js +231 -0
  52. package/static/chunk-Crk3W7xi.js +1 -0
  53. package/static/chunk-CyJ4bWgC.js +1 -0
  54. package/static/chunk-D289T3W2.js +39 -0
  55. package/static/chunk-D3k4QmBl.js +162 -0
  56. package/static/chunk-D5VxBvZv.js +7 -0
  57. package/static/chunk-D7FarRGf.js +1 -0
  58. package/static/chunk-D87EWWOh.js +206 -0
  59. package/static/chunk-DBVuY2lB.js +96 -0
  60. package/static/chunk-DEKSwylx.js +10 -0
  61. package/static/chunk-DHkjHDYG.js +1 -0
  62. package/static/chunk-DHwB1DNv.js +161 -0
  63. package/static/chunk-DQT7Z75O.js +99 -0
  64. package/static/chunk-DS17s2tU.js +1 -0
  65. package/static/chunk-DTTB1Rjf.js +1 -0
  66. package/static/chunk-DVXDUcQZ.js +1 -0
  67. package/static/chunk-DdmKGZXf.js +1 -0
  68. package/static/chunk-Dex9JiR1.js +3 -0
  69. package/static/chunk-DfMSIask.js +1 -0
  70. package/static/chunk-DfcNMhQb.js +41 -0
  71. package/static/chunk-Dh6xxjov.js +1 -0
  72. package/static/chunk-Dk6P4vVY.js +1 -0
  73. package/static/chunk-DlMt7LBR.js +1 -0
  74. package/static/chunk-DlPtUH2x.js +24 -0
  75. package/static/chunk-DmHr41SG.js +72 -0
  76. package/static/chunk-DpHRgEqx.js +1 -0
  77. package/static/chunk-DqZGGQx6.js +2 -0
  78. package/static/chunk-DsF7k-Jl.js +1 -0
  79. package/static/chunk-DtQPBrIL.js +129 -0
  80. package/static/chunk-DufL1VKq.js +1 -0
  81. package/static/chunk-DxSMoRAg.js +1 -0
  82. package/static/chunk-Dy-Kn_BL.js +1 -0
  83. package/static/chunk-H9AITnpH.js +1 -0
  84. package/static/chunk-IOULrYuo.js +7 -0
  85. package/static/chunk-IsizPYbz.js +70 -0
  86. package/static/chunk-LHD4D-55.js +1 -0
  87. package/static/chunk-MLIlWKCY.js +1 -0
  88. package/static/chunk-SWs44m7G.js +1 -0
  89. package/static/chunk-Wi1hfKnY.js +15 -0
  90. package/static/chunk-dQvmgBo8.js +106 -0
  91. package/static/chunk-fybaL0A-.js +1 -0
  92. package/static/chunk-lpcXIlNC.js +84 -0
  93. package/static/chunk-nKL3T2Rn.js +78 -0
  94. package/static/chunk-oZJp8_Oh.js +1 -0
  95. package/static/chunk-oxFaA6rD.js +120 -0
  96. package/static/chunk-pzXl7dPH.js +1 -0
  97. package/static/chunk-qMjAlJP9.js +1 -0
  98. package/static/chunk-qnagBFlM.js +1 -0
  99. package/static/shell.css +372 -41
  100. package/static/sidebar.css +140 -4
  101. package/static/workspace.js +0 -0
@@ -10,6 +10,13 @@ import { platform } from "node:process";
10
10
  //#region ../../packages/review-harness/src/agents/agent.ts
11
11
  /** The agent could not be run at all, as distinct from running and failing. */
12
12
  var AgentUnavailableError = class extends Error {};
13
+ /**
14
+ * The caller stopped waiting, so the run was stopped.
15
+ *
16
+ * Its own class rather than a message, because every caller has to tell it from a failure: a run
17
+ * the reviewer cancelled is not an error to report to them, and it is not a turn to store.
18
+ */
19
+ var AgentCancelledError = class extends Error {};
13
20
  /** The run was refused before it started, because it would have breached isolation. */
14
21
  var PromptIsolationError = class extends Error {};
15
22
  /**
@@ -81,6 +88,10 @@ var CLAUDE_AGENT_ID = "claude";
81
88
  * about sign-in to errors that have nothing to do with it, which is worse than staying quiet.
82
89
  */
83
90
  var AUTHENTICATION = /\b(?:auth(?:enticat\w*)?|oauth|credential|session expired|not logged in|log ?in)\b/i;
91
+ /** Said to the caller that asked for the stop, and never shown to the reviewer who pressed it. */
92
+ var STOPPED = "The agent was stopped.";
93
+ /** How long a stopped agent gets to end on its own terms before it is ended for it. */
94
+ var SIGKILL_GRACE_MS = 2e3;
84
95
  /** The two variables that decide which Claude answers, and nothing else. */
85
96
  var CARRIED = ["CLAUDE_CONFIG_DIR", "PATH"];
86
97
  /**
@@ -302,6 +313,7 @@ var claudeAgent = {
302
313
  },
303
314
  async run(input) {
304
315
  guardPrompt(input);
316
+ if (input.signal?.aborted) throw new AgentCancelledError(STOPPED);
305
317
  const reader = createStreamReader(input.onProgress ?? (() => {}));
306
318
  return new Promise((resolvePromise, reject) => {
307
319
  const child = spawn("claude", claudeArgs(input.capability), {
@@ -318,6 +330,14 @@ var claudeAgent = {
318
330
  child.kill("SIGKILL");
319
331
  reject(/* @__PURE__ */ new Error("The agent did not finish in time and was stopped."));
320
332
  }, input.timeoutMs ?? DEFAULT_TIMEOUT_MS[input.capability]);
333
+ const stop = () => {
334
+ clearTimeout(timer);
335
+ child.kill("SIGTERM");
336
+ setTimeout(() => child.kill("SIGKILL"), SIGKILL_GRACE_MS).unref();
337
+ reject(new AgentCancelledError(STOPPED));
338
+ };
339
+ input.signal?.addEventListener("abort", stop, { once: true });
340
+ const release = () => input.signal?.removeEventListener("abort", stop);
321
341
  child.stdout.setEncoding("utf8");
322
342
  child.stdout.on("data", (chunk) => reader.push(chunk));
323
343
  child.stderr.setEncoding("utf8");
@@ -326,11 +346,13 @@ var claudeAgent = {
326
346
  });
327
347
  child.on("error", (error) => {
328
348
  clearTimeout(timer);
349
+ release();
329
350
  if (error.code === "ENOENT") return reject(new ClaudeUnavailableError("The `claude` command is not on PATH, so this cannot be run from here. Install Claude Code, or start the workspace from a shell where `claude` runs."));
330
351
  reject(error);
331
352
  });
332
353
  child.on("close", () => {
333
354
  clearTimeout(timer);
355
+ release();
334
356
  reader.end();
335
357
  if (reader.overflowed()) return reject(/* @__PURE__ */ new Error("The agent produced more output than could be read, so it was stopped."));
336
358
  const failure = reader.failed();
@@ -1193,6 +1215,73 @@ function inferLanguage(files) {
1193
1215
  }[topExt] ?? topExt;
1194
1216
  }
1195
1217
  //#endregion
1218
+ //#region ../../packages/review-harness/src/domain/ai_review/outstanding.ts
1219
+ function reviewerDispositions(session) {
1220
+ const byId = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
1221
+ return {
1222
+ isDismissed: (issue) => byId.has(issue.id),
1223
+ isStale: (issue) => issue.reconciliationStatus === "affected" && (byId.get(issue.id)?.sessionId ?? session.id) !== session.id
1224
+ };
1225
+ }
1226
+ /**
1227
+ * An issue is done when the commits fixed it, or when the reviewer dismissed it and that decision
1228
+ * still stands.
1229
+ *
1230
+ * `still-open` and `new` are outstanding unless dismissed. `affected` is the one worth stating: the
1231
+ * code under it moved without settling it, so it needs the reviewer's eyes *more* than an untouched
1232
+ * finding does — and that outranks a dismissal made before the code moved. The finding comes back
1233
+ * into the count **carrying its dismissal**, which the card shows: the reviewer's decision is not
1234
+ * deleted, its applicability has expired, and re-affirming it closes the finding again.
1235
+ */
1236
+ function isIssueOutstanding(issue, dispositions) {
1237
+ if (issue.reconciliationStatus === "resolved") return false;
1238
+ if (dispositions.isDismissed(issue)) return dispositions.isStale(issue);
1239
+ return true;
1240
+ }
1241
+ /**
1242
+ * A question is done when the reviewer has ticked it, or when the new commits answered it.
1243
+ *
1244
+ * Either alone is enough. The reviewer's tick is the older of the two and the only one the first
1245
+ * analysis can produce; `reconciliationStatus === 'answered'` arrives with an update and carries
1246
+ * `reconciliationAnswer` beside it, which is the answer itself. Requiring the tick as well would
1247
+ * make the reviewer hand-close seven questions the page had already answered in front of them.
1248
+ *
1249
+ * No `affected` expiry here, unlike an issue's dismissal. An issue is a claim about the code and a
1250
+ * dismissal is a bet on that code; a question the reviewer has answered was answered out of their
1251
+ * own head, and a commit somewhere near it does not un-know what they now know.
1252
+ */
1253
+ function isQuestionOutstanding(question, answered) {
1254
+ return !answered.has(question.id) && question.reconciliationStatus !== "answered";
1255
+ }
1256
+ function tally(items, outstanding, byReviewer) {
1257
+ let open = 0;
1258
+ let reviewerClosed = 0;
1259
+ for (const item of items) if (outstanding(item)) open += 1;
1260
+ else if (byReviewer(item)) reviewerClosed += 1;
1261
+ return {
1262
+ total: items.length,
1263
+ open,
1264
+ done: items.length - open,
1265
+ reviewerClosed
1266
+ };
1267
+ }
1268
+ function issuesOutstanding(issues, dispositions) {
1269
+ return tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && dispositions.isDismissed(issue));
1270
+ }
1271
+ function questionsOutstanding(questions, answered) {
1272
+ return tally(questions, (question) => isQuestionOutstanding(question, answered), (question) => question.reconciliationStatus !== "answered" && answered.has(question.id));
1273
+ }
1274
+ /**
1275
+ * What to call the pile of findings nobody has to act on any more.
1276
+ *
1277
+ * "Resolved" is a claim about the code and is only true while the commits did all the closing. One
1278
+ * dismissal in there and the honest word is the vaguer one, because the reviewer deciding a finding
1279
+ * is acceptable did not resolve anything.
1280
+ */
1281
+ function doneVerb(counts) {
1282
+ return counts.reviewerClosed > 0 ? "dealt with" : "resolved";
1283
+ }
1284
+ //#endregion
1196
1285
  //#region ../../packages/review-harness/src/workspace/store.ts
1197
1286
  var STORE_ROOT = join(homedir(), ".prreviewbuddy");
1198
1287
  var WORKSPACES_DIR = join(STORE_ROOT, "workspaces");
@@ -1335,8 +1424,9 @@ function summarise(workspace) {
1335
1424
  summary: result?.summary ?? "",
1336
1425
  overallRisk: result?.overallRisk ?? "unknown",
1337
1426
  issues: result?.issues.length ?? 0,
1427
+ openIssues: issuesOutstanding(result?.issues ?? [], reviewerDispositions(workspace.session)).open,
1338
1428
  questions: questions.length,
1339
- unansweredQuestions: questions.filter((question) => !answered.has(question.id)).length,
1429
+ unansweredQuestions: questionsOutstanding(questions, answered).open,
1340
1430
  messages: workspace.transcript.length,
1341
1431
  lastViewedAt: workspace.lastViewedAt,
1342
1432
  completedAt: workspace.completedAt,
@@ -1348,6 +1438,16 @@ function summarise(workspace) {
1348
1438
  };
1349
1439
  }
1350
1440
  /**
1441
+ * The commit a review was made from, however the record spells it. Null when it recorded none.
1442
+ *
1443
+ * Lives here rather than beside one of its callers because it is a reading of the record, and the
1444
+ * two things that need it are unrelated: pinning a checkout to answer questions against, and
1445
+ * reading a single file the reviewer's own checkout does not have.
1446
+ */
1447
+ function reviewedCommit(workspace) {
1448
+ return workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
1449
+ }
1450
+ /**
1351
1451
  * Record that a workspace was opened.
1352
1452
  *
1353
1453
  * Best-effort and deliberately silent: a review that cannot record having been looked at is still
@@ -1456,6 +1556,7 @@ async function buildChangeSet(options) {
1456
1556
  },
1457
1557
  repoPath,
1458
1558
  baseRef: displayRef(baseRef),
1559
+ baseResolvedRef: baseRef,
1459
1560
  headRef,
1460
1561
  mergeBase,
1461
1562
  includesUncommitted: false,
@@ -1849,6 +1950,90 @@ function resumeFrom(job) {
1849
1950
  return WORK_PHASES.find((phase) => !job.completed.includes(phase)) ?? "analysing";
1850
1951
  }
1851
1952
  //#endregion
1953
+ //#region ../../packages/review-harness/src/workspace/request_branch.ts
1954
+ /**
1955
+ * Where the change under review is now.
1956
+ *
1957
+ * One question, asked by two things that must never answer it differently. `review_update.ts` asks
1958
+ * it to know what to reconcile against; `freshness.ts` asks it to know whether the findings still
1959
+ * describe the change. When those two disagree the product contradicts itself on one screen: the
1960
+ * strip says the code was rewritten and the button says the review is already current, and the
1961
+ * reviewer has no way to tell which of them is lying.
1962
+ *
1963
+ * The refs are tried in the order that best answers it, and the first that resolves wins:
1964
+ *
1965
+ * 1. `refs/remotes/origin/<the request's branch>` — the pull request's own branch, as the forge
1966
+ * named it. A plain `git fetch` writes this for every branch on origin, and the review path
1967
+ * already ran one to get the head commit here at all.
1968
+ * 2. `refs/remotes/<the request's branch>` — the same thing on a remote that is not called origin.
1969
+ * 3. `<the review's branch>@{upstream}` — whatever the local branch tracks, resolved without
1970
+ * reading HEAD, which matters because nothing here is entitled to care what is checked out.
1971
+ *
1972
+ * Nothing is guessed past that. A pull request from a fork has its branch on a remote this
1973
+ * repository does not have, and the honest answer there is that we cannot see where the change is,
1974
+ * not a comparison against some other branch that happens to be at hand.
1975
+ *
1976
+ * Read-only, and it has to stay that way: `freshness.ts` may reach nothing that writes or fetches,
1977
+ * and `freshness_read_only.test.ts` scans this file for the same reason it scans that one.
1978
+ */
1979
+ function requestBranchRefs(branch, remoteHeadRef) {
1980
+ return remoteHeadRef ? [
1981
+ `refs/remotes/origin/${remoteHeadRef}`,
1982
+ `refs/remotes/${remoteHeadRef}`,
1983
+ `${branch}@{upstream}`
1984
+ ] : [`${branch}@{upstream}`];
1985
+ }
1986
+ async function resolveRequestBranch(repoPath, branch, remoteHeadRef) {
1987
+ for (const ref of requestBranchRefs(branch, remoteHeadRef)) {
1988
+ const sha = await refSha(repoPath, ref);
1989
+ if (sha) return {
1990
+ sha,
1991
+ ref: await shortName(repoPath, ref) ?? ref,
1992
+ remote: true
1993
+ };
1994
+ }
1995
+ return null;
1996
+ }
1997
+ /**
1998
+ * The name to show for a ref that has already resolved.
1999
+ *
2000
+ * Asked of git rather than trimmed off the string, because the two candidate forms need opposite
2001
+ * treatments and only git knows the answer to the second: `refs/remotes/origin/x` is `origin/x` by
2002
+ * removing a prefix, and `x@{upstream}` is also `origin/x`, which no amount of trimming produces.
2003
+ */
2004
+ async function shortName(repoPath, ref) {
2005
+ try {
2006
+ return (await git(repoPath, [
2007
+ "rev-parse",
2008
+ "--abbrev-ref",
2009
+ ref
2010
+ ])).trim() || null;
2011
+ } catch {
2012
+ return null;
2013
+ }
2014
+ }
2015
+ /** A local branch by its full path, so a ref that is really a sha cannot be mistaken for one. */
2016
+ async function localBranch(repoPath, branch) {
2017
+ const sha = await refSha(repoPath, `refs/heads/${branch}`);
2018
+ return sha ? {
2019
+ sha,
2020
+ ref: branch,
2021
+ remote: false
2022
+ } : null;
2023
+ }
2024
+ async function refSha(repoPath, ref) {
2025
+ try {
2026
+ return (await git(repoPath, [
2027
+ "rev-parse",
2028
+ "--verify",
2029
+ "--quiet",
2030
+ `${ref}^{commit}`
2031
+ ])).trim() || null;
2032
+ } catch {
2033
+ return null;
2034
+ }
2035
+ }
2036
+ //#endregion
1852
2037
  //#region ../../packages/review-harness/src/workspace/freshness.ts
1853
2038
  /**
1854
2039
  * Is this review still describing the code in front of you?
@@ -1862,6 +2047,10 @@ function resumeFrom(job) {
1862
2047
  * come from three different places:
1863
2048
  *
1864
2049
  * 1. **Has the code moved?** Local git against the commit the review was made from. Free.
2050
+ * What it is compared *to* is the branch this review is of, never whatever happens to be
2051
+ * checked out. A pull request is reviewed in a checkout of its own and the reviewer never
2052
+ * stands on its branch, so HEAD is a different change entirely: measuring against it announced
2053
+ * "Code rewritten, 343 files differ" about a review that was perfectly current.
1865
2054
  * 2. **Is the checkout what the pull request had?** The stored head sha against local HEAD.
1866
2055
  * Answerable offline precisely because the sha was written down at import time.
1867
2056
  * 3. **Has anyone commented since?** Not answerable here at all. The daemon reaches no forge,
@@ -1891,7 +2080,7 @@ async function checkFreshness(workspace) {
1891
2080
  } catch {}
1892
2081
  return {
1893
2082
  checkedAt: Date.now(),
1894
- code: await codeFreshness(workspace, reviewedSha, headSha),
2083
+ code: await codeFreshness(workspace, reviewedSha, headSha, await latest(workspace)),
1895
2084
  checkout: await checkoutFreshness(workspace, headSha),
1896
2085
  conversation: workspace.prContext ? {
1897
2086
  attached: true,
@@ -1900,17 +2089,45 @@ async function checkFreshness(workspace) {
1900
2089
  } : { attached: false }
1901
2090
  };
1902
2091
  }
1903
- async function codeFreshness(workspace, reviewedSha, headSha) {
2092
+ /**
2093
+ * The branch this review is of, and where it currently is.
2094
+ *
2095
+ * The comparison the whole panel rests on, and the one that used to read `git rev-parse HEAD`. That
2096
+ * was right while every review was of the checkout you were standing in. It stopped being right the
2097
+ * day pull requests were reviewed in a worktree of their own: the reviewer never checks that branch
2098
+ * out, so HEAD is some unrelated branch and the distance to it is not a fact about this review.
2099
+ *
2100
+ * A pull request is measured against its own branch as this repository last saw it. A branch review
2101
+ * is measured against that branch by name, `refs/heads/<name>` rather than HEAD, so switching
2102
+ * branches to look at something else does not make the review look rewritten. Neither reads HEAD,
2103
+ * and that is deliberate: what is checked out is a fact about the reviewer, not about the change.
2104
+ *
2105
+ * Null when the branch is not in this repository at all, which is a real answer with two ordinary
2106
+ * causes: a pull request from a fork, whose branch lives on a remote this clone does not have, and
2107
+ * a branch deleted after it merged. Neither is a licence to compare against something else.
2108
+ */
2109
+ async function latest(workspace) {
2110
+ const branch = workspace.changeSet.headRef;
2111
+ const requestRef = workspace.prContext?.pullRequest.headRef;
2112
+ if (requestRef) return resolveRequestBranch(workspace.repoPath, branch, requestRef);
2113
+ return await localBranch(workspace.repoPath, branch) ?? resolveRequestBranch(workspace.repoPath, branch);
2114
+ }
2115
+ async function codeFreshness(workspace, reviewedSha, readable, latest) {
2116
+ const latestSha = latest?.sha ?? null;
1904
2117
  const base = {
1905
2118
  state: "unknown",
1906
2119
  reviewedSha,
1907
- headSha,
2120
+ latestSha,
2121
+ comparison: latest ? {
2122
+ ref: latest.ref,
2123
+ remote: latest.remote
2124
+ } : null,
1908
2125
  newCommits: null,
1909
2126
  changedFiles: null,
1910
2127
  rewritten: false,
1911
2128
  reason: null
1912
2129
  };
1913
- if (!headSha || !reviewedSha) return {
2130
+ if (!readable || !reviewedSha) return {
1914
2131
  ...base,
1915
2132
  reason: "git-failed"
1916
2133
  };
@@ -1919,14 +2136,18 @@ async function codeFreshness(workspace, reviewedSha, headSha) {
1919
2136
  ...base,
1920
2137
  reason: "missing-commit"
1921
2138
  };
1922
- if (headSha !== reviewedSha) {
1923
- const rewritten = !await isAncestor(repoPath, reviewedSha, headSha);
2139
+ if (!latestSha) return {
2140
+ ...base,
2141
+ reason: "no-branch"
2142
+ };
2143
+ if (latestSha !== reviewedSha) {
2144
+ const rewritten = !await isAncestor(repoPath, reviewedSha, latestSha);
1924
2145
  return {
1925
2146
  ...base,
1926
2147
  state: "changed",
1927
2148
  rewritten,
1928
- newCommits: rewritten ? null : await countCommits(repoPath, reviewedSha, headSha),
1929
- changedFiles: await countChangedFiles(repoPath, reviewedSha, headSha)
2149
+ newCommits: rewritten ? null : await countCommits(repoPath, reviewedSha, latestSha),
2150
+ changedFiles: await countChangedFiles(repoPath, reviewedSha, latestSha)
1930
2151
  };
1931
2152
  }
1932
2153
  if (workspace.changeSet.includesUncommitted) return {
@@ -5154,113 +5375,6 @@ async function applyGuide(workspaceId, payload) {
5154
5375
  });
5155
5376
  }
5156
5377
  //#endregion
5157
- //#region ../../packages/review-harness/src/workspace/landing.ts
5158
- /**
5159
- * Long enough for a loopback round trip many times over. Short because nothing rests on it: when
5160
- * it expires the reader gets a link rather than a verdict.
5161
- */
5162
- var PROBE_TIMEOUT_MS = 1500;
5163
- /**
5164
- * The landing page, as a string.
5165
- *
5166
- * Pure, and separated from writing it for the usual reason: what this page says when the server is
5167
- * down is the half that is hard to look at by hand, because reproducing it means killing a daemon.
5168
- */
5169
- /**
5170
- * A record with no token renders as stopped, and that is not a lie anyone can be told: nothing
5171
- * writes such a record. `cli/server.ts` mints a token before it listens, and `healthyPort` stops a
5172
- * server whose version does not match this build, which is the only way one could ever be seen.
5173
- * The case is here because the field is optional in the type, not because it is a state.
5174
- */
5175
- function renderLanding(record) {
5176
- const url = record?.token ? indexUrl(record) : null;
5177
- return url ? running(record, url) : stopped();
5178
- }
5179
- function indexUrl(record) {
5180
- return `http://127.0.0.1:${record.port}/?t=${encodeURIComponent(record.token ?? "")}`;
5181
- }
5182
- function running(record, url) {
5183
- return page("Opening your reviews", `<h1>Opening your reviews</h1>
5184
- <p class="prb-quiet" id="waiting">Looking for the workspace server on port ${record.port}.</p>
5185
- <div id="gone" hidden>
5186
- <p><a class="prb-go" href="${escapeHtml(url)}">Open your reviews on port ${record.port}</a></p>
5187
- <p>This page could not confirm the server was answering, so it has not sent you there on its
5188
- own. Follow the link and see.</p>
5189
- <p>If it does not open, the server has stopped since this page was written. Nothing is lost:
5190
- every review is on disk in <code>~/.prreviewbuddy/workspaces</code>. Run
5191
- <code>/prreviewbuddy:start-server</code> in Claude Code, and this page will point at the new
5192
- server by the time you reload it.</p>
5193
- </div>
5194
- <script>
5195
- // An image, not a fetch: a file:// page cannot read a cross-origin response, but it can be
5196
- // told whether one arrived. /assets is the only route that answers without a credential.
5197
- var settled = false;
5198
- var probe = new Image();
5199
- var give_up = setTimeout(function () { fail(); }, ${PROBE_TIMEOUT_MS});
5200
- function fail() {
5201
- if (settled) return;
5202
- settled = true;
5203
- clearTimeout(give_up);
5204
- document.getElementById('waiting').hidden = true;
5205
- document.getElementById('gone').hidden = false;
5206
- }
5207
- probe.onload = function () {
5208
- if (settled) return;
5209
- settled = true;
5210
- clearTimeout(give_up);
5211
- // replace, not assign: going back should leave the reviews, not bounce through here again.
5212
- window.location.replace(${JSON.stringify(url)});
5213
- };
5214
- probe.onerror = fail;
5215
- probe.src = ${JSON.stringify(`http://127.0.0.1:${record.port}/assets/icon128.png?probe=`)} + Date.now();
5216
- <\/script>`);
5217
- }
5218
- function stopped() {
5219
- return page("Your reviews", `<h1>The workspace server is not running</h1>
5220
- <p>Every review is still on disk in <code>~/.prreviewbuddy/workspaces</code>, with its guide,
5221
- its diff and its conversation.</p>
5222
- <p>Run <code>/prreviewbuddy:start-server</code> in Claude Code to serve them again. This page
5223
- is rewritten when it starts, so keep the bookmark and reload it then.</p>`);
5224
- }
5225
- function page(title, body) {
5226
- return `<!doctype html>
5227
- <html lang="en">
5228
- <head>
5229
- <meta charset="utf-8">
5230
- <meta name="viewport" content="width=device-width, initial-scale=1">
5231
- <title>${escapeHtml(title)} · PR Review Buddy</title>
5232
- <style>
5233
- :root { color-scheme: light dark; --prb-ink: #1b1b1f; --prb-paper: #fbfbfd; --prb-quiet: #5c5c66; }
5234
- @media (prefers-color-scheme: dark) {
5235
- :root { --prb-ink: #e8e8ec; --prb-paper: #17171b; --prb-quiet: #9a9aa4; }
5236
- }
5237
- body {
5238
- margin: 0; min-height: 100vh; display: grid; place-items: center;
5239
- background: var(--prb-paper); color: var(--prb-ink);
5240
- font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
5241
- }
5242
- main { max-width: 34rem; padding: 2rem; }
5243
- h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
5244
- p { margin: 0 0 0.75rem; }
5245
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
5246
- .prb-quiet { color: var(--prb-quiet); }
5247
- .prb-go { font-weight: 600; }
5248
- [hidden] { display: none; }
5249
- </style>
5250
- </head>
5251
- <body><main>${body}</main></body>
5252
- </html>
5253
- `;
5254
- }
5255
- /**
5256
- * The token is minted from `base64url` and cannot contain any of these, and it is escaped anyway.
5257
- * A credential is the last value in this product to be interpolated on the strength of what it is
5258
- * currently generated from.
5259
- */
5260
- function escapeHtml(value) {
5261
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5262
- }
5263
- //#endregion
5264
5378
  //#region ../../packages/review-harness/src/domain/feedback/feedback_url.ts
5265
5379
  /** How each client names itself in the prefilled message, which is the half a screenshot carries. */
5266
5380
  var CLIENT_LABELS = {
@@ -5400,16 +5514,188 @@ function feedbackUrl() {
5400
5514
  });
5401
5515
  }
5402
5516
  //#endregion
5517
+ //#region ../../packages/review-harness/src/workspace/way_back.ts
5518
+ /**
5519
+ * How a reviewer gets back to the list of their reviews.
5520
+ *
5521
+ * Three places have to answer this, and until now all three answered it for the plugin: the
5522
+ * refusal at `/`, and both states of the bookmark page. A reviewer who installed the CLI was told
5523
+ * to run `/prreviewbuddy:start-server`, which is a Claude Code slash command they do not have,
5524
+ * from a product that has never mentioned Claude Code to them. Every one of those readers is
5525
+ * already stuck, which is the worst moment to be handed a command that does not exist.
5526
+ *
5527
+ * The build knows which product it is. `SURFACE.client` is read from the `package.json` beside the
5528
+ * running code, and each app bundles its own copy of the workspace server, so the server that
5529
+ * refuses the request is the CLI's server or the plugin's server and can say so. A build that
5530
+ * cannot name itself names both, which is never wrong and is only slightly longer.
5531
+ *
5532
+ * The bookmark is here too, because it is the other half of the same answer and the durable half:
5533
+ * the command is what unsticks this browser now, and the bookmark is what stops the question being
5534
+ * asked again after the next restart.
5535
+ */
5536
+ /**
5537
+ * The one address in this product that never changes.
5538
+ *
5539
+ * Rewritten by `daemon.ts` at both moments the truth changes. It lives here rather than there
5540
+ * because the pages that tell a reviewer about it must not have to import the daemon to do it.
5541
+ */
5542
+ var LANDING_PAGE = join(STORE_ROOT, "reviews.html");
5543
+ var CLI = {
5544
+ command: "prreviewbuddy open",
5545
+ where: "your terminal"
5546
+ };
5547
+ var PLUGIN = {
5548
+ command: "/prreviewbuddy:start-server",
5549
+ where: "Claude Code"
5550
+ };
5551
+ function wayBack() {
5552
+ return {
5553
+ commands: SURFACE.client === "cli" ? [CLI] : SURFACE.client === "plugin" ? [PLUGIN] : [CLI, PLUGIN],
5554
+ bookmark: LANDING_PAGE
5555
+ };
5556
+ }
5557
+ /** The commands as one sentence, for somewhere with no room to make a list of them. */
5558
+ function wayBackSentence(way = wayBack()) {
5559
+ return way.commands.map(({ command, where }) => `${command} in ${where}`).join(", or ");
5560
+ }
5561
+ //#endregion
5562
+ //#region ../../packages/review-harness/src/workspace/landing.ts
5563
+ /**
5564
+ * Long enough for a loopback round trip many times over. Short because nothing rests on it: when
5565
+ * it expires the reader gets a link rather than a verdict.
5566
+ */
5567
+ var PROBE_TIMEOUT_MS = 1500;
5568
+ /**
5569
+ * The landing page, as a string.
5570
+ *
5571
+ * Pure, and separated from writing it for the usual reason: what this page says when the server is
5572
+ * down is the half that is hard to look at by hand, because reproducing it means killing a daemon.
5573
+ */
5574
+ /**
5575
+ * A record with no token renders as stopped, and that is not a lie anyone can be told: nothing
5576
+ * writes such a record. `cli/server.ts` mints a token before it listens, and `healthyPort` stops a
5577
+ * server whose version does not match this build, which is the only way one could ever be seen.
5578
+ * The case is here because the field is optional in the type, not because it is a state.
5579
+ */
5580
+ function renderLanding(record) {
5581
+ const url = record?.token ? indexUrl(record) : null;
5582
+ return url ? running(record, url) : stopped();
5583
+ }
5584
+ function indexUrl(record) {
5585
+ return `http://127.0.0.1:${record.port}/?t=${encodeURIComponent(record.token ?? "")}`;
5586
+ }
5587
+ function running(record, url) {
5588
+ return page("Opening your reviews", `<h1>Opening your reviews</h1>
5589
+ <p class="prb-quiet" id="waiting">Looking for the workspace server on port ${record.port}.</p>
5590
+ <div id="gone" hidden>
5591
+ <p><a class="prb-go" href="${escapeHtml(url)}">Open your reviews on port ${record.port}</a></p>
5592
+ <p>This page could not confirm the server was answering, so it has not sent you there on its
5593
+ own. Follow the link and see.</p>
5594
+ <p>If it does not open, the server has stopped since this page was written. Nothing is lost:
5595
+ every review is on disk in <code>~/.prreviewbuddy/workspaces</code>. Start it again and this
5596
+ page will point at the new server by the time you reload it.</p>
5597
+ ${commandList(wayBack())}
5598
+ </div>
5599
+ <script>
5600
+ // An image, not a fetch: a file:// page cannot read a cross-origin response, but it can be
5601
+ // told whether one arrived. /assets is the only route that answers without a credential.
5602
+ var settled = false;
5603
+ var probe = new Image();
5604
+ var give_up = setTimeout(function () { fail(); }, ${PROBE_TIMEOUT_MS});
5605
+ function fail() {
5606
+ if (settled) return;
5607
+ settled = true;
5608
+ clearTimeout(give_up);
5609
+ document.getElementById('waiting').hidden = true;
5610
+ document.getElementById('gone').hidden = false;
5611
+ }
5612
+ probe.onload = function () {
5613
+ if (settled) return;
5614
+ settled = true;
5615
+ clearTimeout(give_up);
5616
+ // replace, not assign: going back should leave the reviews, not bounce through here again.
5617
+ window.location.replace(${JSON.stringify(url)});
5618
+ };
5619
+ probe.onerror = fail;
5620
+ probe.src = ${JSON.stringify(`http://127.0.0.1:${record.port}/assets/icon128.png?probe=`)} + Date.now();
5621
+ <\/script>`);
5622
+ }
5623
+ function stopped() {
5624
+ return page("Your reviews", `<h1>The workspace server is not running</h1>
5625
+ <p>Every review is still on disk in <code>~/.prreviewbuddy/workspaces</code>, with its guide,
5626
+ its diff and its conversation.</p>
5627
+ <p>Serve them again with:</p>
5628
+ ${commandList(wayBack())}
5629
+ <p>This page is rewritten when the server starts, so keep the bookmark and reload it then.</p>`);
5630
+ }
5631
+ /**
5632
+ * The commands that start a server, named for the product the reader actually installed.
5633
+ *
5634
+ * A list rather than a sentence because a build that cannot name itself offers both, and two
5635
+ * commands run together in prose read as one command with a typo in it.
5636
+ */
5637
+ function commandList(way) {
5638
+ return `<ul class="prb-commands">${way.commands.map(({ command, where }) => `<li><code>${escapeHtml(command)}</code> in ${escapeHtml(where)}</li>`).join("")}</ul>`;
5639
+ }
5640
+ /**
5641
+ * The shell both of this file's states use, and the refusal at `/` uses too.
5642
+ *
5643
+ * Exported so that the three pages a stuck reviewer can land on look like one product rather than
5644
+ * like three accidents. It carries no script and no asset request, which is what lets it render
5645
+ * identically from `file://` and from the server.
5646
+ */
5647
+ function page(title, body) {
5648
+ return `<!doctype html>
5649
+ <html lang="en">
5650
+ <head>
5651
+ <meta charset="utf-8">
5652
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5653
+ <title>${escapeHtml(title)} · PR Review Buddy</title>
5654
+ <style>
5655
+ :root { color-scheme: light dark; --prb-ink: #1b1b1f; --prb-paper: #fbfbfd; --prb-quiet: #5c5c66; }
5656
+ @media (prefers-color-scheme: dark) {
5657
+ :root { --prb-ink: #e8e8ec; --prb-paper: #17171b; --prb-quiet: #9a9aa4; }
5658
+ }
5659
+ body {
5660
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
5661
+ background: var(--prb-paper); color: var(--prb-ink);
5662
+ font: 15px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
5663
+ }
5664
+ main { max-width: 34rem; padding: 2rem; }
5665
+ h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
5666
+ p { margin: 0 0 0.75rem; }
5667
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
5668
+ .prb-quiet { color: var(--prb-quiet); }
5669
+ .prb-go { font-weight: 600; }
5670
+ .prb-commands { margin: 0 0 0.75rem; padding-left: 1.25rem; }
5671
+ .prb-commands li { margin: 0 0 0.25rem; }
5672
+ [hidden] { display: none; }
5673
+ </style>
5674
+ </head>
5675
+ <body><main>${body}</main></body>
5676
+ </html>
5677
+ `;
5678
+ }
5679
+ /**
5680
+ * The token is minted from `base64url` and cannot contain any of these, and it is escaped anyway.
5681
+ * A credential is the last value in this product to be interpolated on the strength of what it is
5682
+ * currently generated from.
5683
+ */
5684
+ function escapeHtml(value) {
5685
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5686
+ }
5687
+ //#endregion
5403
5688
  //#region ../../packages/review-harness/src/workspace/daemon.ts
5404
- var SERVER_RECORD = join(STORE_ROOT, "server.json");
5405
- var SPAWN_LOCK = join(STORE_ROOT, "server.lock");
5406
5689
  /**
5407
5690
  * The one address in this product that never changes.
5408
5691
  *
5409
- * Rewritten at both moments the truth changes, which is why it is here rather than anywhere that
5692
+ * Declared in `way_back.ts` with the rest of the answer to "how do I get back to my reviews", and
5693
+ * re-exported here because this is where callers already look for it. Rewritten by this file at
5694
+ * both moments the truth changes, which is why the writing is here rather than anywhere that
5410
5695
  * merely knows the port: this file is where a server starts existing and stops existing.
5411
5696
  */
5412
- var LANDING_PAGE = join(STORE_ROOT, "reviews.html");
5697
+ var SERVER_RECORD = join(STORE_ROOT, "server.json");
5698
+ var SPAWN_LOCK = join(STORE_ROOT, "server.lock");
5413
5699
  var SERVER_ENTRY = process.env.PRB_SERVER_ENTRY ?? fileURLToPath(new URL("./server.js", import.meta.url));
5414
5700
  var STARTUP_TIMEOUT_MS = 15e3;
5415
5701
  var POLL_INTERVAL_MS = 100;
@@ -5613,6 +5899,29 @@ async function waitForHealthyServer() {
5613
5899
  function workspaceUrl(port, id, token) {
5614
5900
  return `http://127.0.0.1:${port}/r/${id}?t=${token}`;
5615
5901
  }
5902
+ /**
5903
+ * A workspace link that also teaches the browser its way back to the list.
5904
+ *
5905
+ * The reviews index is opened by a credential no review page is allowed to carry, and the only
5906
+ * thing that puts that credential into a browser is arriving at the index with it once. Nothing on
5907
+ * the `prreviewbuddy review` path ever sent anyone there, so "All reviews" on the page it printed
5908
+ * refused every reviewer who had never separately run `prreviewbuddy open`. The bootstrap belongs
5909
+ * here, in the link, rather than as a step for the reviewer to discover after being refused.
5910
+ *
5911
+ * It goes through the index because that is the route that sets the cookie, and it carries `go` so
5912
+ * that the browser does not stop there: cookie set, then straight on to the review, leaving
5913
+ * `/r/<id>?t=<reviewToken>` in the address bar. The page still never receives the index credential,
5914
+ * which is the boundary that matters. What is in the address bar afterwards is safe to copy.
5915
+ *
5916
+ * Printing this rather than the plain link costs nothing when the credential is missing or stale:
5917
+ * `handleIndex` forwards to `go` either way, and only the cookie depends on the credential being
5918
+ * right. The worst case is exactly the link this used to print.
5919
+ */
5920
+ function bootstrapUrl(port, id, token, indexToken) {
5921
+ const workspace = `/r/${id}?t=${encodeURIComponent(token)}`;
5922
+ if (!indexToken) return `http://127.0.0.1:${port}${workspace}`;
5923
+ return `http://127.0.0.1:${port}/?t=${encodeURIComponent(indexToken)}&go=${encodeURIComponent(workspace)}`;
5924
+ }
5616
5925
  /** The reviews index. Null when the running server predates the index and has no token. */
5617
5926
  function reviewsUrl(port, token) {
5618
5927
  return token ? `http://127.0.0.1:${port}/?t=${token}` : null;
@@ -5984,10 +6293,6 @@ function countByStatus(issues) {
5984
6293
  * same policy as every other managed directory and a crash between creating one and recording it
5985
6294
  * leaves residue the orphan rule can still reach.
5986
6295
  */
5987
- /** The commit a review was made from, however the record spells it. Null when it recorded none. */
5988
- function reviewedCommit(workspace) {
5989
- return workspace.session.metadata?.commitSha || workspace.changeSet.data.pullRequest.head.sha || null;
5990
- }
5991
6296
  var NoCheckoutError = class extends Error {};
5992
6297
  /**
5993
6298
  * A worktree detached at `sha`, and the job that owns it.
@@ -6360,31 +6665,21 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
6360
6665
  }
6361
6666
  var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer in this repository, so there is nothing to measure the new code against. This one needs starting fresh: run /prreviewbuddy:fresh-review.";
6362
6667
  /**
6363
- * The candidate refs, in the order that best answers "where is this change now".
6668
+ * Where the request's branch is, after a fetch.
6669
+ *
6670
+ * The candidates live in `request_branch.ts` rather than here because the freshness strip has to
6671
+ * ask the same question and get the same answer. Two answers means the panel says the code was
6672
+ * rewritten while this button says the review is already current, on one screen, with nothing to
6673
+ * tell the reviewer which is right.
6364
6674
  *
6365
- * `<branch>@{upstream}` resolves the configured remote-tracking ref regardless of what is
6366
- * currently checked out, which is what makes asking here safe: nothing is checked out to ask, and
6367
- * the reviewer's own HEAD is never consulted.
6675
+ * The fetch is the only difference between the two callers, and it is the honest one: this button
6676
+ * is allowed to reach the network and that panel is not, so it looks at a newer copy of the same
6677
+ * refs.
6368
6678
  */
6369
- function candidateRefs(branch, remoteHeadRef) {
6370
- return remoteHeadRef ? [
6371
- `refs/remotes/origin/${remoteHeadRef}`,
6372
- `refs/remotes/${remoteHeadRef}`,
6373
- `${branch}@{upstream}`
6374
- ] : [`${branch}@{upstream}`];
6375
- }
6376
6679
  async function remoteHead(workspace) {
6377
6680
  const fetchFailed = !await fetchOrigin(workspace.repoPath);
6378
- const refs = candidateRefs(workspace.changeSet.headRef, workspace.prContext?.pullRequest.headRef);
6379
- for (const ref of refs) {
6380
- const sha = await refSha(workspace.repoPath, ref);
6381
- if (sha) return {
6382
- sha,
6383
- fetchFailed
6384
- };
6385
- }
6386
6681
  return {
6387
- sha: null,
6682
+ sha: (await resolveRequestBranch(workspace.repoPath, workspace.changeSet.headRef, workspace.prContext?.pullRequest.headRef))?.sha ?? null,
6388
6683
  fetchFailed
6389
6684
  };
6390
6685
  }
@@ -6405,18 +6700,6 @@ async function fetchOrigin(repoPath) {
6405
6700
  return false;
6406
6701
  }
6407
6702
  }
6408
- async function refSha(repoPath, ref) {
6409
- try {
6410
- return (await git(repoPath, [
6411
- "rev-parse",
6412
- "--verify",
6413
- "--quiet",
6414
- `${ref}^{commit}`
6415
- ])).trim() || null;
6416
- } catch {
6417
- return null;
6418
- }
6419
- }
6420
6703
  /** Whether the object itself is still in the repository, whatever ref does or does not name it. */
6421
6704
  async function commitPresent(repoPath, sha) {
6422
6705
  try {
@@ -6465,7 +6748,7 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath) {
6465
6748
  try {
6466
6749
  const plain = await buildReviewContext({
6467
6750
  repoPath: worktreePath,
6468
- base: await resolvableBase(worktreePath, workspace.changeSet.baseRef)
6751
+ base: await resolvableBase(worktreePath, workspace.changeSet)
6469
6752
  });
6470
6753
  whole = workspace.prContext ? await withPrContext(plain, workspace.prContext) : plain;
6471
6754
  workspace.changeSet = whole.changeSet;
@@ -6476,15 +6759,32 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath) {
6476
6759
  delete workspace.dismissedFreshnessSha;
6477
6760
  }
6478
6761
  /**
6479
- * The stored base, turned back into something git will accept.
6762
+ * The base this review was made against, turned back into something git will accept.
6763
+ *
6764
+ * `ChangeSet.baseResolvedRef` is the ref git actually resolved when the review was built, so a
6765
+ * review that has one needs no reasoning at all: it is the same comparison or it is nothing.
6480
6766
  *
6481
- * `ChangeSet.baseRef` is a *display* string: `origin/main` is stored as `main`, because that is
6482
- * what a reviewer calls it. In a checkout with no local `main` branch, which is the ordinary shape
6483
- * of a clone made for one branch, handing that back to git resolves to nothing. Undefined is the
6484
- * last resort and means "work it out again", which is what the change set did in the first place.
6767
+ * Everything else is a review made before that field existed, and all such a review kept is
6768
+ * `baseRef`, which is a *display* string: `origin/main` is stored as `main`, because that is what
6769
+ * a reviewer calls it. Both candidates have to be tried, and the order matters more than it looks.
6770
+ * A clone made for one branch has no local `main` at all, so the display name resolves to nothing
6771
+ * and `origin/main` is the only answer. An ordinary clone has both, `main` sits behind the remote
6772
+ * from the first day nobody pulls, and picking it silently widens the review to every upstream
6773
+ * commit that has not been fetched into the working branch. So the remote-tracking ref goes first:
6774
+ * it is what `resolveBase` itself prefers when nobody names a base, and no `prreviewbuddy review
6775
+ * <pr>` ever names one.
6776
+ *
6777
+ * Undefined is the last resort and means "work it out again", which is what the change set did in
6778
+ * the first place.
6485
6779
  */
6486
- async function resolvableBase(repoPath, stored) {
6487
- for (const candidate of [stored, `origin/${stored}`]) if (candidate && await refExists(repoPath, candidate)) return candidate;
6780
+ async function resolvableBase(repoPath, changeSet) {
6781
+ const stored = changeSet.baseRef;
6782
+ const candidates = [
6783
+ changeSet.baseResolvedRef,
6784
+ `origin/${stored}`,
6785
+ stored
6786
+ ];
6787
+ for (const candidate of candidates) if (candidate && await refExists(repoPath, candidate)) return candidate;
6488
6788
  }
6489
6789
  /** Null when there is no conversation attached, which is neither success nor failure. */
6490
6790
  async function reReadConversation(workspace, fetch) {
@@ -6664,4 +6964,4 @@ function loadConfig() {
6664
6964
  return config;
6665
6965
  }
6666
6966
  //#endregion
6667
- export { agentById as $, WORK_PHASES as A, lineagePosition as B, clearClaim as C, removeWorktree as D, readMarker as E, loadJob as F, saveWorkspace as G, positionInLineage as H, saveJob as I, processDiscussion as J, summarise as K, displayRef as L, deleteJob as M, fail as N, relativeTime as O, isTerminal as P, AGENT_IDS as Q, STORE_ROOT as R, fillFileUrlTemplate as S, MANAGED_ROOT as T, recentReviewGroups as U, loadWorkspace as V, recentWorkspaces as W, resolveTarget as X, describeAuthorship as Y, withBase as Z, feedbackUrl as _, refreshPrContext as a, EXPLAIN_SIMPLY_PROMPT as b, agentEnvOf as c, ensureServer as d, agentFor as et, readServerRecord as f, BUILD_VERSION as g, writeServerRecord as h, updateReview as i, allJobs as j, checkFreshness as k, LANDING_PAGE as l, workspaceUrl as m, record as n, AgentUnavailableError as nt, isUnchanged as o, reviewsUrl as p, touchWorkspace as q, RefreshUnavailableError as r, askCheckout as s, readEvents as t, defaultAgent as tt, clearServerRecord as u, runJob as v, isClaimed as w, forgeResolver as x, startJob as y, groupByLineage as z };
6967
+ export { touchWorkspace as $, MANAGED_ROOT as A, loadJob as B, runJob as C, fillFileUrlTemplate as D, forgeResolver as E, WORK_PHASES as F, lineagePosition as G, displayRef as H, allJobs as I, recentReviewGroups as J, loadWorkspace as K, deleteJob as L, removeWorktree as M, relativeTime as N, clearClaim as O, checkFreshness as P, summarise as Q, fail as R, feedbackUrl as S, EXPLAIN_SIMPLY_PROMPT as T, STORE_ROOT as U, saveJob as V, groupByLineage as W, reviewedCommit as X, recentWorkspaces as Y, saveWorkspace as Z, page as _, refreshPrContext as a, reviewerDispositions as at, wayBackSentence as b, agentEnvOf as c, resolveTarget as ct, ensureServer as d, AGENT_IDS as dt, doneVerb as et, readServerRecord as f, agentById as ft, commandList as g, AgentUnavailableError as gt, writeServerRecord as h, AgentCancelledError as ht, updateReview as i, questionsOutstanding as it, readMarker as j, isClaimed as k, bootstrapUrl as l, withBase as lt, workspaceUrl as m, defaultAgent as mt, record as n, isQuestionOutstanding as nt, isUnchanged as o, processDiscussion as ot, reviewsUrl as p, agentFor as pt, positionInLineage as q, RefreshUnavailableError as r, issuesOutstanding as rt, askCheckout as s, describeAuthorship as st, readEvents as t, isIssueOutstanding as tt, clearServerRecord as u, git as ut, LANDING_PAGE as v, startJob as w, BUILD_VERSION as x, wayBack as y, isTerminal as z };