prreviewbuddy 0.77.1 → 0.79.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/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as resolveWorkspacePort, $n as saveJob, $r as explanationActionOf, $t as pruneNotifications, A as parsePullRequestLink, Ai as git, An as ReviewBeingDeletedError, Ar as activityAttributes, At as repositoryKey, Bi as agentById, Bn as findExistingReview, Br as queueAddMany, Bt as announceJob, C as completeRun, Ci as originRemote, Cn as GITHUB_MARKDOWN, Cr as reviewedCommit, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as AmbiguousRefError, Dn as escapeHtml$1, Dr as workspaceRevision, Dt as loadArchitecture, E as stopRun, Ei as resolveBase, En as CODE_COPY_CLASS, Er as touchWorkspace, Et as checkArchitecture, Fi as readAgentPreference, Fn as readMarker, Fr as lifecyclePill, Ft as QUOTE_LIMITS, G as stopChild, Gn as fetchHead, Gr as isIssueOutstanding, Gt as createPrState, Hi as detectAgents, Hn as checkReview, Hr as queueStamp, Ht as subjectOf, I as settleQueue, Ii as writeAgentPreference, In as removeWorktree, Ir as offersLifecycle, It as REPOSITORY_SCOPE, J as writeServerRecord, Jn as allJobs, Jr as questionsOutstanding, Jt as listNotifications, Kn as forgeResolver, Kr as isQuestionOutstanding, Kt as createPrUpdate, L as readIndexToken, Ln as requestJobStop, Lr as parseLifecycle, Lt as ancestorsOf, M as runBatch, Mi as resolveAgent, Mn as createWorktree, Mr as isLifecycle, Mt as storedScopes, N as activateQueued, Ni as resolveModel, Nn as holdWorktree, Nr as lifecycleChip, Nt as trackedFiles, O as batchIdentity, Oi as shortNameOf, On as renderInlineText, Or as workspaceStamps, Ot as partFiles, P as compactQueue, Pi as clearAgentPreference, Pn as isWorktreeHeld, Pr as lifecycleItemLabel, Pt as ownersOf, Q as parseWorkspacePort, Qn as loadJob, Qr as recommendationsOf, Qt as moveIds, Ri as STORE_ROOT, Rr as sectionOf, Rt as isArchitectureId, S as beginRun, Si as resolveTarget, Sn as hasAuthorContent, Sr as recentWorkspaces, St as recordRuntimeBestEffort, T as reportRun, Ti as isCommitReview, Tn as CODE_BLOCK_CLASS, Tt as ARCHITECTURE_DIR, U as serverEntryExists, Ui as AgentCancelledError, Un as followedBranch, Ur as readQueue, Ut as categoryOf, V as readServerRecord, Vi as agentFor$1, Vn as checkCodeFreshness, Vr as queueRemove, Vt as branchOf, W as spawnServer, Wi as AgentUnavailableError, Wn as reviewedShaOf, Wr as doneVerb, Wt as createArchitectureNotification, X as MAX_WORKSPACE_PORT, Xn as fail$1, Xr as reviewerDispositions, Xt as markRead, Y as DEFAULT_WORKSPACE_PORT, Yn as endedAt, Yr as resolvedByConversation, Yt as markAllRead, Z as MIN_WORKSPACE_PORT, Zn as isTerminal, Zr as processDiscussion, Zt as markReadForReview, _i as extractJsonText, _n as agentEnvOf, _t as BUILD_VERSION, a as wasBlocked, ai as assessmentLookup, an as unreadAround, at as settleReview, b as track, bi as WITHHELD_REASON, bn as UNESTABLISHED_MOTIVATION, br as presentedKissRunOf, bt as waitingHistory, c as RefreshUnavailableError, ci as findingIn, cn as existingPaths, cr as followedRefName, d as mergeUpdateInto, di as promptGrade, dn as prepareConversationWithin, dr as indexReviewGroups, dt as record, ei as explanationOf, en as readNotification, et as statedWorkspacePort, f as updateReview, fi as reassessIn, fn as saveRefresh, fr as kissDecisionRefusal, gi as asString, gn as isUnchanged, gr as loadWorkspace, h as liveUpdateFor, hi as followUpFindingId, hn as refreshPrContext, hr as lineagePosition, ht as telemetryUploadConsent, i as liveJobsFor, ii as analysisRefOf, in as toneOf, ir as purposeOf, it as prepareReview, j as unsupportedReason, ji as refExists, jn as MANAGED_ROOT, jr as activityLabel, jt as storeArchitecture, k as extractPullRequestLinks, ki as shorthandOf, kr as LIFECYCLES, kt as readArchitecture, l as cancelUpdate, li as gradeScale, ln as ASK_REREAD_CEILING_MS, lr as groupByLineage, m as UpdateAlreadyRunningError, mi as wasReassessed, mn as unforeseenFailure, mr as latestKissRun, mt as recordTelemetryUploadConsent, n as deleteReview, ni as withExplanation, nn as removeNotification, nr as phasesFor, nt as failureOf$1, o as discardJob, oi as currentGradeIn, on as unreadCount$1, or as isClaimed, ot as reanalyseReview, p as askCheckout, pi as sourcePhrase, pn as storedConversation, pr as kissDecisionsOf, qn as fillFileUrlTemplate, qr as issuesOutstanding, qt as eventName, r as lineageIds, ri as ANALYSIS_ONLY, rn as retireNotification, rr as progressSteps, rt as observeReview, s as FETCH_TIMEOUT_MS, si as findingAssessment, sn as withUsageRecorded, sr as decideKissRecommendation, st as runJob, t as deleteLineage, ti as isExplanationAction, tn as readStateOf, tr as isLiveJob, tt as writePortPreference, u as cancellableHere, ui as isGradeFor, un as adoptLegacyBaseline, ur as indexKeptIds, ut as readEvents, v as startKissJob, vi as objectWithAnyOf, vn as EXPLAIN_SIMPLY_PROMPT, vr as mutateWorkspace, w as endRun, wi as DetachedHeadError, wn as renderMarkdown, wt as describeRuntimes, x as RUN_KINDS, xi as kissOutcome, xn as authorPreview, xr as previousKissResult, xt as mapRuntimes, y as isDriving, yi as describeAuthorship, yn as chatConversationSection, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as AGENT_IDS, zr as queueAdd, zt as writeJsonAtomically } from "./delete_review-BHOzMQ0_.js";
1
+ import { $ as resolveWorkspacePort, $n as saveJob, $r as recommendationsOf, $t as pruneNotifications, A as parsePullRequestLink, Ai as shorthandOf, An as ReviewBeingDeletedError, Ar as LIFECYCLES, At as repositoryKey, Bi as AGENT_IDS, Bn as findExistingReview, Br as queueAdd, Bt as announceJob, C as completeRun, Ci as resolveTarget, Cn as GITHUB_MARKDOWN, Cr as recentWorkspaces, Ct as RUNTIME_WORDS, D as relativeTime$1, Di as resolveBase, Dn as escapeHtml$1, Dr as touchWorkspace, Dt as loadArchitecture, E as stopRun, Ei as isCommitReview, En as CODE_COPY_CLASS, Et as checkArchitecture, Fi as clearAgentPreference, Fn as readMarker, Fr as lifecycleItemLabel, Ft as QUOTE_LIMITS, G as stopChild, Gi as AgentUnavailableError, Gn as fetchHead, Gr as doneVerb, Gt as createPrState, Hi as agentFor$1, Hn as checkReview, Hr as queueRemove, Ht as subjectOf, I as settleQueue, Ii as readAgentPreference, In as removeWorktree, Ir as lifecyclePill, It as REPOSITORY_SCOPE, J as writeServerRecord, Jn as allJobs, Jr as issuesOutstanding, Jt as listNotifications, Kn as forgeResolver, Kr as isIssueOutstanding, Kt as createPrUpdate, L as readIndexToken, Li as writeAgentPreference, Ln as requestJobStop, Lr as offersLifecycle, Lt as ancestorsOf, M as runBatch, Mi as refExists, Mn as createWorktree, Mr as activityLabel, Mt as storedScopes, N as activateQueued, Ni as resolveAgent, Nn as holdWorktree, Nr as isLifecycle, Nt as trackedFiles, O as batchIdentity, Oi as AmbiguousRefError, On as renderInlineText, Or as workspaceRevision, Ot as partFiles, P as compactQueue, Pi as resolveModel, Pn as isWorktreeHeld, Pr as lifecycleChip, Pt as ownersOf, Q as parseWorkspacePort, Qn as loadJob, Qr as processDiscussion, Qt as moveIds, Rr as parseLifecycle, Rt as isArchitectureId, S as beginRun, Si as kissOutcome, Sn as hasAuthorContent, Sr as previousKissResult, St as recordRuntimeBestEffort, T as reportRun, Ti as DetachedHeadError, Tn as CODE_BLOCK_CLASS, Tt as ARCHITECTURE_DIR, U as serverEntryExists, Ui as detectAgents, Un as followedBranch, Ur as queueStamp, Ut as categoryOf, V as readServerRecord, Vi as agentById, Vn as checkCodeFreshness, Vr as queueAddMany, Vt as branchOf, W as spawnServer, Wi as AgentCancelledError, Wn as reviewedShaOf, Wr as readQueue, Wt as createArchitectureNotification, X as MAX_WORKSPACE_PORT, Xn as fail$1, Xr as resolvedByConversation, Xt as markRead, Y as DEFAULT_WORKSPACE_PORT, Yn as endedAt, Yr as questionsOutstanding, Yt as markAllRead, Z as MIN_WORKSPACE_PORT, Zn as isTerminal, Zr as reviewerDispositions, Zt as markReadForReview, _i as asString, _n as agentEnvOf, _r as loadWorkspace, _t as BUILD_VERSION, a as wasBlocked, ai as analysisRefOf, an as unreadAround, ar as purposeOf, at as settleReview, b as track, bi as describeAuthorship, bn as UNESTABLISHED_MOTIVATION, bt as waitingHistory, c as RefreshUnavailableError, ci as findingAssessment, cn as existingPaths, cr as decideKissRecommendation, d as mergeUpdateInto, di as isGradeFor, dn as prepareConversationWithin, dr as indexKeptIds, dt as record, ei as explanationActionOf, en as readNotification, et as statedWorkspacePort, f as updateReview, fi as promptGrade, fn as saveRefresh, fr as indexReviewGroups, gi as followUpFindingId, gn as isUnchanged, gr as lineagePosition, h as liveUpdateFor, hi as wasReassessed, hn as refreshPrContext, hr as latestKissRun, ht as telemetryUploadConsent, i as liveJobsFor, ii as ANALYSIS_ONLY, in as toneOf, ir as progressSteps, it as prepareReview, j as unsupportedReason, ji as git, jn as MANAGED_ROOT, jr as activityAttributes, jt as storeArchitecture, k as extractPullRequestLinks, ki as shortNameOf, kr as workspaceStamps, kt as readArchitecture, l as cancelUpdate, li as findingIn, ln as ASK_REREAD_CEILING_MS, lr as followedRefName, m as UpdateAlreadyRunningError, mi as sourcePhrase, mn as unforeseenFailure, mr as kissDecisionsOf, mt as recordTelemetryUploadConsent, n as deleteReview, ni as isExplanationAction, nn as removeNotification, nr as isTerminalJobPhase, nt as failureOf$1, o as discardJob, oi as assessmentLookup, on as unreadCount$1, ot as reanalyseReview, p as askCheckout, pi as reassessIn, pn as storedConversation, pr as kissDecisionRefusal, qn as fillFileUrlTemplate, qr as isQuestionOutstanding, qt as eventName, r as lineageIds, ri as withExplanation, rn as retireNotification, rr as phasesFor, rt as observeReview, s as FETCH_TIMEOUT_MS, si as currentGradeIn, sn as withUsageRecorded, sr as isClaimed, st as runJob, t as deleteLineage, ti as explanationOf, tn as readStateOf, tr as isLiveJob, tt as writePortPreference, u as cancellableHere, ui as gradeScale, un as adoptLegacyBaseline, ur as groupByLineage, ut as readEvents, v as startKissJob, vi as extractJsonText, vn as EXPLAIN_SIMPLY_PROMPT, w as endRun, wi as originRemote, wn as renderMarkdown, wr as reviewedCommit, wt as describeRuntimes, x as RUN_KINDS, xi as WITHHELD_REASON, xn as authorPreview, xr as presentedKissRunOf, xt as mapRuntimes, y as isDriving, yi as objectWithAnyOf, yn as chatConversationSection, yr as mutateWorkspace, yt as feedbackUrl, z as clearServerRecordOwnedBy, zi as STORE_ROOT, zr as sectionOf, zt as writeJsonAtomically } from "./delete_review-DwF1NMJ4.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -243,12 +243,26 @@ var INDEX_SECTIONS = [
243
243
  "done"
244
244
  ];
245
245
  /**
246
+ * Every section the list draws, top to bottom: In progress, then the card sections.
247
+ *
248
+ * In progress is not in `INDEX_SECTIONS` because no card is ever placed there. It lists work, one
249
+ * entry per running job or waiting row (`deriveOperations`), and a review being updated stays a
250
+ * card in its own section while its update is listed here. Windows, Load more, the selection and
251
+ * the counts are all about cards, so they keep reading `INDEX_SECTIONS`; folding, its memory and its
252
+ * keyboard commands are about headings, so they read this.
253
+ */
254
+ var LIST_SECTIONS = ["in-progress", ...INDEX_SECTIONS];
255
+ /**
246
256
  * Each section's name, as its heading says it, and the key that folds it from the keyboard
247
257
  * (⌘K L and this key). The commands are made from this table, so a section added here is a section
248
258
  * the keyboard can fold, with no second list to remember.
249
259
  */
250
260
  var lifecycleName = (id) => LIFECYCLES.find((lifecycle) => lifecycle.id === id).name;
251
261
  var INDEX_SECTION_NAMES = {
262
+ "in-progress": {
263
+ title: "In progress",
264
+ key: "i"
265
+ },
252
266
  pinned: {
253
267
  title: "Pinned",
254
268
  key: "p"
@@ -273,8 +287,9 @@ var INDEX_SECTION_NAMES = {
273
287
  function sectionOpen(section, collapsed) {
274
288
  return !(collapsed?.[section] ?? section === "done");
275
289
  }
276
- function isIndexSection(value) {
277
- return typeof value === "string" && INDEX_SECTIONS.includes(value);
290
+ /** A section the list draws a heading for, and so one whose fold can be stored. */
291
+ function isListSection(value) {
292
+ return typeof value === "string" && LIST_SECTIONS.includes(value);
278
293
  }
279
294
  /**
280
295
  * Which section a card is drawn in: Pinned when pinned, and otherwise where its lifecycle says.
@@ -310,9 +325,13 @@ function cardLifecycleChip(pinned, lifecycle, activity) {
310
325
  * The two numbers are different questions and only both worth asking once they differ. A page of
311
326
  * thirteen branches reviewed once each would read "13 reviews · 13 branches", which invites the
312
327
  * reader to work out what the distinction is on the one page where there isn't one.
328
+ *
329
+ * `of` is every review on the page, given while the search or a filter is narrowing the list:
330
+ * "12 of 71 reviews" says the list is filtered and by how much, where "12 reviews" reads as all
331
+ * there are.
313
332
  */
314
- function countLabel(reviews, branches) {
315
- const label = `${reviews} review${reviews === 1 ? "" : "s"}`;
333
+ function countLabel(reviews, branches, of) {
334
+ const label = of === void 0 ? `${reviews} review${reviews === 1 ? "" : "s"}` : `${reviews} of ${of} review${of === 1 ? "" : "s"}`;
316
335
  if (reviews === branches) return label;
317
336
  return `${label} · ${branches} branch${branches === 1 ? "" : "es"}`;
318
337
  }
@@ -431,7 +450,8 @@ var PR_FILTER_OPTIONS = [
431
450
  * once a second about a review that finished failing an hour ago, for as long as the tab stayed
432
451
  * open.
433
452
  */
434
- function anythingRunning(states) {
453
+ function anythingRunning(states, operations = []) {
454
+ if (operations.length > 0) return true;
435
455
  for (const state of states.values()) if (state.phase !== "failed") return true;
436
456
  return false;
437
457
  }
@@ -9392,6 +9412,98 @@ async function checkPrStateNow(workspace, deps = defaultDeps()) {
9392
9412
  } catch {}
9393
9413
  }
9394
9414
  //#endregion
9415
+ //#region ../../packages/review-harness/src/workspace/operations.ts
9416
+ /** What a running job is, in the words the section uses. */
9417
+ function operationKind(job) {
9418
+ if (purposeOf(job) === "update") return "updating";
9419
+ if (job.reanalysis) return "re-running";
9420
+ return job.phase === "preparing" ? "starting" : "analysing";
9421
+ }
9422
+ /**
9423
+ * Every operation, running first and newest first, then the queue in the order it was submitted.
9424
+ *
9425
+ * A row is dropped once its review has a job that is running, or one that ended after the row took
9426
+ * its slot. Both halves are about the hand-over, which has two windows. A row learns its review id
9427
+ * before that review's job is claimed, so "has a job" alone would leave nothing on the page for a
9428
+ * moment; and a row learns its job ended only after it has, so "has a running job" alone would
9429
+ * bring a finished review back as Starting. An ending from before the slot was taken is an earlier
9430
+ * attempt, a Retry's predecessor, and says nothing about this one.
9431
+ *
9432
+ * An ending hands over only a row that has its slot. A Retry puts a row back to waiting with the
9433
+ * review, and the job that failed, it already had; until the retry starts, that ending is the
9434
+ * attempt being retried, not this one, and treating it as the hand-over hid a queued retry for as
9435
+ * long as it waited. A running job hands over a row at any stage: the work is on the page either
9436
+ * way, and a waiting row beside it would be the same review listed twice.
9437
+ *
9438
+ * Both orders are total, ending on the key, because the result is hashed into the index token and a
9439
+ * tie left to the directory's order would reload the page for no reason a reviewer could see.
9440
+ */
9441
+ function deriveOperations(jobs, isLive, rows) {
9442
+ const running = [];
9443
+ const liveOrEnded = [];
9444
+ for (const job of jobs) {
9445
+ if (purposeOf(job) === "ask") continue;
9446
+ if (isTerminalJobPhase(job.phase)) {
9447
+ liveOrEnded.push(job);
9448
+ continue;
9449
+ }
9450
+ if (!isLive(job)) continue;
9451
+ liveOrEnded.push(job);
9452
+ running.push({
9453
+ key: job.id,
9454
+ kind: operationKind(job),
9455
+ reviewId: job.workspaceId,
9456
+ phase: job.phase,
9457
+ purpose: purposeOf(job),
9458
+ ...job.reviewType && job.reviewType !== "standard" ? { lens: job.reviewType } : {},
9459
+ startedAt: job.startedAt
9460
+ });
9461
+ }
9462
+ const handedOver = (row) => row.reviewId !== void 0 && liveOrEnded.some((job) => job.workspaceId === row.reviewId && (!isTerminalJobPhase(job.phase) || row.status === "reviewing" && (job.finishedAt ?? job.updatedAt) >= (row.takenAt ?? Number.POSITIVE_INFINITY)));
9463
+ const queued = [];
9464
+ for (const row of rows) {
9465
+ if (handedOver(row)) continue;
9466
+ const base = {
9467
+ key: row.key,
9468
+ row,
9469
+ ...row.reviewId ? { reviewId: row.reviewId } : {}
9470
+ };
9471
+ if (row.status === "waiting") queued.push({
9472
+ ...base,
9473
+ kind: "queued"
9474
+ });
9475
+ else running.push({
9476
+ ...base,
9477
+ kind: "starting",
9478
+ startedAt: row.takenAt ?? 0
9479
+ });
9480
+ }
9481
+ const byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
9482
+ running.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0) || byKey(a, b));
9483
+ queued.sort((a, b) => (a.row?.order ?? 0) - (b.row?.order ?? 0) || byKey(a, b));
9484
+ return [...running, ...queued];
9485
+ }
9486
+ var LEADS = {
9487
+ queued: "Queued",
9488
+ starting: "Starting",
9489
+ analysing: "Analysing",
9490
+ updating: "Updating review",
9491
+ "re-running": "Re-running"
9492
+ };
9493
+ /**
9494
+ * The entry's status line: what kind of work, then the step it is on, in the words of `PHASES` /
9495
+ * `UPDATE_PHASES`, which the card, the terminal and the review page's checklist already use.
9496
+ *
9497
+ * "Analysing the change" stands alone rather than after "Analysing", which would say it twice.
9498
+ */
9499
+ function operationLine(operation) {
9500
+ const lead = operation.lens === "kiss" ? "KISS review" : LEADS[operation.kind];
9501
+ if (operation.phase === void 0) return lead;
9502
+ const step = phasesFor({ purpose: operation.purpose })[operation.phase].doing;
9503
+ if (step.toLowerCase().startsWith(lead.toLowerCase())) return step;
9504
+ return `${lead} · ${step}`;
9505
+ }
9506
+ //#endregion
9395
9507
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
9396
9508
  function renderReviewsPage(view) {
9397
9509
  const now = view.now ?? Date.now();
@@ -9408,7 +9520,9 @@ function renderReviewsPage(view) {
9408
9520
  };
9409
9521
  for (const group of ordered) sections[indexSectionOf(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all")].push(group);
9410
9522
  const rank = new Map(ordered.map((group, index) => [group, index]));
9411
- const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates))).join("");
9523
+ const operations = view.operations ?? [];
9524
+ const inProgressOnly = new Set(ordered.filter((group) => group.history.length === 1 && !group.latest.hasStandardReview && operations.some((operation) => operation.reviewId === group.latest.id && operation.purpose !== "update")).map((group) => group.latest.id));
9525
+ const renderCards = (groups) => groups.map((group) => renderCard(group, now, activityOf(group), rank.get(group), view.analyses?.get(group.latest.id), positions.get(group.latest.id), group.history.map((review) => view.moved?.get(review.id)).find((kind) => kind !== void 0), prStateOf(group.latest, view.prStates), inProgressOnly.has(group.latest.id))).join("");
9412
9526
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
9413
9527
  const queue = view.queue ?? [];
9414
9528
  const positions = new Map(queue.map((id, position) => [id, position]));
@@ -9422,7 +9536,7 @@ function renderReviewsPage(view) {
9422
9536
  <link rel="stylesheet" href="/assets/sidebar.css">
9423
9537
  <link rel="stylesheet" href="/assets/shell.css">
9424
9538
  </head>
9425
- <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map())}">
9539
+ <body class="prb-index-body" data-way-back="${escapeHtml$1(wayBackSentence())}" data-notifications-since="${now}"${view.revision === void 0 ? "" : ` data-index-revision="${escapeHtml$1(view.revision)}"`} data-live="${anythingRunning(view.analyses ?? /* @__PURE__ */ new Map(), operations)}">
9426
9540
  ${renderGlobalBar({
9427
9541
  current: "reviews",
9428
9542
  unread: view.unreadNotifications ?? 0,
@@ -9443,7 +9557,8 @@ ${renderWorkspaceRail({
9443
9557
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
9444
9558
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
9445
9559
  </div>`}
9446
- ${INDEX_SECTIONS.map((section) => renderSection(section, sections[section], renderCards, sectionOpen(section, view.sectionsCollapsed))).join("\n ")}
9560
+ ${renderInProgress(operations, ordered, positions, view.prStates, now, sectionOpen("in-progress", view.sectionsCollapsed))}
9561
+ ${INDEX_SECTIONS.map((section) => renderSection(section, sections[section], renderCards, sectionOpen(section, view.sectionsCollapsed), sections[section].filter((group) => !inProgressOnly.has(group.latest.id)).length)).join("\n ")}
9447
9562
  ${ordered.length === 0 ? `<p class="prb-index-empty">No reviews stored yet. Ask Claude Code to review a change and one will appear here.</p>` : `<p class="prb-index-none" data-no-matches hidden>Nothing matches that.</p>`}
9448
9563
  ${ordered.length === 0 ? "" : renderBulkBar()}
9449
9564
  <p class="prb-index-note">Reviews are kept for thirty days, on this machine only. Pinned reviews, Done reviews and anything in your review queue are kept for ninety days instead, counting from the last time you opened or marked them. Every review has a fixed address, so a link to one is worth keeping and still opens after a restart. Stopping the server deletes nothing; it only stops anything being served until the next review starts it again.</p>
@@ -9475,9 +9590,17 @@ function renderControls(queued, authors, anyRequest) {
9475
9590
  </select>
9476
9591
  </label>
9477
9592
  ${anyRequest ? renderPrFilter() : ""}
9593
+ ${renderClearFilters()}
9478
9594
  </div>`;
9479
9595
  }
9480
9596
  /**
9597
+ * Puts the search and every filter back at once. Hidden until the browser finds one narrowing the
9598
+ * list: the hash is the browser's to read, and without script there is nothing to clear.
9599
+ */
9600
+ function renderClearFilters() {
9601
+ return `<button type="button" class="prb-index-clear" data-clear-filters hidden><span aria-hidden="true">×</span> Clear filters</button>`;
9602
+ }
9603
+ /**
9481
9604
  * The pull request's state upstream, beside Review state and not inside it, so the two combine.
9482
9605
  *
9483
9606
  * Absent when no card is of a pull request, for the author filter's reason: a select whose every
@@ -9505,11 +9628,16 @@ var GITHUB_MARK = "<svg class=\"prb-index-forge-mark\" viewBox=\"0 0 16 16\" wid
9505
9628
  *
9506
9629
  * Absent when no card carries a login, which is every page of local-branch reviews: a dropdown
9507
9630
  * whose only choice is Everyone is not a choice.
9631
+ *
9632
+ * The button holds the chosen name where the browser draws a select's menu itself, so a long one
9633
+ * can be cut short inside the box's fixed width (shell.css). A browser that does not parses the
9634
+ * button and its contents away and draws the select as it always has.
9508
9635
  */
9509
9636
  function renderAuthorFilter(authors) {
9510
9637
  if (authors.length === 0) return "";
9511
9638
  return `<label class="prb-index-status prb-index-forge">${GITHUB_MARK}Author:
9512
9639
  <select name="author" data-author-filter aria-label="Filter by pull request author">
9640
+ <button><selectedcontent></selectedcontent></button>
9513
9641
  <option value="">Everyone</option>${authors.map((author) => `<option value="${escapeHtml$1(author.handle)}">${escapeHtml$1(authorOptionLabel(author))}</option>`).join("")}
9514
9642
  </select>
9515
9643
  </label>`;
@@ -9534,10 +9662,10 @@ function renderLoadMore$1(section) {
9534
9662
  * empty and hidden rather than left out, so a card the browser moves here after a mark or a pin has
9535
9663
  * somewhere to go, and an empty section keeps its fold for when something arrives.
9536
9664
  */
9537
- function renderSection(section, groups, renderCards, open) {
9665
+ function renderSection(section, groups, renderCards, open, shown = groups.length) {
9538
9666
  const title = INDEX_SECTION_NAMES[section].title;
9539
- return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
9540
- <summary class="prb-index-section-head">${title} · <span data-section-count>${groups.length}</span></summary>
9667
+ return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${shown === 0 ? " hidden" : ""}>
9668
+ <summary class="prb-index-section-head">${title} · <span data-section-count>${shown}</span></summary>
9541
9669
  <div class="prb-cards" data-cards="${section}">
9542
9670
  ${renderCards(groups)}
9543
9671
  </div>
@@ -9545,6 +9673,87 @@ function renderSection(section, groups, renderCards, open) {
9545
9673
  </details>`;
9546
9674
  }
9547
9675
  /**
9676
+ * In progress: the work going on now, above everything else on the page.
9677
+ *
9678
+ * Not drawn at all when nothing is going on, rather than drawn empty and hidden like the card
9679
+ * sections: nothing ever moves into it in the browser, because every change to it is a change to the
9680
+ * index token and arrives as a reload. Its count is of work, not reviews, so a review being updated
9681
+ * counts once here and once in its own section, which is the point.
9682
+ */
9683
+ function renderInProgress(operations, groups, positions, prStates, now, open) {
9684
+ if (operations.length === 0) return "";
9685
+ const reviews = new Map(groups.flatMap((group) => group.history.map((review) => [review.id, review])));
9686
+ const entries = operations.map((operation) => renderOperation(operation, reviewOf(operation, reviews), positions, prStates, now)).join("");
9687
+ return `<details class="prb-index-section" data-section="in-progress"${open ? " open" : ""}>
9688
+ <summary class="prb-index-section-head">${INDEX_SECTION_NAMES["in-progress"].title} · <span data-section-count>${operations.length}</span></summary>
9689
+ <div class="prb-cards" data-operations>
9690
+ ${entries}
9691
+ </div>
9692
+ </details>`;
9693
+ }
9694
+ /**
9695
+ * The review an entry is about. Its own where it has one. A row still waiting has none yet, and
9696
+ * takes the newest review of the same pull request or branch from the same folder where there is
9697
+ * one, which is what that review's title, author and state still are: the filters then treat a
9698
+ * re-review of a known pull request as that pull request, rather than as somebody unknown.
9699
+ */
9700
+ function reviewOf(operation, reviews) {
9701
+ if (operation.reviewId !== void 0) return reviews.get(operation.reviewId);
9702
+ const row = operation.row;
9703
+ if (!row?.repoPath) return void 0;
9704
+ let newest;
9705
+ for (const review of reviews.values()) {
9706
+ if (review.repoPath !== row.repoPath) continue;
9707
+ if ((row.prNumber !== void 0 ? review.prNumber === row.prNumber : review.prNumber === void 0 && review.branch === row.branch) && (!newest || review.createdAt > newest.createdAt)) newest = review;
9708
+ }
9709
+ return newest;
9710
+ }
9711
+ /**
9712
+ * One piece of work, in a card's clothes and with none of a card's controls.
9713
+ *
9714
+ * It carries the same filter attributes as its review's card, so the search and the filters answer
9715
+ * the same question about both. No risk, no counts, no review state: there are no results yet, or the
9716
+ * results on show are about to be replaced, and either way they are the card's to show.
9717
+ *
9718
+ * A link to the review, whose own page shows the steps, the cancel and the Retry. A row still waiting
9719
+ * for a slot has no review to open, so it is not a link.
9720
+ */
9721
+ function renderOperation(operation, review, positions, prStates, now) {
9722
+ const row = operation.row;
9723
+ const own = operation.reviewId !== void 0 && review?.id === operation.reviewId ? review : void 0;
9724
+ const repoPath = review?.repoPath ?? row?.repoPath;
9725
+ const prNumber = review?.prNumber ?? row?.prNumber;
9726
+ const branch = review ? followedRefName(review.branch, review.headResolvedRef) ?? review.branch : row?.branch;
9727
+ const prState = prNumber !== void 0 && repoPath ? prStates?.get(requestKey(repoPath, prNumber)) : void 0;
9728
+ const queued = own ? positions.get(own.id) : void 0;
9729
+ const status = own ? statusOf(own) : "new";
9730
+ const search = [
9731
+ review ? searchText(review) : "",
9732
+ row?.label ?? "",
9733
+ repoPath ? lastSegment(repoPath) : "",
9734
+ prNumber === void 0 ? "" : `#${prNumber} ${prNumber}`,
9735
+ branch ?? ""
9736
+ ].join(" ").toLowerCase();
9737
+ const title = own?.title || review?.title || row?.label || "Review";
9738
+ const running = operation.kind !== "queued";
9739
+ const body = `<div class="prb-card-head">
9740
+ <h2>${escapeHtml$1(title)}</h2>
9741
+ </div>
9742
+ <p class="prb-card-where">
9743
+ ${repoPath ? `<span class="prb-card-repo">${escapeHtml$1(lastSegment(repoPath))}</span>` : ""}
9744
+ ${prNumber === void 0 ? "" : `<span class="prb-card-pr">#${prNumber}</span>`}
9745
+ ${branch ? `<span class="prb-card-branch">${escapeHtml$1(branch)}</span>` : ""}
9746
+ </p>
9747
+ <p class="prb-card-meta">
9748
+ <span class="prb-card-analysis" data-analysis="${running ? "running" : "queued"}"><i></i>${escapeHtml$1(operationLine(operation))}</span>
9749
+ ${operation.startedAt ? `<span class="prb-card-when"><time datetime="${new Date(operation.startedAt).toISOString()}">${escapeHtml$1(`Started ${relativeTime(operation.startedAt, now)}`)}</time></span>` : ""}
9750
+ </p>`;
9751
+ const href = own ? `/r/${encodeURIComponent(own.id)}?t=${encodeURIComponent(own.token)}` : void 0;
9752
+ return `<article class="prb-card" data-operation="${escapeHtml$1(operation.kind)}" data-key="${escapeHtml$1(operation.key)}" data-id="${escapeHtml$1(operation.reviewId ?? "")}" data-status="${status}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review?.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-search-text="${escapeHtml$1(search)}">
9753
+ ${href ? `<a class="prb-card-link" href="${escapeHtml$1(href)}">${body}</a>` : `<div class="prb-card-link">${body}</div>`}
9754
+ </article>`;
9755
+ }
9756
+ /**
9548
9757
  * The bulk bar, with its counts left out.
9549
9758
  *
9550
9759
  * Same division as the pager: every word is written here, where the house style tests read it, and
@@ -9601,13 +9810,13 @@ function renderBulkBar() {
9601
9810
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
9602
9811
  * than a rebuild of the list.
9603
9812
  */
9604
- function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
9813
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false) {
9605
9814
  const review = group.latest;
9606
9815
  const status = statusOf(review);
9607
9816
  const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
9608
9817
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
9609
9818
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
9610
- return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}">
9819
+ return `<article class="prb-card" data-card data-id="${escapeHtml$1(review.id)}" data-status="${status}" data-pinned="${review.pinned}"${queued === void 0 ? "" : ` data-queue="${queued}"`}${prState === void 0 ? "" : ` data-pr-state="${prState}"`}${review.authorHandle ? ` data-author="${escapeHtml$1(review.authorHandle)}"` : ""} data-lifecycle="${review.lifecycle?.state ?? "active"}"${review.lifecycle ? ` data-lifecycle-at="${review.lifecycle.at}"` : ""}${activityAttributes(activity)} data-rank="${rank}" data-reviews="${group.history.length}" data-lineage="${escapeHtml$1(group.history.map((entry) => entry.id).join(" "))}" data-search-text="${escapeHtml$1(groupSearchText(group))}"${inProgressOnly ? " data-in-progress-only hidden" : ""}>
9611
9820
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
9612
9821
  <button type="button" class="prb-card-pin" data-pin="${escapeHtml$1(review.id)}" aria-pressed="${review.pinned}" aria-label="${review.pinned ? "Unpin" : "Pin"} this review">${review.pinned ? "&#9733;" : "&#9734;"}</button>
9613
9822
  ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
@@ -10022,7 +10231,7 @@ function writeChangeMapRegister(register) {
10022
10231
  function readReviewSections(value) {
10023
10232
  if (!value || typeof value !== "object") return {};
10024
10233
  const sections = {};
10025
- for (const section of INDEX_SECTIONS) {
10234
+ for (const section of LIST_SECTIONS) {
10026
10235
  const collapsed = value[section];
10027
10236
  if (typeof collapsed === "boolean") sections[section] = collapsed;
10028
10237
  }
@@ -11006,545 +11215,105 @@ function renderNotificationRow(notification, gone) {
11006
11215
  </li>`;
11007
11216
  }
11008
11217
  //#endregion
11009
- //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
11010
- /**
11011
- * Whether anything the reviews index draws has changed, as one token.
11012
- *
11013
- * The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
11014
- * never needs to know *which* review changed, so it asks one question whose cost is the same for
11015
- * five reviews and five hundred. Per-card revisions would be more information than the page has any
11016
- * use for, and a request per card to carry it.
11017
- *
11018
- * Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
11019
- * tokens for equality, never order them, never parse one. The implementation below is a hash of
11020
- * facts the page happens to draw today, and the whole point of saying so here is that it can be
11021
- * replaced by a counter, a watch, or anything else without a page having to be told.
11022
- *
11023
- * ## What is in it, and what is deliberately not
11024
- *
11025
- * In: every review's id and file revision, and the phase of every analysis running right now.
11026
- * Between them those cover everything a card shows — the summary, the risk badge, the counts, the
11027
- * pin, the reviewed mark, the running line — because every one of those reaches disk through
11028
- * `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
11029
- *
11030
- * Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
11031
- * streams its progress into the job record every few seconds, so a token derived from job file
11032
- * mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
11033
- * would then reload every few seconds for the whole length of an analysis — a page thrashing
11034
- * itself, with the reviewer's scroll position going with it. Phase only, which changes four times
11035
- * in a run.
11036
- */
11218
+ //#region ../../packages/review-harness/src/workspace/working_folders.ts
11037
11219
  /**
11038
- * What is being done to each review, or was last done and stopped.
11039
- *
11040
- * Keyed by review, valued by the phase the card should name. Two states reach it and a third
11041
- * deliberately does not:
11042
- *
11043
- * - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
11044
- * load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
11045
- * before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
11046
- * be the index confidently reporting work that nothing is doing.
11047
- * - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
11048
- * this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
11049
- * `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
11050
- * and the card's "Analysis stopped" branch was unreachable in production while passing a unit
11051
- * test that handed it a state the product could not produce. A review whose analysis failed went
11052
- * back to reading "No standard review yet" the moment its driver let go.
11053
- * - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
11054
- * result on the card already, and an abandoned one is the sweep's to judge rather than this
11055
- * renderer's to pre-empt.
11056
- *
11057
- * **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
11058
- * a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
11059
- * KISS run, and the review page's poll is built around one finishing while the other goes on. An
11060
- * earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
11061
- * first spoke for a standard analysis that was still working, and the card fell silent on work that
11062
- * was happening in front of the reviewer.
11220
+ * The local Git working folders PR Review Buddy can find right now.
11063
11221
  *
11064
- * So the precedence is running, then the most recent ending, and it is stated in that order because
11065
- * that is the property: **nothing that has ended can hide something that has not.**
11222
+ * One question and nothing more: which folders on this machine could a review run in. The answer is
11223
+ * derived each time from what is already known, the folders past reviews ran in and the other
11224
+ * worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
11225
+ * up because `git worktree list` names it, not because anything remembered it.
11066
11226
  *
11067
- * `jobs` is injected only so a test can hand the same set in two different orders and prove the
11068
- * answer does not move. Production always reads the directory.
11227
+ * A folder is a folder. A linked worktree is its own entry with its own path and is never folded
11228
+ * into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
11229
+ * where the branch is checked out.
11069
11230
  *
11070
- * A failure is visible for as long as the job record survives, which is the sweep's retention. Once
11071
- * that passes the card quietly returns to saying nothing, which is the same silence every review
11072
- * had before this existed.
11231
+ * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
11073
11232
  */
11074
- function analysisStates(now = Date.now(), jobs = allJobs()) {
11075
- const byReview = /* @__PURE__ */ new Map();
11076
- for (const job of jobs) {
11077
- if (purposeOf(job) === "ask") continue;
11078
- const held = byReview.get(job.workspaceId);
11079
- if (held) held.push(job);
11080
- else byReview.set(job.workspaceId, [job]);
11233
+ function canonical(path) {
11234
+ try {
11235
+ return realpathSync(path);
11236
+ } catch {
11237
+ return path;
11081
11238
  }
11082
- /**
11083
- * Both comparators are **total**, and that is a requirement rather than tidiness.
11084
- *
11085
- * `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
11086
- * happened to return. This function's output is hashed into the index token, and the page reloads
11087
- * whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
11088
- * reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
11089
- * same hazard the sorting rule was written for, one level further down.
11090
- *
11091
- * Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
11092
- * before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
11093
- * the last resort because it is the only field guaranteed to differ.
11094
- */
11095
- const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
11096
- const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
11097
- const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
11098
- const states = /* @__PURE__ */ new Map();
11099
- for (const [reviewId, jobs] of byReview) {
11100
- const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
11101
- if (running.length > 0) {
11102
- states.set(reviewId, {
11103
- phase: running[0].phase,
11104
- purpose: purposeOf(running[0])
11239
+ }
11240
+ /** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
11241
+ async function worktreesOf(repoPath) {
11242
+ let output;
11243
+ try {
11244
+ output = await git(repoPath, [
11245
+ "worktree",
11246
+ "list",
11247
+ "--porcelain"
11248
+ ]);
11249
+ } catch {
11250
+ return [];
11251
+ }
11252
+ const entries = [];
11253
+ for (const block of output.split(/\n\s*\n/)) {
11254
+ const lines = block.split("\n");
11255
+ const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
11256
+ if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
11257
+ const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
11258
+ entries.push({
11259
+ path,
11260
+ branch: ref ? ref.replace(/^refs\/heads\//, "") : null
11261
+ });
11262
+ }
11263
+ return entries;
11264
+ }
11265
+ async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
11266
+ const lastReviewed = /* @__PURE__ */ new Map();
11267
+ for (const summary of summaries) {
11268
+ if (!summary.repoPath) continue;
11269
+ const key = canonical(summary.repoPath);
11270
+ lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
11271
+ }
11272
+ const found = /* @__PURE__ */ new Map();
11273
+ for (const seed of lastReviewed.keys()) {
11274
+ if (found.has(seed) || !existsSync(seed)) continue;
11275
+ (await worktreesOf(seed)).forEach((entry, index) => {
11276
+ const key = canonical(entry.path);
11277
+ if (!found.has(key) && existsSync(key)) found.set(key, {
11278
+ entry: {
11279
+ ...entry,
11280
+ path: key
11281
+ },
11282
+ main: index === 0
11105
11283
  });
11106
- continue;
11107
- }
11108
- const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
11109
- if (ended[0]?.phase === "failed") states.set(reviewId, {
11110
- phase: "failed",
11111
- purpose: "review",
11112
- ...ended[0].failure?.stopped ? { stopped: true } : {}
11113
11284
  });
11114
11285
  }
11115
- return states;
11286
+ return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
11287
+ path: entry.path,
11288
+ remote: await originRemote(entry.path),
11289
+ branch: entry.branch,
11290
+ main,
11291
+ lastReviewedAt: lastReviewed.get(entry.path) ?? null
11292
+ })))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
11293
+ }
11294
+ /** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
11295
+ function isGitHubRepository(remote, owner, repo) {
11296
+ return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
11116
11297
  }
11117
11298
  /**
11118
- * The token.
11299
+ * The folders a pull request could be reviewed in, the suggested one first.
11119
11300
  *
11120
- * Takes the live analyses rather than reading them, so that one request answers with a token and a
11121
- * running state that describe the same instant. Computing them separately would let a phase change
11122
- * land between the two, and the page would then be told "nothing has changed" alongside a state it
11123
- * had not seen.
11301
+ * Every match is returned, because which one is right is the reviewer's call: an agent's worktree
11302
+ * and the main clone hold the same objects and differ in what is checked out. The order only
11303
+ * decides the default. A folder that already has a review of this pull request comes first, so
11304
+ * reviewing it again finds that review rather than starting a second one filed under another
11305
+ * folder; then the main worktree; then whichever was reviewed in most recently.
11124
11306
  */
11125
- function reviewsRevision(live = analysisStates()) {
11126
- const hash = createHash("sha1");
11127
- for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
11128
- for (const id of [...live.keys()].sort()) {
11129
- const state = live.get(id);
11130
- hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
11131
- }
11132
- hash.update(`queue:${queueStamp()}\n`);
11133
- for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
11134
- for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
11135
- for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
11136
- for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
11137
- return hash.digest("base64url");
11307
+ function candidatesFor(folders, link, summaries) {
11308
+ const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
11309
+ const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
11310
+ const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
11311
+ return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
11138
11312
  }
11139
- function asKind(value) {
11140
- return value === "issue" || value === "question" ? value : null;
11141
- }
11142
- /** What the page sent, or null when it is not a request a host understands. */
11143
- function parseReassessmentRequest(value) {
11144
- const body = value;
11145
- if (body?.op === "decide") {
11146
- if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
11147
- if (body.decision !== "accepted" && body.decision !== "declined") return null;
11148
- return {
11149
- op: "decide",
11150
- proposalId: body.proposalId,
11151
- decision: body.decision
11152
- };
11153
- }
11154
- if (body?.op === "set") {
11155
- const kind = asKind(body.kind);
11156
- if (!kind || typeof body.id !== "string" || body.id === "") return null;
11157
- if (!isGradeFor(kind, body.to)) return null;
11158
- if (body.from !== null && !isGradeFor(kind, body.from)) return null;
11159
- if (body.reason !== void 0 && typeof body.reason !== "string") return null;
11160
- const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
11161
- return {
11162
- op: "set",
11163
- kind,
11164
- findingId: body.id,
11165
- from: body.from,
11166
- to: body.to,
11167
- ...reason ? { reason } : {}
11168
- };
11169
- }
11170
- return null;
11171
- }
11172
- var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
11173
- var REASSESSMENT_REFUSED = {
11174
- "not-a-grade": "That is not a grade this finding can have.",
11175
- unchanged: "The finding already has that grade.",
11176
- stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
11177
- outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
11178
- "no-reason": "A suggestion needs a reason.",
11179
- unknown: "This review has no suggestion with that id.",
11180
- decided: "That suggestion has already been answered.",
11181
- "no-finding": "This review has no such finding."
11182
- };
11183
- function gradeFragments$1(session, kind, id) {
11184
- const finding = findingIn(session, kind, id);
11185
- if (!finding) return null;
11186
- const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
11187
- const options = {
11188
- editable: true,
11189
- askMore: true
11190
- };
11191
- return {
11192
- kind,
11193
- id,
11194
- current: assessment.current,
11195
- cardClass: cardGradeClass(kind, assessment.current),
11196
- grade: renderGradeControl(kind, assessment, options),
11197
- region: renderAssessmentRegion(kind, assessment, options),
11198
- copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
11199
- };
11200
- }
11201
- /** The finding a request is about: named outright, or through the proposal it decides. */
11202
- function findingOfRequest(session, request) {
11203
- if (request.op === "set") return {
11204
- kind: request.kind,
11205
- id: request.findingId
11206
- };
11207
- const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
11208
- return proposal ? {
11209
- kind: proposal.kind,
11210
- id: proposal.findingId
11211
- } : null;
11212
- }
11213
- /** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
11214
- function refusedReply(session, request, refusal) {
11215
- const finding = findingOfRequest(session, request);
11216
- const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
11217
- return {
11218
- status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
11219
- body: {
11220
- error: REASSESSMENT_REFUSED[refusal],
11221
- refusal,
11222
- ...card ? { card } : {}
11223
- }
11224
- };
11225
- }
11226
- //#endregion
11227
- //#region ../../packages/review-harness/src/workspace/reassessment_state.ts
11228
- /**
11229
- * The reviewer's grade moves and their answers to proposals, as `/state` receives them.
11230
- *
11231
- * The request, its refusals and the card parts a reply carries are shared with the extension
11232
- * (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
11233
- * against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
11234
- * the write, where the answer is final.
11235
- */
11236
- /** A stored review's card parts for one finding. */
11237
- function gradeFragments(workspace, kind, id) {
11238
- return gradeFragments$1(workspace.session, kind, id);
11239
- }
11240
- /**
11241
- * One write, onto the review as it is now, answered with the card as it now stands. `now` is
11242
- * injectable so a test can name the time an entry records.
11243
- */
11244
- function applyReassessmentRequest(workspaceId, value, now = Date.now) {
11245
- const request = parseReassessmentRequest(value);
11246
- if (!request) return {
11247
- status: 400,
11248
- body: { error: BAD_REASSESSMENT_REQUEST }
11249
- };
11250
- const before = loadWorkspace(workspaceId);
11251
- if (!before) return {
11252
- status: 404,
11253
- body: { error: "No such review" }
11254
- };
11255
- const early = reassessIn(structuredClone(before.session), request, now());
11256
- if (!early.ok) return refusedReply(before.session, request, early.refusal);
11257
- let outcome = null;
11258
- const saved = mutateWorkspace(workspaceId, (current) => {
11259
- outcome = reassessIn(current.session, request, now());
11260
- });
11261
- if (!saved || !outcome) return {
11262
- status: 404,
11263
- body: { error: "No such review" }
11264
- };
11265
- const written = outcome;
11266
- if (!written.ok) return refusedReply(saved.session, request, written.refusal);
11267
- const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
11268
- return {
11269
- status: 200,
11270
- body: {
11271
- ok: true,
11272
- entry: written.entry,
11273
- ...card ? { card } : {}
11274
- }
11275
- };
11276
- }
11277
- //#endregion
11278
- //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
11279
- /** More than any real answer cites; a list longer than this is padding, not evidence. */
11280
- var MAX_EVIDENCE = 10;
11281
- function scaleText(kind) {
11282
- return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
11283
- }
11284
- /** What the grade means, in the words the analysis prompt uses for it. */
11285
- function gradeMeaning(kind) {
11286
- return kind === "issue" ? "Severity is how much the issue would matter if it is real. It is not how sure anyone is that it is real." : "Importance is how much of the reviewer's attention the question deserves before they can approve.";
11287
- }
11288
- function findingText(kind, finding) {
11289
- if (kind === "issue") {
11290
- const issue = finding;
11291
- const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
11292
- return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
11293
- }
11294
- const question = finding;
11295
- const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
11296
- return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
11297
- }
11298
- function historyText(assessment) {
11299
- if (!assessment.history.length) return "It has not been reassessed before.";
11300
- return ["Earlier moves of this grade, oldest first:", ...assessment.history.map((step) => `- ${step.from ?? "ungraded"} → ${step.to}, ${sourcePhrase(step.source)}${step.reason ? `: ${step.reason}` : ""}`)].join("\n");
11301
- }
11302
- function threadText(thread) {
11303
- if (!thread) return [];
11304
- const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
11305
- if (!comments.length) return [];
11306
- return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
11307
- }
11308
- /** The block a Reassess turn adds to the ordinary Buddy prompt. */
11309
- function buildReassessInstructions(input) {
11310
- const { kind, finding, assessment } = input;
11311
- const noun = kind === "issue" ? "severity" : "importance";
11312
- const current = assessment.current;
11313
- return [
11314
- "---",
11315
- `THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
11316
- findingText(kind, finding),
11317
- `Its ${noun} now: ${promptGrade(assessment)}.`,
11318
- historyText(assessment),
11319
- ...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
11320
- ...threadText(input.thread),
11321
- "---",
11322
- `Judge whether its ${noun} should change, using the code in the checkout and everything above, including this conversation. ${gradeMeaning(kind)} Read the code that decides it rather than reasoning from the finding's wording.`,
11323
- `Raising it is as legitimate as lowering it. "keep" is a good answer, and the right one whenever the evidence does not clearly move it; do not change it because you were asked.`,
11324
- "Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
11325
- `{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
11326
- `"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
11327
- "\"reason\" is one to three sentences the reviewer will read on the card: name what in the code or the conversation moves it, or what leaves it where it is.",
11328
- "\"evidence\" is the places in the code that settle it, as paths relative to the repository root. Omit it rather than citing a file you did not read."
11329
- ];
11330
- }
11331
- var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
11332
- function readEvidence(value) {
11333
- if (!Array.isArray(value)) return [];
11334
- const out = [];
11335
- for (const item of value) {
11336
- const entry = item;
11337
- const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
11338
- if (!file) continue;
11339
- const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
11340
- out.push(line === void 0 ? { file } : {
11341
- file,
11342
- line
11343
- });
11344
- if (out.length === MAX_EVIDENCE) break;
11345
- }
11346
- return out;
11347
- }
11348
- /**
11349
- * The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
11350
- * grade on this finding's scale that differs from the current one, and every answer must give a
11351
- * reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
11352
- * use it, and nothing is proposed.
11353
- */
11354
- function parseReassessAnswer(raw, kind, current) {
11355
- let parsed;
11356
- try {
11357
- parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
11358
- } catch {
11359
- return null;
11360
- }
11361
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
11362
- const obj = parsed;
11363
- const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
11364
- if (!reason) return null;
11365
- const evidence = readEvidence(obj.evidence);
11366
- if (obj.verdict === "keep") return {
11367
- verdict: "keep",
11368
- reason,
11369
- evidence
11370
- };
11371
- if (obj.verdict !== "change") return null;
11372
- if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
11373
- return {
11374
- verdict: "change",
11375
- to: obj.to,
11376
- reason,
11377
- evidence
11378
- };
11379
- }
11380
- //#endregion
11381
- //#region ../../packages/review-harness/src/workspace/reassess_turn.ts
11382
- /**
11383
- * The answer, with every cited place checked against the commit the review describes. A path that
11384
- * is not there is dropped and the suggestion kept: the judgement may stand even when the model
11385
- * misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
11386
- * itself fails, no evidence is kept rather than unchecked evidence.
11387
- *
11388
- * Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
11389
- * no verified code evidence rather than drawing it like one whose citations held. Acceptance does
11390
- * not depend on it; a reason may be sound without a citation.
11391
- */
11392
- async function readReassessTurn(raw, target, where, check = existingPaths) {
11393
- const answer = parseReassessAnswer(raw, target.kind, target.from);
11394
- if (!answer || !answer.evidence.length) return {
11395
- answer,
11396
- raw,
11397
- droppedEvidence: 0
11398
- };
11399
- let real;
11400
- try {
11401
- real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
11402
- } catch {
11403
- real = /* @__PURE__ */ new Set();
11404
- }
11405
- const evidence = answer.evidence.filter((place) => real.has(place.file));
11406
- return {
11407
- answer: {
11408
- ...answer,
11409
- evidence
11410
- },
11411
- raw,
11412
- droppedEvidence: answer.evidence.length - evidence.length
11413
- };
11414
- }
11415
- /**
11416
- * Writes the suggestion onto the session, when there is one to write. Run inside the store write on
11417
- * the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
11418
- * while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
11419
- * grade it never saw.
11420
- */
11421
- function keepReassessTurn(session, target, turn, askedAt) {
11422
- if (!turn.answer) return "unreadable";
11423
- if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
11424
- return reassessIn(session, {
11425
- op: "propose",
11426
- kind: target.kind,
11427
- findingId: target.id,
11428
- from: target.from,
11429
- to: turn.answer.to,
11430
- reason: turn.answer.reason,
11431
- ...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
11432
- ...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
11433
- source: {
11434
- kind: "buddy",
11435
- askedAt
11436
- }
11437
- }, askedAt).ok ? "proposed" : "stale";
11438
- }
11439
- /** What the conversation shows for the turn, in words, never the JSON. */
11440
- function reassessTurnText(target, turn, outcome) {
11441
- const from = target.from ?? "ungraded";
11442
- const answer = turn.answer;
11443
- if (!answer) return `${turn.raw.trim()}\n\n*This answer was not in the form a suggestion needs, so nothing was suggested and the grade is unchanged.*`;
11444
- if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
11445
- const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
11446
- return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
11447
- }
11448
- //#endregion
11449
- //#region ../../packages/review-harness/src/workspace/working_folders.ts
11450
- /**
11451
- * The local Git working folders PR Review Buddy can find right now.
11452
- *
11453
- * One question and nothing more: which folders on this machine could a review run in. The answer is
11454
- * derived each time from what is already known, the folders past reviews ran in and the other
11455
- * worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
11456
- * up because `git worktree list` names it, not because anything remembered it.
11457
- *
11458
- * A folder is a folder. A linked worktree is its own entry with its own path and is never folded
11459
- * into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
11460
- * where the branch is checked out.
11461
- *
11462
- * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
11463
- */
11464
- function canonical(path) {
11465
- try {
11466
- return realpathSync(path);
11467
- } catch {
11468
- return path;
11469
- }
11470
- }
11471
- /** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
11472
- async function worktreesOf(repoPath) {
11473
- let output;
11474
- try {
11475
- output = await git(repoPath, [
11476
- "worktree",
11477
- "list",
11478
- "--porcelain"
11479
- ]);
11480
- } catch {
11481
- return [];
11482
- }
11483
- const entries = [];
11484
- for (const block of output.split(/\n\s*\n/)) {
11485
- const lines = block.split("\n");
11486
- const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
11487
- if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
11488
- const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
11489
- entries.push({
11490
- path,
11491
- branch: ref ? ref.replace(/^refs\/heads\//, "") : null
11492
- });
11493
- }
11494
- return entries;
11495
- }
11496
- async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
11497
- const lastReviewed = /* @__PURE__ */ new Map();
11498
- for (const summary of summaries) {
11499
- if (!summary.repoPath) continue;
11500
- const key = canonical(summary.repoPath);
11501
- lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
11502
- }
11503
- const found = /* @__PURE__ */ new Map();
11504
- for (const seed of lastReviewed.keys()) {
11505
- if (found.has(seed) || !existsSync(seed)) continue;
11506
- (await worktreesOf(seed)).forEach((entry, index) => {
11507
- const key = canonical(entry.path);
11508
- if (!found.has(key) && existsSync(key)) found.set(key, {
11509
- entry: {
11510
- ...entry,
11511
- path: key
11512
- },
11513
- main: index === 0
11514
- });
11515
- });
11516
- }
11517
- return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
11518
- path: entry.path,
11519
- remote: await originRemote(entry.path),
11520
- branch: entry.branch,
11521
- main,
11522
- lastReviewedAt: lastReviewed.get(entry.path) ?? null
11523
- })))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
11524
- }
11525
- /** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
11526
- function isGitHubRepository(remote, owner, repo) {
11527
- return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
11528
- }
11529
- /**
11530
- * The folders a pull request could be reviewed in, the suggested one first.
11531
- *
11532
- * Every match is returned, because which one is right is the reviewer's call: an agent's worktree
11533
- * and the main clone hold the same objects and differ in what is checked out. The order only
11534
- * decides the default. A folder that already has a review of this pull request comes first, so
11535
- * reviewing it again finds that review rather than starting a second one filed under another
11536
- * folder; then the main worktree; then whichever was reviewed in most recently.
11537
- */
11538
- function candidatesFor(folders, link, summaries) {
11539
- const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
11540
- const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
11541
- const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
11542
- return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
11543
- }
11544
- /** The review of this pull request already filed under one of these folders, if there is one. */
11545
- function reviewedIn(folders, link, summaries) {
11546
- const paths = new Set(folders.map((folder) => folder.path));
11547
- return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
11313
+ /** The review of this pull request already filed under one of these folders, if there is one. */
11314
+ function reviewedIn(folders, link, summaries) {
11315
+ const paths = new Set(folders.map((folder) => folder.path));
11316
+ return summaries.find((summary) => summary.prNumber === link.number && paths.has(canonical(summary.repoPath))) ?? null;
11548
11317
  }
11549
11318
  //#endregion
11550
11319
  //#region ../../packages/review-harness/src/workspace/choose_folder.ts
@@ -11697,625 +11466,1145 @@ function baseChoiceOf(error) {
11697
11466
  };
11698
11467
  }
11699
11468
  /**
11700
- * What the panel says for an error a review could not get past.
11701
- *
11702
- * Two errors are known by their class and said in the panel's own words. Anything else keeps its
11703
- * message, which is already written for a person, but only its first paragraph is the sentence:
11704
- * the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
11705
- * needs to diagnose it.
11469
+ * What the panel says for an error a review could not get past.
11470
+ *
11471
+ * Two errors are known by their class and said in the panel's own words. Anything else keeps its
11472
+ * message, which is already written for a person, but only its first paragraph is the sentence:
11473
+ * the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
11474
+ * needs to diagnose it.
11475
+ */
11476
+ function refusalOf(error) {
11477
+ const text = message(error);
11478
+ if (error instanceof AmbiguousRefError) {
11479
+ const choice = baseChoiceOf(error);
11480
+ return {
11481
+ error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
11482
+ detail: text,
11483
+ baseChoice: choice
11484
+ };
11485
+ }
11486
+ if (error instanceof DetachedHeadError) return { error: "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead." };
11487
+ return briefly(text);
11488
+ }
11489
+ /** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
11490
+ function briefly(text) {
11491
+ const first = text.split(/\n\s*\n/)[0].trim();
11492
+ return first && first !== text.trim() ? {
11493
+ error: first,
11494
+ detail: text
11495
+ } : { error: text };
11496
+ }
11497
+ /** A path as a person reads it, with their home folder as `~`. */
11498
+ function displayPath(path, home) {
11499
+ return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
11500
+ }
11501
+ /** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
11502
+ function expandPath(raw, home) {
11503
+ const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
11504
+ if (!trimmed) return null;
11505
+ const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
11506
+ return isAbsolute(expanded) ? expanded : null;
11507
+ }
11508
+ function message(error) {
11509
+ return error instanceof Error ? error.message : String(error);
11510
+ }
11511
+ /** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
11512
+ function unresolvedAgent(resolution) {
11513
+ switch (resolution.kind) {
11514
+ case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
11515
+ case "unknown-id": return `"${resolution.id}" is not an agent PR Review Buddy knows. Choose another under Review options, or change the default in Settings.`;
11516
+ case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
11517
+ }
11518
+ }
11519
+ /**
11520
+ * The agent a submission runs under: the one named under Review options, or else the same answer
11521
+ * the CLI reaches with no flag (the configured default, or the only one installed). Never the
11522
+ * harness's own fallback: an id is always passed down, so `startJob` never has to guess.
11523
+ */
11524
+ async function agentFor(flag, deps) {
11525
+ if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
11526
+ const resolution = await deps.resolveAgent(flag || void 0);
11527
+ if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
11528
+ if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
11529
+ return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
11530
+ }
11531
+ function modelOf(value) {
11532
+ if (value === void 0 || value === null || value === "") return void 0;
11533
+ if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
11534
+ return value.trim() || void 0;
11535
+ }
11536
+ /**
11537
+ * The tail of each target's queue of creations, by batch identity. In-process, like the fetch
11538
+ * queue in `target.ts`.
11539
+ *
11540
+ * `prepareReview` looks for an existing review and then creates one, and nothing between the two
11541
+ * stops a second start of the same target, from another tab or another paste, from looking in the
11542
+ * same gap and creating a second review of it. One at a time per target closes that gap for every
11543
+ * start this server makes; the second one finds the first one's review.
11544
+ */
11545
+ var creations = /* @__PURE__ */ new Map();
11546
+ function oneCreationAtATime(target, work) {
11547
+ const key = batchIdentity(target);
11548
+ const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
11549
+ const tail = mine.catch(() => {});
11550
+ creations.set(key, tail);
11551
+ tail.then(() => {
11552
+ if (creations.get(key) === tail) creations.delete(key);
11553
+ });
11554
+ return mine;
11555
+ }
11556
+ var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
11557
+ async function addReviewOptions(deps) {
11558
+ const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
11559
+ return {
11560
+ status: 200,
11561
+ body: {
11562
+ agents,
11563
+ agent: resolution.kind === "resolved" ? {
11564
+ kind: "resolved",
11565
+ id: resolution.agent.id,
11566
+ displayName: resolution.agent.displayName
11567
+ } : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
11568
+ kind: "refused",
11569
+ error: unresolvedAgent(resolution)
11570
+ },
11571
+ canChooseFolder: deps.chooseFolder !== null
11572
+ }
11573
+ };
11574
+ }
11575
+ function reviewedRow(reviewId, deps) {
11576
+ const kept = deps.resumeOf(reviewId);
11577
+ return {
11578
+ reviewId,
11579
+ ...kept ? { kept } : {}
11580
+ };
11581
+ }
11582
+ function folderOption(folder, home) {
11583
+ return {
11584
+ path: folder.path,
11585
+ display: displayPath(folder.path, home),
11586
+ branch: folder.branch
11587
+ };
11588
+ }
11589
+ /**
11590
+ * What a paste would start, without starting anything: rows, the folders each could run in, and
11591
+ * which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
11592
+ * run on every pause in typing.
11593
+ *
11594
+ * Every link gets a row, however many there are. Past the batch limit the panel refuses to start
11595
+ * rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
11596
+ * got twenty reviews would not find out which one was missing.
11597
+ */
11598
+ async function previewPullRequests(text, deps) {
11599
+ if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
11600
+ const links = extractPullRequestLinks(text);
11601
+ const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
11602
+ return {
11603
+ status: 200,
11604
+ body: {
11605
+ rows: links.map((link) => {
11606
+ if (link.kind === "unsupported") return {
11607
+ key: link.key,
11608
+ label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
11609
+ url: link.url,
11610
+ ok: false,
11611
+ reason: unsupportedReason(link.provider),
11612
+ folders: []
11613
+ };
11614
+ const label = `${link.owner}/${link.repo} #${link.number}`;
11615
+ const candidates = candidatesFor(folders, link, summaries);
11616
+ if (candidates.length === 0) return {
11617
+ key: link.key,
11618
+ label,
11619
+ url: link.url,
11620
+ ok: false,
11621
+ reason: "Local repository not found",
11622
+ needsFolder: true,
11623
+ folders: []
11624
+ };
11625
+ const reviewed = reviewedIn(candidates, link, summaries);
11626
+ return {
11627
+ key: link.key,
11628
+ label,
11629
+ url: link.url,
11630
+ ok: true,
11631
+ folders: candidates.map((folder) => folderOption(folder, deps.home)),
11632
+ folder: candidates[0].path,
11633
+ ...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
11634
+ };
11635
+ }),
11636
+ limit: MAX_BATCH
11637
+ }
11638
+ };
11639
+ }
11640
+ var batches = /* @__PURE__ */ new Map();
11641
+ var facts = /* @__PURE__ */ new WeakMap();
11642
+ var submitted = 0;
11643
+ function noteRow(row, known) {
11644
+ submitted += 1;
11645
+ facts.set(row, {
11646
+ order: submitted,
11647
+ ...known
11648
+ });
11649
+ }
11650
+ /** The row has its slot and is under way. When it took it is kept from the first time only. */
11651
+ function begin(row) {
11652
+ const known = facts.get(row);
11653
+ if (known && row.status !== "reviewing") known.takenAt = Date.now();
11654
+ row.status = "reviewing";
11655
+ }
11656
+ /**
11657
+ * Every row still to start or under way, for the Reviews page's In progress section. Rows that
11658
+ * ended (ready, failed, already reviewed) are not here: what they made is in the list, and how they
11659
+ * failed is in the panel and on the review.
11660
+ *
11661
+ * This server's rows only, from memory: see `liveOperations` for why the queue is process-local.
11662
+ */
11663
+ function pendingBatchRows() {
11664
+ const pending = [];
11665
+ for (const batch of batches.values()) batch.rows.forEach((row, index) => {
11666
+ if (row.status !== "waiting" && row.status !== "reviewing") return;
11667
+ const known = facts.get(row);
11668
+ pending.push({
11669
+ key: `${batch.id}:${index}`,
11670
+ status: row.status,
11671
+ label: row.label,
11672
+ order: known?.order ?? 0,
11673
+ ...known?.takenAt === void 0 ? {} : { takenAt: known.takenAt },
11674
+ ...row.reviewId ? { reviewId: row.reviewId } : {},
11675
+ ...known?.repoPath ? { repoPath: known.repoPath } : {},
11676
+ ...known?.prNumber === void 0 ? {} : { prNumber: known.prNumber },
11677
+ ...known?.branch ? { branch: known.branch } : {}
11678
+ });
11679
+ });
11680
+ return pending;
11681
+ }
11682
+ function forgetFinished(now) {
11683
+ for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
11684
+ }
11685
+ function newBatch(rows) {
11686
+ forgetFinished(Date.now());
11687
+ const now = Date.now();
11688
+ const batch = {
11689
+ id: randomUUID(),
11690
+ rows,
11691
+ finishedAt: now,
11692
+ seenAt: now,
11693
+ running: 0,
11694
+ rerun: () => {}
11695
+ };
11696
+ batches.set(batch.id, batch);
11697
+ return batch;
11698
+ }
11699
+ /**
11700
+ * Count work against a batch, inside the restart guard. The batch is finished only when nothing
11701
+ * counted against it is still going, so a retry reopens a batch an open panel can then follow again.
11702
+ */
11703
+ function follow(batch, work, deps, driving = true) {
11704
+ batch.running += 1;
11705
+ batch.finishedAt = null;
11706
+ (driving ? deps.track(work) : work).catch(() => {}).finally(() => {
11707
+ batch.running -= 1;
11708
+ if (batch.running === 0) batch.finishedAt = Date.now();
11709
+ });
11710
+ }
11711
+ function fail(row, error) {
11712
+ const refusal = refusalOf(error);
11713
+ row.status = "failed";
11714
+ row.reason = refusal.error;
11715
+ if (refusal.detail) row.detail = refusal.detail;
11716
+ }
11717
+ var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
11718
+ /**
11719
+ * Create the review for a row's target, or find the one it has, under the per-target lock. The
11720
+ * caller holds an analysis slot: nothing is created without one, so nothing created waits.
11721
+ */
11722
+ /**
11723
+ * `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
11724
+ * branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
11725
+ * the same branch or request, which for a fresh start is the very failure being started over.
11726
+ */
11727
+ async function prepareRow(row, target, agentId, model, deps, fresh = false) {
11728
+ const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
11729
+ row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
11730
+ if (prepared.kind === "existing" && !prepared.existing.inProgress) row.status = "already-reviewed";
11731
+ else begin(row);
11732
+ if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
11733
+ else delete row.kept;
11734
+ return prepared;
11735
+ }
11736
+ /**
11737
+ * Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
11738
+ * something else is already analysing is watched instead, never driven: the watch is handed to the
11739
+ * batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
11740
+ * stays Analysing until that analysis ends. Counted against the batch, not against restarts.
11706
11741
  */
11707
- function refusalOf(error) {
11708
- const text = message(error);
11709
- if (error instanceof AmbiguousRefError) {
11710
- const choice = baseChoiceOf(error);
11711
- return {
11712
- error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
11713
- detail: text,
11714
- baseChoice: choice
11715
- };
11716
- }
11717
- if (error instanceof DetachedHeadError) return { error: "This folder is on a detached HEAD, so there is no branch to review. Check out a branch in it, or review its pull request instead." };
11718
- return briefly(text);
11719
- }
11720
- /** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
11721
- function briefly(text) {
11722
- const first = text.split(/\n\s*\n/)[0].trim();
11723
- return first && first !== text.trim() ? {
11724
- error: first,
11725
- detail: text
11726
- } : { error: text };
11727
- }
11728
- /** A path as a person reads it, with their home folder as `~`. */
11729
- function displayPath(path, home) {
11730
- return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
11742
+ async function completeRow(row, prepared, batch, deps) {
11743
+ if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
11744
+ if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
11731
11745
  }
11732
- /** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
11733
- function expandPath(raw, home) {
11734
- const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
11735
- if (!trimmed) return null;
11736
- const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
11737
- return isAbsolute(expanded) ? expanded : null;
11746
+ async function settleRow(row, settling) {
11747
+ const settled = await settling;
11748
+ if (settled.ok) row.status = "ready";
11749
+ else if (settled.stopped) {
11750
+ row.status = "failed";
11751
+ row.reason = "Stopped";
11752
+ } else fail(row, settled.reason);
11738
11753
  }
11739
- function message(error) {
11740
- return error instanceof Error ? error.message : String(error);
11754
+ /**
11755
+ * A retry runs against the commit the row was first resolved to, never a newer one: a retry
11756
+ * resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
11757
+ * that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
11758
+ * says so and offers the fresh start instead of quietly reviewing something else.
11759
+ */
11760
+ async function stillThere(row, target, what, deps) {
11761
+ if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
11762
+ row.status = "failed";
11763
+ row.reason = `The commit this review was of (${target.sha.slice(0, 7)}) is no longer in this repository, so it can't be retried as it was. Start a fresh review to analyse the ${what} as it is now.`;
11764
+ row.stale = true;
11765
+ return false;
11741
11766
  }
11742
- /** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
11743
- function unresolvedAgent(resolution) {
11744
- switch (resolution.kind) {
11745
- case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
11746
- case "unknown-id": return `"${resolution.id}" is not an agent PR Review Buddy knows. Choose another under Review options, or change the default in Settings.`;
11747
- case "unavailable": return `${resolution.agent.displayName} can't be used from the workspace: ${resolution.reason}`;
11767
+ /**
11768
+ * Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
11769
+ * finds the latest review of the branch or request: one made since, of newer commits, would be
11770
+ * handed back in place of the one that failed, and a retry would quietly become a different review.
11771
+ *
11772
+ * Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
11773
+ * something has taken it up since (that Retry, a terminal), and it is watched rather than run again.
11774
+ */
11775
+ async function retryReview(row, batch, deps) {
11776
+ const reviewId = row.reviewId;
11777
+ const kept = deps.resumeOf(reviewId);
11778
+ begin(row);
11779
+ if (kept) {
11780
+ row.kept = kept;
11781
+ return settleRow(row, deps.settle(reviewId));
11748
11782
  }
11783
+ follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
11749
11784
  }
11750
11785
  /**
11751
- * The agent a submission runs under: the one named under Review options, or else the same answer
11752
- * the CLI reaches with no flag (the configured default, or the only one installed). Never the
11753
- * harness's own fallback: an id is always passed down, so `startJob` never has to guess.
11786
+ * A fresh start that ended before it made its review leaves the row exactly as it was: the old
11787
+ * target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
11788
+ * Retry finds the old commit gone again, rather than resuming the old review against it.
11754
11789
  */
11755
- async function agentFor(flag, deps) {
11756
- if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
11757
- const resolution = await deps.resolveAgent(flag || void 0);
11758
- if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
11759
- if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
11760
- return refuse(resolution.kind === "unknown-id" ? 400 : 409, unresolvedAgent(resolution), { field: "agent" });
11790
+ function keepFreshPending(row, replaced) {
11791
+ if (row.status === "failed" && !replaced) row.stale = true;
11761
11792
  }
11762
- function modelOf(value) {
11763
- if (value === void 0 || value === null || value === "") return void 0;
11764
- if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
11765
- return value.trim() || void 0;
11793
+ function keptOf(agentId, model, deps) {
11794
+ return {
11795
+ agent: deps.agentName(agentId),
11796
+ ...model ? { model } : {}
11797
+ };
11766
11798
  }
11767
11799
  /**
11768
- * The tail of each target's queue of creations, by batch identity. In-process, like the fetch
11769
- * queue in `target.ts`.
11800
+ * Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
11801
+ * dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
11770
11802
  *
11771
- * `prepareReview` looks for an existing review and then creates one, and nothing between the two
11772
- * stops a second start of the same target, from another tab or another paste, from looking in the
11773
- * same gap and creating a second review of it. One at a time per target closes that gap for every
11774
- * start this server makes; the second one finds the first one's review.
11803
+ * Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
11804
+ * other batch and every branch review, and is resolved and created only once it has one.
11775
11805
  */
11776
- var creations = /* @__PURE__ */ new Map();
11777
- function oneCreationAtATime(target, work) {
11778
- const key = batchIdentity(target);
11779
- const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
11780
- const tail = mine.catch(() => {});
11781
- creations.set(key, tail);
11782
- tail.then(() => {
11783
- if (creations.get(key) === tail) creations.delete(key);
11784
- });
11785
- return mine;
11806
+ async function startPullRequestBatch(body, deps) {
11807
+ const raw = Array.isArray(body?.items) ? body.items : null;
11808
+ if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
11809
+ if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
11810
+ const items = [];
11811
+ for (const entry of raw) {
11812
+ const url = typeof entry?.url === "string" ? entry.url : "";
11813
+ const link = parsePullRequestLink(url);
11814
+ if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
11815
+ const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
11816
+ if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
11817
+ items.push({
11818
+ url: link.url,
11819
+ owner: link.owner,
11820
+ repo: link.repo,
11821
+ number: link.number,
11822
+ key: link.key,
11823
+ folder
11824
+ });
11825
+ }
11826
+ const model = modelOf(body?.model);
11827
+ if (isAnswer(model)) return model;
11828
+ const agent = await agentFor(body?.agent, deps);
11829
+ if (isAnswer(agent)) return agent;
11830
+ const batch = newBatch(items.map((item) => ({
11831
+ key: item.key,
11832
+ label: `${item.owner}/${item.repo} #${item.number}`,
11833
+ status: "waiting"
11834
+ })));
11835
+ batch.rows.forEach((row, index) => noteRow(row, {
11836
+ repoPath: items[index].folder,
11837
+ prNumber: items[index].number
11838
+ }));
11839
+ /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
11840
+ const claimed = /* @__PURE__ */ new Map();
11841
+ /** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
11842
+ const resolved = [];
11843
+ const lane = async (item, index, fresh = false) => {
11844
+ const row = batch.rows[index];
11845
+ const release = await deps.slots.acquire();
11846
+ begin(row);
11847
+ /** A fresh start has made its review, so the row now belongs to the new target. */
11848
+ let replaced = false;
11849
+ try {
11850
+ let target = fresh ? void 0 : resolved[index];
11851
+ if (target) {
11852
+ if (!await stillThere(row, target, "pull request", deps)) return;
11853
+ if (row.reviewId) return await retryReview(row, batch, deps);
11854
+ } else {
11855
+ const remote = await deps.remoteOf(item.folder);
11856
+ if (!isGitHubRepository(remote, item.owner, item.repo)) {
11857
+ row.status = "failed";
11858
+ row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
11859
+ return;
11860
+ }
11861
+ target = await deps.resolvePullRequest(item.folder, item.url);
11862
+ if (!fresh) resolved[index] = target;
11863
+ }
11864
+ const identity = batchIdentity(target);
11865
+ const first = claimed.get(identity);
11866
+ if (first !== void 0 && first !== index) {
11867
+ row.status = "already-reviewed";
11868
+ row.reason = `Same pull request as ${batch.rows[first].label}`;
11869
+ return;
11870
+ }
11871
+ claimed.set(identity, index);
11872
+ const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
11873
+ if (fresh) {
11874
+ resolved[index] = target;
11875
+ replaced = true;
11876
+ }
11877
+ await completeRow(row, prepared, batch, deps);
11878
+ } catch (error) {
11879
+ fail(row, error);
11880
+ } finally {
11881
+ if (fresh) keepFreshPending(row, replaced);
11882
+ release();
11883
+ }
11884
+ };
11885
+ batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
11886
+ follow(batch, runBatch(items, {
11887
+ concurrency: deps.concurrency,
11888
+ run: lane
11889
+ }), deps);
11890
+ return {
11891
+ status: 200,
11892
+ body: {
11893
+ batchId: batch.id,
11894
+ rows: rowsOf(batch)
11895
+ }
11896
+ };
11786
11897
  }
11787
- var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
11788
- async function addReviewOptions(deps) {
11789
- const [agents, resolution] = await Promise.all([deps.agents(), deps.resolveAgent()]);
11898
+ /**
11899
+ * A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
11900
+ * about is never forgotten, so its Retry stays good for as long as the panel is open.
11901
+ */
11902
+ function batchRows(id) {
11903
+ const batch = batches.get(id);
11904
+ if (!batch) return lostBatch();
11905
+ batch.seenAt = Date.now();
11906
+ return {
11907
+ status: 200,
11908
+ body: {
11909
+ rows: rowsOf(batch),
11910
+ finished: batch.finishedAt !== null
11911
+ }
11912
+ };
11913
+ }
11914
+ /**
11915
+ * Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
11916
+ * a while is forgotten too; the server cannot tell which happened, so it says what follows from
11917
+ * either.
11918
+ */
11919
+ var lostBatch = () => refuse(404, "This batch is no longer on the workspace server, so it can’t be followed or retried here. Reviews that had started are in your reviews list. Any that were still queued did not start.");
11920
+ /**
11921
+ * Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
11922
+ * and base, and the same commits, never the branch or pull request as it has since moved on. A
11923
+ * review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
11924
+ * one exception, sent only when those commits have gone: it looks again at what is there now.
11925
+ * The other rows are left exactly as they are, and the batch is followed again until it ends.
11926
+ */
11927
+ function retryBatchRow(id, key, fresh = false) {
11928
+ const batch = batches.get(id);
11929
+ if (!batch) return lostBatch();
11930
+ const index = batch.rows.findIndex((row) => row.key === key);
11931
+ const row = batch.rows[index];
11932
+ if (!row) return refuse(404, "That review is not part of this batch.");
11933
+ if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
11934
+ row.status = "waiting";
11935
+ const known = facts.get(row);
11936
+ if (known) delete known.takenAt;
11937
+ delete row.reason;
11938
+ delete row.detail;
11939
+ delete row.stale;
11940
+ batch.seenAt = Date.now();
11941
+ batch.rerun(index, fresh === true);
11790
11942
  return {
11791
11943
  status: 200,
11792
11944
  body: {
11793
- agents,
11794
- agent: resolution.kind === "resolved" ? {
11795
- kind: "resolved",
11796
- id: resolution.agent.id,
11797
- displayName: resolution.agent.displayName
11798
- } : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
11799
- kind: "refused",
11800
- error: unresolvedAgent(resolution)
11801
- },
11802
- canChooseFolder: deps.chooseFolder !== null
11945
+ rows: rowsOf(batch),
11946
+ finished: false
11803
11947
  }
11804
11948
  };
11805
11949
  }
11806
- function reviewedRow(reviewId, deps) {
11807
- const kept = deps.resumeOf(reviewId);
11808
- return {
11809
- reviewId,
11810
- ...kept ? { kept } : {}
11811
- };
11812
- }
11813
- function folderOption(folder, home) {
11950
+ /** The recent and discoverable folders, for the Branch panel's list. */
11951
+ async function listFolders(deps) {
11814
11952
  return {
11815
- path: folder.path,
11816
- display: displayPath(folder.path, home),
11817
- branch: folder.branch
11953
+ status: 200,
11954
+ body: { folders: (await deps.folders()).map((folder) => ({
11955
+ ...folderOption(folder, deps.home),
11956
+ repo: folder.remote ? folder.remote.repo : basename(folder.path)
11957
+ })) }
11818
11958
  };
11819
11959
  }
11820
11960
  /**
11821
- * What a paste would start, without starting anything: rows, the folders each could run in, and
11822
- * which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
11823
- * run on every pause in typing.
11961
+ * What reviewing this folder would review, read without changing anything: the same target a bare
11962
+ * `prreviewbuddy` here would resolve, and the review it would find.
11824
11963
  *
11825
- * Every link gets a row, however many there are. Past the batch limit the panel refuses to start
11826
- * rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
11827
- * got twenty reviews would not find out which one was missing.
11964
+ * With the base the reviewer named, when they named one, so what this checks is the comparison
11965
+ * that would be submitted. Without it, a repository whose default base is ambiguous could never be
11966
+ * reviewed from here, though naming the base is exactly what settles it.
11828
11967
  */
11829
- async function previewPullRequests(text, deps) {
11830
- if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
11831
- const links = extractPullRequestLinks(text);
11832
- const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
11833
- return {
11968
+ async function inspectFolder(rawPath, deps, rawBase) {
11969
+ const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
11970
+ if (!path) return {
11834
11971
  status: 200,
11835
11972
  body: {
11836
- rows: links.map((link) => {
11837
- if (link.kind === "unsupported") return {
11838
- key: link.key,
11839
- label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
11840
- url: link.url,
11841
- ok: false,
11842
- reason: unsupportedReason(link.provider),
11843
- folders: []
11844
- };
11845
- const label = `${link.owner}/${link.repo} #${link.number}`;
11846
- const candidates = candidatesFor(folders, link, summaries);
11847
- if (candidates.length === 0) return {
11848
- key: link.key,
11849
- label,
11850
- url: link.url,
11851
- ok: false,
11852
- reason: "Local repository not found",
11853
- needsFolder: true,
11854
- folders: []
11855
- };
11856
- const reviewed = reviewedIn(candidates, link, summaries);
11857
- return {
11858
- key: link.key,
11859
- label,
11860
- url: link.url,
11861
- ok: true,
11862
- folders: candidates.map((folder) => folderOption(folder, deps.home)),
11863
- folder: candidates[0].path,
11864
- ...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
11865
- };
11866
- }),
11867
- limit: MAX_BATCH
11973
+ ok: false,
11974
+ error: "Enter the full path of a folder, or choose one."
11868
11975
  }
11869
11976
  };
11870
- }
11871
- var batches = /* @__PURE__ */ new Map();
11872
- function forgetFinished(now) {
11873
- for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
11874
- }
11875
- function newBatch(rows) {
11876
- forgetFinished(Date.now());
11877
- const now = Date.now();
11878
- const batch = {
11879
- id: randomUUID(),
11880
- rows,
11881
- finishedAt: now,
11882
- seenAt: now,
11883
- running: 0,
11884
- rerun: () => {}
11977
+ const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
11978
+ let target;
11979
+ try {
11980
+ target = await deps.resolveBranch(path, named);
11981
+ } catch (error) {
11982
+ return {
11983
+ status: 200,
11984
+ body: {
11985
+ ok: false,
11986
+ ...refusalOf(error)
11987
+ }
11988
+ };
11989
+ }
11990
+ const [remote, base, existing] = await Promise.all([
11991
+ deps.remoteOf(target.originRepoPath),
11992
+ named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
11993
+ deps.findExisting(target)
11994
+ ]);
11995
+ const kept = existing ? deps.resumeOf(existing.id) : null;
11996
+ return {
11997
+ status: 200,
11998
+ body: {
11999
+ ok: true,
12000
+ path: target.originRepoPath,
12001
+ display: displayPath(target.originRepoPath, deps.home),
12002
+ repo: remote ? remote.repo : basename(target.originRepoPath),
12003
+ branch: target.branch,
12004
+ base,
12005
+ ...existing ? { reviewed: {
12006
+ reviewId: existing.id,
12007
+ inProgress: existing.inProgress,
12008
+ ...kept ? { kept } : {}
12009
+ } } : {}
12010
+ }
11885
12011
  };
11886
- batches.set(batch.id, batch);
11887
- return batch;
11888
12012
  }
11889
12013
  /**
11890
- * Count work against a batch, inside the restart guard. The batch is finished only when nothing
11891
- * counted against it is still going, so a retry reopens a batch an open panel can then follow again.
12014
+ * Review the branch checked out in a folder: `prreviewbuddy` run there.
12015
+ *
12016
+ * Always answers with a one-row batch, which is what the panel follows, so a branch shows its
12017
+ * progress exactly as a pull request does. With a free analysis slot it answers once the review
12018
+ * exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
12019
+ * it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
12020
+ * nobody is running, which the sweep would fail. A review the branch already has needs no slot.
12021
+ *
12022
+ * A refusal before anything exists comes back as a refusal, with the base question when that is
12023
+ * what it was, so the panel can ask it beside the folder rather than as a failed row.
11892
12024
  */
11893
- function follow(batch, work, deps, driving = true) {
11894
- batch.running += 1;
11895
- batch.finishedAt = null;
11896
- (driving ? deps.track(work) : work).catch(() => {}).finally(() => {
11897
- batch.running -= 1;
11898
- if (batch.running === 0) batch.finishedAt = Date.now();
12025
+ async function startBranchReview(body, deps) {
12026
+ const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
12027
+ if (!path) return refuse(400, "Choose the working folder to review.");
12028
+ const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
12029
+ const model = modelOf(body?.model);
12030
+ if (isAnswer(model)) return model;
12031
+ let target;
12032
+ try {
12033
+ target = await deps.resolveBranch(path, base);
12034
+ } catch (error) {
12035
+ return {
12036
+ status: 400,
12037
+ body: refusalOf(error)
12038
+ };
12039
+ }
12040
+ const agent = await agentFor(body?.agent, deps);
12041
+ if (isAnswer(agent)) return agent;
12042
+ const row = {
12043
+ key: batchIdentity(target),
12044
+ label: target.branch,
12045
+ status: "waiting"
12046
+ };
12047
+ const batch = newBatch([row]);
12048
+ noteRow(row, {
12049
+ repoPath: target.originRepoPath,
12050
+ branch: target.branch
11899
12051
  });
12052
+ /**
12053
+ * The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
12054
+ * branch has moved since, unless `fresh`, which looks at the folder again as it is now.
12055
+ */
12056
+ const run = async (release, again) => {
12057
+ const fresh = again === "fresh";
12058
+ let replaced = false;
12059
+ begin(row);
12060
+ try {
12061
+ const next = fresh ? await deps.resolveBranch(path, base) : target;
12062
+ if (again === "retry") {
12063
+ if (!await stillThere(row, target, "branch", deps)) return;
12064
+ if (row.reviewId) return await retryReview(row, batch, deps);
12065
+ }
12066
+ const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
12067
+ target = next;
12068
+ replaced = fresh;
12069
+ await completeRow(row, prepared, batch, deps);
12070
+ } catch (error) {
12071
+ fail(row, error);
12072
+ } finally {
12073
+ if (fresh) keepFreshPending(row, replaced);
12074
+ release();
12075
+ }
12076
+ };
12077
+ batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
12078
+ const answer = (outcome) => ({
12079
+ status: 200,
12080
+ body: {
12081
+ outcome,
12082
+ batchId: batch.id,
12083
+ rows: rowsOf(batch),
12084
+ ...row.reviewId ? { reviewId: row.reviewId } : {},
12085
+ ...row.kept ? { kept: row.kept } : {}
12086
+ }
12087
+ });
12088
+ const release = deps.slots.tryAcquire();
12089
+ if (!release) {
12090
+ const existing = await deps.findExisting(target);
12091
+ if (existing && !deps.resumeOf(existing.id)) {
12092
+ row.reviewId = existing.id;
12093
+ if (!existing.inProgress) {
12094
+ row.status = "already-reviewed";
12095
+ return answer("existing");
12096
+ }
12097
+ begin(row);
12098
+ follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
12099
+ return answer("existing");
12100
+ }
12101
+ follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
12102
+ return answer("waiting");
12103
+ }
12104
+ let prepared;
12105
+ try {
12106
+ prepared = await prepareRow(row, target, agent.agentId, model, deps);
12107
+ } catch (error) {
12108
+ release();
12109
+ batches.delete(batch.id);
12110
+ return {
12111
+ status: 409,
12112
+ body: refusalOf(error)
12113
+ };
12114
+ }
12115
+ if (prepared.kind === "existing") {
12116
+ await completeRow(row, prepared, batch, deps);
12117
+ release();
12118
+ } else follow(batch, (async () => {
12119
+ try {
12120
+ await settleRow(row, deps.settle(row.reviewId));
12121
+ } catch (error) {
12122
+ fail(row, error);
12123
+ } finally {
12124
+ release();
12125
+ }
12126
+ })(), deps);
12127
+ return answer(prepared.kind);
11900
12128
  }
11901
- function fail(row, error) {
11902
- const refusal = refusalOf(error);
11903
- row.status = "failed";
11904
- row.reason = refusal.error;
11905
- if (refusal.detail) row.detail = refusal.detail;
11906
- }
11907
- var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
11908
- /**
11909
- * Create the review for a row's target, or find the one it has, under the per-target lock. The
11910
- * caller holds an analysis slot: nothing is created without one, so nothing created waits.
11911
- */
11912
- /**
11913
- * `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
11914
- * branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
11915
- * the same branch or request, which for a fresh start is the very failure being started over.
11916
- */
11917
- async function prepareRow(row, target, agentId, model, deps, fresh = false) {
11918
- const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
11919
- row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
11920
- if (prepared.kind === "existing") row.status = prepared.existing.inProgress ? "reviewing" : "already-reviewed";
11921
- else row.status = "reviewing";
11922
- if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
11923
- else delete row.kept;
11924
- return prepared;
12129
+ async function chooseFolder(deps) {
12130
+ if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
12131
+ try {
12132
+ const path = await deps.chooseFolder();
12133
+ return {
12134
+ status: 200,
12135
+ body: path ? { path } : { cancelled: true }
12136
+ };
12137
+ } catch (error) {
12138
+ return refuse(500, message(error));
12139
+ }
11925
12140
  }
12141
+ //#endregion
12142
+ //#region ../../packages/review-harness/src/workspace/live_operations.ts
11926
12143
  /**
11927
- * Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
11928
- * something else is already analysing is watched instead, never driven: the watch is handed to the
11929
- * batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
11930
- * stays Analysing until that analysis ends. Counted against the batch, not against restarts.
12144
+ * The review work going on now, as one list: the one place that gathers it.
12145
+ *
12146
+ * The Reviews page's In progress section is its first reader, and anything else that monitors
12147
+ * execution should read this rather than gathering the same facts again. The rule that turns the
12148
+ * facts into entries is `deriveOperations`; this only supplies them, with the claim test
12149
+ * `analysisStates` uses, so the list and a card's analysis line never disagree about what is running.
12150
+ *
12151
+ * **Two scopes, on purpose.**
12152
+ *
12153
+ * - **Running work is machine-wide.** Jobs live in the store on disk and are claimed by whichever
12154
+ * process drives them, so a review started from a terminal, an MCP session or this server is
12155
+ * listed here alike.
12156
+ * - **Queued work is this process's only.** Rows waiting for an analysis slot are held in memory by
12157
+ * the server that accepted them (`pendingBatchRows`, from `add_review.ts`). Another process's
12158
+ * waiting work, such as the later pull requests of a `prreviewbuddy review --pr …` batch, is not
12159
+ * listed until it starts and has a job. A restart forgets this server's own waiting rows too.
12160
+ *
12161
+ * Nothing persists or shares the queue across processes, and nothing here should claim it does.
12162
+ *
12163
+ * `jobs` and `rows` are injected so a caller with its own snapshot, or a test, can hand them in.
11931
12164
  */
11932
- async function completeRow(row, prepared, batch, deps) {
11933
- if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
11934
- if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
11935
- }
11936
- async function settleRow(row, settling) {
11937
- const settled = await settling;
11938
- if (settled.ok) row.status = "ready";
11939
- else if (settled.stopped) {
11940
- row.status = "failed";
11941
- row.reason = "Stopped";
11942
- } else fail(row, settled.reason);
12165
+ function liveOperations(now = Date.now(), jobs = allJobs(), rows = pendingBatchRows()) {
12166
+ return deriveOperations(jobs, (job) => isClaimed(job.id, now), rows);
11943
12167
  }
12168
+ //#endregion
12169
+ //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
11944
12170
  /**
11945
- * A retry runs against the commit the row was first resolved to, never a newer one: a retry
11946
- * resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
11947
- * that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
11948
- * says so and offers the fresh start instead of quietly reviewing something else.
12171
+ * Whether anything the reviews index draws has changed, as one token.
12172
+ *
12173
+ * The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
12174
+ * never needs to know *which* review changed, so it asks one question whose cost is the same for
12175
+ * five reviews and five hundred. Per-card revisions would be more information than the page has any
12176
+ * use for, and a request per card to carry it.
12177
+ *
12178
+ * Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
12179
+ * tokens for equality, never order them, never parse one. The implementation below is a hash of
12180
+ * facts the page happens to draw today, and the whole point of saying so here is that it can be
12181
+ * replaced by a counter, a watch, or anything else without a page having to be told.
12182
+ *
12183
+ * ## What is in it, and what is deliberately not
12184
+ *
12185
+ * In: every review's id and file revision, and the phase of every analysis running right now.
12186
+ * Between them those cover everything a card shows — the summary, the risk badge, the counts, the
12187
+ * pin, the reviewed mark, the running line — because every one of those reaches disk through
12188
+ * `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
12189
+ *
12190
+ * Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
12191
+ * streams its progress into the job record every few seconds, so a token derived from job file
12192
+ * mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
12193
+ * would then reload every few seconds for the whole length of an analysis — a page thrashing
12194
+ * itself, with the reviewer's scroll position going with it. Phase only, which changes four times
12195
+ * in a run.
11949
12196
  */
11950
- async function stillThere(row, target, what, deps) {
11951
- if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
11952
- row.status = "failed";
11953
- row.reason = `The commit this review was of (${target.sha.slice(0, 7)}) is no longer in this repository, so it can't be retried as it was. Start a fresh review to analyse the ${what} as it is now.`;
11954
- row.stale = true;
11955
- return false;
11956
- }
11957
12197
  /**
11958
- * Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
11959
- * finds the latest review of the branch or request: one made since, of newer commits, would be
11960
- * handed back in place of the one that failed, and a retry would quietly become a different review.
12198
+ * What is being done to each review, or was last done and stopped.
11961
12199
  *
11962
- * Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
11963
- * something has taken it up since (that Retry, a terminal), and it is watched rather than run again.
12200
+ * Keyed by review, valued by the phase the card should name. Two states reach it and a third
12201
+ * deliberately does not:
12202
+ *
12203
+ * - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
12204
+ * load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
12205
+ * before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
12206
+ * be the index confidently reporting work that nothing is doing.
12207
+ * - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
12208
+ * this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
12209
+ * `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
12210
+ * and the card's "Analysis stopped" branch was unreachable in production while passing a unit
12211
+ * test that handed it a state the product could not produce. A review whose analysis failed went
12212
+ * back to reading "No standard review yet" the moment its driver let go.
12213
+ * - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
12214
+ * result on the card already, and an abandoned one is the sweep's to judge rather than this
12215
+ * renderer's to pre-empt.
12216
+ *
12217
+ * **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
12218
+ * a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
12219
+ * KISS run, and the review page's poll is built around one finishing while the other goes on. An
12220
+ * earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
12221
+ * first spoke for a standard analysis that was still working, and the card fell silent on work that
12222
+ * was happening in front of the reviewer.
12223
+ *
12224
+ * So the precedence is running, then the most recent ending, and it is stated in that order because
12225
+ * that is the property: **nothing that has ended can hide something that has not.**
12226
+ *
12227
+ * `jobs` is injected only so a test can hand the same set in two different orders and prove the
12228
+ * answer does not move. Production always reads the directory.
12229
+ *
12230
+ * A failure is visible for as long as the job record survives, which is the sweep's retention. Once
12231
+ * that passes the card quietly returns to saying nothing, which is the same silence every review
12232
+ * had before this existed.
11964
12233
  */
11965
- async function retryReview(row, batch, deps) {
11966
- const reviewId = row.reviewId;
11967
- const kept = deps.resumeOf(reviewId);
11968
- row.status = "reviewing";
11969
- if (kept) {
11970
- row.kept = kept;
11971
- return settleRow(row, deps.settle(reviewId));
12234
+ function analysisStates(now = Date.now(), jobs = allJobs()) {
12235
+ const byReview = /* @__PURE__ */ new Map();
12236
+ for (const job of jobs) {
12237
+ if (purposeOf(job) === "ask") continue;
12238
+ const held = byReview.get(job.workspaceId);
12239
+ if (held) held.push(job);
12240
+ else byReview.set(job.workspaceId, [job]);
11972
12241
  }
11973
- follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
12242
+ /**
12243
+ * Both comparators are **total**, and that is a requirement rather than tidiness.
12244
+ *
12245
+ * `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
12246
+ * happened to return. This function's output is hashed into the index token, and the page reloads
12247
+ * whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
12248
+ * reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
12249
+ * same hazard the sorting rule was written for, one level further down.
12250
+ *
12251
+ * Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
12252
+ * before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
12253
+ * the last resort because it is the only field guaranteed to differ.
12254
+ */
12255
+ const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
12256
+ const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
12257
+ const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
12258
+ const states = /* @__PURE__ */ new Map();
12259
+ for (const [reviewId, jobs] of byReview) {
12260
+ const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
12261
+ if (running.length > 0) {
12262
+ states.set(reviewId, {
12263
+ phase: running[0].phase,
12264
+ purpose: purposeOf(running[0])
12265
+ });
12266
+ continue;
12267
+ }
12268
+ const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
12269
+ if (ended[0]?.phase === "failed") states.set(reviewId, {
12270
+ phase: "failed",
12271
+ purpose: "review",
12272
+ ...ended[0].failure?.stopped ? { stopped: true } : {}
12273
+ });
12274
+ }
12275
+ return states;
11974
12276
  }
11975
12277
  /**
11976
- * A fresh start that ended before it made its review leaves the row exactly as it was: the old
11977
- * target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
11978
- * Retry finds the old commit gone again, rather than resuming the old review against it.
12278
+ * The token.
12279
+ *
12280
+ * Takes the live analyses rather than reading them, so that one request answers with a token and a
12281
+ * running state that describe the same instant. Computing them separately would let a phase change
12282
+ * land between the two, and the page would then be told "nothing has changed" alongside a state it
12283
+ * had not seen.
11979
12284
  */
11980
- function keepFreshPending(row, replaced) {
11981
- if (row.status === "failed" && !replaced) row.stale = true;
12285
+ function reviewsRevision(live = analysisStates(), operations = liveOperations()) {
12286
+ const hash = createHash("sha1");
12287
+ for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
12288
+ for (const id of [...live.keys()].sort()) {
12289
+ const state = live.get(id);
12290
+ hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
12291
+ }
12292
+ for (const operation of [...operations].sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) hash.update(`op:${operation.key}:${operation.kind}:${operation.phase ?? ""}:${operation.reviewId ?? ""}\n`);
12293
+ hash.update(`queue:${queueStamp()}\n`);
12294
+ for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
12295
+ for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
12296
+ for (const [key, entry] of Object.entries(readWatch()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`head:${key}:${entry.changedAt}\n`);
12297
+ for (const [key, entry] of Object.entries(readPrStates()).sort(([a], [b]) => a.localeCompare(b))) if (entry.changedAt !== void 0) hash.update(`pr-changed:${key}:${entry.changedAt}\n`);
12298
+ return hash.digest("base64url");
11982
12299
  }
11983
- function keptOf(agentId, model, deps) {
11984
- return {
11985
- agent: deps.agentName(agentId),
11986
- ...model ? { model } : {}
11987
- };
12300
+ function asKind(value) {
12301
+ return value === "issue" || value === "question" ? value : null;
11988
12302
  }
11989
- /**
11990
- * Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
11991
- * dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
11992
- *
11993
- * Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
11994
- * other batch and every branch review, and is resolved and created only once it has one.
11995
- */
11996
- async function startPullRequestBatch(body, deps) {
11997
- const raw = Array.isArray(body?.items) ? body.items : null;
11998
- if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
11999
- if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
12000
- const items = [];
12001
- for (const entry of raw) {
12002
- const url = typeof entry?.url === "string" ? entry.url : "";
12003
- const link = parsePullRequestLink(url);
12004
- if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
12005
- const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
12006
- if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
12007
- items.push({
12008
- url: link.url,
12009
- owner: link.owner,
12010
- repo: link.repo,
12011
- number: link.number,
12012
- key: link.key,
12013
- folder
12014
- });
12303
+ /** What the page sent, or null when it is not a request a host understands. */
12304
+ function parseReassessmentRequest(value) {
12305
+ const body = value;
12306
+ if (body?.op === "decide") {
12307
+ if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
12308
+ if (body.decision !== "accepted" && body.decision !== "declined") return null;
12309
+ return {
12310
+ op: "decide",
12311
+ proposalId: body.proposalId,
12312
+ decision: body.decision
12313
+ };
12314
+ }
12315
+ if (body?.op === "set") {
12316
+ const kind = asKind(body.kind);
12317
+ if (!kind || typeof body.id !== "string" || body.id === "") return null;
12318
+ if (!isGradeFor(kind, body.to)) return null;
12319
+ if (body.from !== null && !isGradeFor(kind, body.from)) return null;
12320
+ if (body.reason !== void 0 && typeof body.reason !== "string") return null;
12321
+ const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
12322
+ return {
12323
+ op: "set",
12324
+ kind,
12325
+ findingId: body.id,
12326
+ from: body.from,
12327
+ to: body.to,
12328
+ ...reason ? { reason } : {}
12329
+ };
12015
12330
  }
12016
- const model = modelOf(body?.model);
12017
- if (isAnswer(model)) return model;
12018
- const agent = await agentFor(body?.agent, deps);
12019
- if (isAnswer(agent)) return agent;
12020
- const batch = newBatch(items.map((item) => ({
12021
- key: item.key,
12022
- label: `${item.owner}/${item.repo} #${item.number}`,
12023
- status: "waiting"
12024
- })));
12025
- /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
12026
- const claimed = /* @__PURE__ */ new Map();
12027
- /** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
12028
- const resolved = [];
12029
- const lane = async (item, index, fresh = false) => {
12030
- const row = batch.rows[index];
12031
- const release = await deps.slots.acquire();
12032
- row.status = "reviewing";
12033
- /** A fresh start has made its review, so the row now belongs to the new target. */
12034
- let replaced = false;
12035
- try {
12036
- let target = fresh ? void 0 : resolved[index];
12037
- if (target) {
12038
- if (!await stillThere(row, target, "pull request", deps)) return;
12039
- if (row.reviewId) return await retryReview(row, batch, deps);
12040
- } else {
12041
- const remote = await deps.remoteOf(item.folder);
12042
- if (!isGitHubRepository(remote, item.owner, item.repo)) {
12043
- row.status = "failed";
12044
- row.reason = remote ? `This folder is a clone of ${remote.owner}/${remote.repo}, not ${item.owner}/${item.repo}` : `This folder is not a clone of ${item.owner}/${item.repo}`;
12045
- return;
12046
- }
12047
- target = await deps.resolvePullRequest(item.folder, item.url);
12048
- if (!fresh) resolved[index] = target;
12049
- }
12050
- const identity = batchIdentity(target);
12051
- const first = claimed.get(identity);
12052
- if (first !== void 0 && first !== index) {
12053
- row.status = "already-reviewed";
12054
- row.reason = `Same pull request as ${batch.rows[first].label}`;
12055
- return;
12056
- }
12057
- claimed.set(identity, index);
12058
- const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
12059
- if (fresh) {
12060
- resolved[index] = target;
12061
- replaced = true;
12062
- }
12063
- await completeRow(row, prepared, batch, deps);
12064
- } catch (error) {
12065
- fail(row, error);
12066
- } finally {
12067
- if (fresh) keepFreshPending(row, replaced);
12068
- release();
12069
- }
12331
+ return null;
12332
+ }
12333
+ var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
12334
+ var REASSESSMENT_REFUSED = {
12335
+ "not-a-grade": "That is not a grade this finding can have.",
12336
+ unchanged: "The finding already has that grade.",
12337
+ stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
12338
+ outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
12339
+ "no-reason": "A suggestion needs a reason.",
12340
+ unknown: "This review has no suggestion with that id.",
12341
+ decided: "That suggestion has already been answered.",
12342
+ "no-finding": "This review has no such finding."
12343
+ };
12344
+ function gradeFragments$1(session, kind, id) {
12345
+ const finding = findingIn(session, kind, id);
12346
+ if (!finding) return null;
12347
+ const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
12348
+ const options = {
12349
+ editable: true,
12350
+ askMore: true
12070
12351
  };
12071
- batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
12072
- follow(batch, runBatch(items, {
12073
- concurrency: deps.concurrency,
12074
- run: lane
12075
- }), deps);
12076
12352
  return {
12077
- status: 200,
12078
- body: {
12079
- batchId: batch.id,
12080
- rows: rowsOf(batch)
12081
- }
12353
+ kind,
12354
+ id,
12355
+ current: assessment.current,
12356
+ cardClass: cardGradeClass(kind, assessment.current),
12357
+ grade: renderGradeControl(kind, assessment, options),
12358
+ region: renderAssessmentRegion(kind, assessment, options),
12359
+ copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
12082
12360
  };
12083
12361
  }
12084
- /**
12085
- * A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
12086
- * about is never forgotten, so its Retry stays good for as long as the panel is open.
12087
- */
12088
- function batchRows(id) {
12089
- const batch = batches.get(id);
12090
- if (!batch) return lostBatch();
12091
- batch.seenAt = Date.now();
12362
+ /** The finding a request is about: named outright, or through the proposal it decides. */
12363
+ function findingOfRequest(session, request) {
12364
+ if (request.op === "set") return {
12365
+ kind: request.kind,
12366
+ id: request.findingId
12367
+ };
12368
+ const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
12369
+ return proposal ? {
12370
+ kind: proposal.kind,
12371
+ id: proposal.findingId
12372
+ } : null;
12373
+ }
12374
+ /** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
12375
+ function refusedReply(session, request, refusal) {
12376
+ const finding = findingOfRequest(session, request);
12377
+ const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
12092
12378
  return {
12093
- status: 200,
12379
+ status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
12094
12380
  body: {
12095
- rows: rowsOf(batch),
12096
- finished: batch.finishedAt !== null
12381
+ error: REASSESSMENT_REFUSED[refusal],
12382
+ refusal,
12383
+ ...card ? { card } : {}
12097
12384
  }
12098
12385
  };
12099
12386
  }
12387
+ //#endregion
12388
+ //#region ../../packages/review-harness/src/workspace/reassessment_state.ts
12100
12389
  /**
12101
- * Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
12102
- * a while is forgotten too; the server cannot tell which happened, so it says what follows from
12103
- * either.
12390
+ * The reviewer's grade moves and their answers to proposals, as `/state` receives them.
12391
+ *
12392
+ * The request, its refusals and the card parts a reply carries are shared with the extension
12393
+ * (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
12394
+ * against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
12395
+ * the write, where the answer is final.
12104
12396
  */
12105
- var lostBatch = () => refuse(404, "This batch is no longer on the workspace server, so it can’t be followed or retried here. Reviews that had started are in your reviews list. Any that were still queued did not start.");
12397
+ /** A stored review's card parts for one finding. */
12398
+ function gradeFragments(workspace, kind, id) {
12399
+ return gradeFragments$1(workspace.session, kind, id);
12400
+ }
12106
12401
  /**
12107
- * Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
12108
- * and base, and the same commits, never the branch or pull request as it has since moved on. A
12109
- * review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
12110
- * one exception, sent only when those commits have gone: it looks again at what is there now.
12111
- * The other rows are left exactly as they are, and the batch is followed again until it ends.
12402
+ * One write, onto the review as it is now, answered with the card as it now stands. `now` is
12403
+ * injectable so a test can name the time an entry records.
12112
12404
  */
12113
- function retryBatchRow(id, key, fresh = false) {
12114
- const batch = batches.get(id);
12115
- if (!batch) return lostBatch();
12116
- const index = batch.rows.findIndex((row) => row.key === key);
12117
- const row = batch.rows[index];
12118
- if (!row) return refuse(404, "That review is not part of this batch.");
12119
- if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
12120
- row.status = "waiting";
12121
- delete row.reason;
12122
- delete row.detail;
12123
- delete row.stale;
12124
- batch.seenAt = Date.now();
12125
- batch.rerun(index, fresh === true);
12405
+ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
12406
+ const request = parseReassessmentRequest(value);
12407
+ if (!request) return {
12408
+ status: 400,
12409
+ body: { error: BAD_REASSESSMENT_REQUEST }
12410
+ };
12411
+ const before = loadWorkspace(workspaceId);
12412
+ if (!before) return {
12413
+ status: 404,
12414
+ body: { error: "No such review" }
12415
+ };
12416
+ const early = reassessIn(structuredClone(before.session), request, now());
12417
+ if (!early.ok) return refusedReply(before.session, request, early.refusal);
12418
+ let outcome = null;
12419
+ const saved = mutateWorkspace(workspaceId, (current) => {
12420
+ outcome = reassessIn(current.session, request, now());
12421
+ });
12422
+ if (!saved || !outcome) return {
12423
+ status: 404,
12424
+ body: { error: "No such review" }
12425
+ };
12426
+ const written = outcome;
12427
+ if (!written.ok) return refusedReply(saved.session, request, written.refusal);
12428
+ const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
12126
12429
  return {
12127
12430
  status: 200,
12128
12431
  body: {
12129
- rows: rowsOf(batch),
12130
- finished: false
12432
+ ok: true,
12433
+ entry: written.entry,
12434
+ ...card ? { card } : {}
12131
12435
  }
12132
12436
  };
12133
12437
  }
12134
- /** The recent and discoverable folders, for the Branch panel's list. */
12135
- async function listFolders(deps) {
12438
+ //#endregion
12439
+ //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
12440
+ /** More than any real answer cites; a list longer than this is padding, not evidence. */
12441
+ var MAX_EVIDENCE = 10;
12442
+ function scaleText(kind) {
12443
+ return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
12444
+ }
12445
+ /** What the grade means, in the words the analysis prompt uses for it. */
12446
+ function gradeMeaning(kind) {
12447
+ return kind === "issue" ? "Severity is how much the issue would matter if it is real. It is not how sure anyone is that it is real." : "Importance is how much of the reviewer's attention the question deserves before they can approve.";
12448
+ }
12449
+ function findingText(kind, finding) {
12450
+ if (kind === "issue") {
12451
+ const issue = finding;
12452
+ const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
12453
+ return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
12454
+ }
12455
+ const question = finding;
12456
+ const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
12457
+ return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
12458
+ }
12459
+ function historyText(assessment) {
12460
+ if (!assessment.history.length) return "It has not been reassessed before.";
12461
+ return ["Earlier moves of this grade, oldest first:", ...assessment.history.map((step) => `- ${step.from ?? "ungraded"} → ${step.to}, ${sourcePhrase(step.source)}${step.reason ? `: ${step.reason}` : ""}`)].join("\n");
12462
+ }
12463
+ function threadText(thread) {
12464
+ if (!thread) return [];
12465
+ const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
12466
+ if (!comments.length) return [];
12467
+ return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
12468
+ }
12469
+ /** The block a Reassess turn adds to the ordinary Buddy prompt. */
12470
+ function buildReassessInstructions(input) {
12471
+ const { kind, finding, assessment } = input;
12472
+ const noun = kind === "issue" ? "severity" : "importance";
12473
+ const current = assessment.current;
12474
+ return [
12475
+ "---",
12476
+ `THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
12477
+ findingText(kind, finding),
12478
+ `Its ${noun} now: ${promptGrade(assessment)}.`,
12479
+ historyText(assessment),
12480
+ ...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
12481
+ ...threadText(input.thread),
12482
+ "---",
12483
+ `Judge whether its ${noun} should change, using the code in the checkout and everything above, including this conversation. ${gradeMeaning(kind)} Read the code that decides it rather than reasoning from the finding's wording.`,
12484
+ `Raising it is as legitimate as lowering it. "keep" is a good answer, and the right one whenever the evidence does not clearly move it; do not change it because you were asked.`,
12485
+ "Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
12486
+ `{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
12487
+ `"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
12488
+ "\"reason\" is one to three sentences the reviewer will read on the card: name what in the code or the conversation moves it, or what leaves it where it is.",
12489
+ "\"evidence\" is the places in the code that settle it, as paths relative to the repository root. Omit it rather than citing a file you did not read."
12490
+ ];
12491
+ }
12492
+ var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
12493
+ function readEvidence(value) {
12494
+ if (!Array.isArray(value)) return [];
12495
+ const out = [];
12496
+ for (const item of value) {
12497
+ const entry = item;
12498
+ const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
12499
+ if (!file) continue;
12500
+ const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
12501
+ out.push(line === void 0 ? { file } : {
12502
+ file,
12503
+ line
12504
+ });
12505
+ if (out.length === MAX_EVIDENCE) break;
12506
+ }
12507
+ return out;
12508
+ }
12509
+ /**
12510
+ * The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
12511
+ * grade on this finding's scale that differs from the current one, and every answer must give a
12512
+ * reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
12513
+ * use it, and nothing is proposed.
12514
+ */
12515
+ function parseReassessAnswer(raw, kind, current) {
12516
+ let parsed;
12517
+ try {
12518
+ parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
12519
+ } catch {
12520
+ return null;
12521
+ }
12522
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
12523
+ const obj = parsed;
12524
+ const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
12525
+ if (!reason) return null;
12526
+ const evidence = readEvidence(obj.evidence);
12527
+ if (obj.verdict === "keep") return {
12528
+ verdict: "keep",
12529
+ reason,
12530
+ evidence
12531
+ };
12532
+ if (obj.verdict !== "change") return null;
12533
+ if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
12136
12534
  return {
12137
- status: 200,
12138
- body: { folders: (await deps.folders()).map((folder) => ({
12139
- ...folderOption(folder, deps.home),
12140
- repo: folder.remote ? folder.remote.repo : basename(folder.path)
12141
- })) }
12535
+ verdict: "change",
12536
+ to: obj.to,
12537
+ reason,
12538
+ evidence
12142
12539
  };
12143
12540
  }
12541
+ //#endregion
12542
+ //#region ../../packages/review-harness/src/workspace/reassess_turn.ts
12144
12543
  /**
12145
- * What reviewing this folder would review, read without changing anything: the same target a bare
12146
- * `prreviewbuddy` here would resolve, and the review it would find.
12544
+ * The answer, with every cited place checked against the commit the review describes. A path that
12545
+ * is not there is dropped and the suggestion kept: the judgement may stand even when the model
12546
+ * misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
12547
+ * itself fails, no evidence is kept rather than unchecked evidence.
12147
12548
  *
12148
- * With the base the reviewer named, when they named one, so what this checks is the comparison
12149
- * that would be submitted. Without it, a repository whose default base is ambiguous could never be
12150
- * reviewed from here, though naming the base is exactly what settles it.
12549
+ * Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
12550
+ * no verified code evidence rather than drawing it like one whose citations held. Acceptance does
12551
+ * not depend on it; a reason may be sound without a citation.
12151
12552
  */
12152
- async function inspectFolder(rawPath, deps, rawBase) {
12153
- const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
12154
- if (!path) return {
12155
- status: 200,
12156
- body: {
12157
- ok: false,
12158
- error: "Enter the full path of a folder, or choose one."
12159
- }
12553
+ async function readReassessTurn(raw, target, where, check = existingPaths) {
12554
+ const answer = parseReassessAnswer(raw, target.kind, target.from);
12555
+ if (!answer || !answer.evidence.length) return {
12556
+ answer,
12557
+ raw,
12558
+ droppedEvidence: 0
12160
12559
  };
12161
- const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
12162
- let target;
12560
+ let real;
12163
12561
  try {
12164
- target = await deps.resolveBranch(path, named);
12165
- } catch (error) {
12166
- return {
12167
- status: 200,
12168
- body: {
12169
- ok: false,
12170
- ...refusalOf(error)
12171
- }
12172
- };
12562
+ real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
12563
+ } catch {
12564
+ real = /* @__PURE__ */ new Set();
12173
12565
  }
12174
- const [remote, base, existing] = await Promise.all([
12175
- deps.remoteOf(target.originRepoPath),
12176
- named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
12177
- deps.findExisting(target)
12178
- ]);
12179
- const kept = existing ? deps.resumeOf(existing.id) : null;
12566
+ const evidence = answer.evidence.filter((place) => real.has(place.file));
12180
12567
  return {
12181
- status: 200,
12182
- body: {
12183
- ok: true,
12184
- path: target.originRepoPath,
12185
- display: displayPath(target.originRepoPath, deps.home),
12186
- repo: remote ? remote.repo : basename(target.originRepoPath),
12187
- branch: target.branch,
12188
- base,
12189
- ...existing ? { reviewed: {
12190
- reviewId: existing.id,
12191
- inProgress: existing.inProgress,
12192
- ...kept ? { kept } : {}
12193
- } } : {}
12194
- }
12568
+ answer: {
12569
+ ...answer,
12570
+ evidence
12571
+ },
12572
+ raw,
12573
+ droppedEvidence: answer.evidence.length - evidence.length
12195
12574
  };
12196
12575
  }
12197
12576
  /**
12198
- * Review the branch checked out in a folder: `prreviewbuddy` run there.
12199
- *
12200
- * Always answers with a one-row batch, which is what the panel follows, so a branch shows its
12201
- * progress exactly as a pull request does. With a free analysis slot it answers once the review
12202
- * exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
12203
- * it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
12204
- * nobody is running, which the sweep would fail. A review the branch already has needs no slot.
12205
- *
12206
- * A refusal before anything exists comes back as a refusal, with the base question when that is
12207
- * what it was, so the panel can ask it beside the folder rather than as a failed row.
12577
+ * Writes the suggestion onto the session, when there is one to write. Run inside the store write on
12578
+ * the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
12579
+ * while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
12580
+ * grade it never saw.
12208
12581
  */
12209
- async function startBranchReview(body, deps) {
12210
- const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
12211
- if (!path) return refuse(400, "Choose the working folder to review.");
12212
- const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
12213
- const model = modelOf(body?.model);
12214
- if (isAnswer(model)) return model;
12215
- let target;
12216
- try {
12217
- target = await deps.resolveBranch(path, base);
12218
- } catch (error) {
12219
- return {
12220
- status: 400,
12221
- body: refusalOf(error)
12222
- };
12223
- }
12224
- const agent = await agentFor(body?.agent, deps);
12225
- if (isAnswer(agent)) return agent;
12226
- const row = {
12227
- key: batchIdentity(target),
12228
- label: target.branch,
12229
- status: "waiting"
12230
- };
12231
- const batch = newBatch([row]);
12232
- /**
12233
- * The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
12234
- * branch has moved since, unless `fresh`, which looks at the folder again as it is now.
12235
- */
12236
- const run = async (release, again) => {
12237
- const fresh = again === "fresh";
12238
- let replaced = false;
12239
- try {
12240
- const next = fresh ? await deps.resolveBranch(path, base) : target;
12241
- if (again === "retry") {
12242
- if (!await stillThere(row, target, "branch", deps)) return;
12243
- if (row.reviewId) return await retryReview(row, batch, deps);
12244
- }
12245
- const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
12246
- target = next;
12247
- replaced = fresh;
12248
- await completeRow(row, prepared, batch, deps);
12249
- } catch (error) {
12250
- fail(row, error);
12251
- } finally {
12252
- if (fresh) keepFreshPending(row, replaced);
12253
- release();
12254
- }
12255
- };
12256
- batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
12257
- const answer = (outcome) => ({
12258
- status: 200,
12259
- body: {
12260
- outcome,
12261
- batchId: batch.id,
12262
- rows: rowsOf(batch),
12263
- ...row.reviewId ? { reviewId: row.reviewId } : {},
12264
- ...row.kept ? { kept: row.kept } : {}
12265
- }
12266
- });
12267
- const release = deps.slots.tryAcquire();
12268
- if (!release) {
12269
- const existing = await deps.findExisting(target);
12270
- if (existing && !deps.resumeOf(existing.id)) {
12271
- row.reviewId = existing.id;
12272
- if (!existing.inProgress) {
12273
- row.status = "already-reviewed";
12274
- return answer("existing");
12275
- }
12276
- row.status = "reviewing";
12277
- follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
12278
- return answer("existing");
12279
- }
12280
- follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
12281
- return answer("waiting");
12282
- }
12283
- let prepared;
12284
- try {
12285
- prepared = await prepareRow(row, target, agent.agentId, model, deps);
12286
- } catch (error) {
12287
- release();
12288
- batches.delete(batch.id);
12289
- return {
12290
- status: 409,
12291
- body: refusalOf(error)
12292
- };
12293
- }
12294
- if (prepared.kind === "existing") {
12295
- await completeRow(row, prepared, batch, deps);
12296
- release();
12297
- } else follow(batch, (async () => {
12298
- try {
12299
- await settleRow(row, deps.settle(row.reviewId));
12300
- } catch (error) {
12301
- fail(row, error);
12302
- } finally {
12303
- release();
12582
+ function keepReassessTurn(session, target, turn, askedAt) {
12583
+ if (!turn.answer) return "unreadable";
12584
+ if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
12585
+ return reassessIn(session, {
12586
+ op: "propose",
12587
+ kind: target.kind,
12588
+ findingId: target.id,
12589
+ from: target.from,
12590
+ to: turn.answer.to,
12591
+ reason: turn.answer.reason,
12592
+ ...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
12593
+ ...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
12594
+ source: {
12595
+ kind: "buddy",
12596
+ askedAt
12304
12597
  }
12305
- })(), deps);
12306
- return answer(prepared.kind);
12598
+ }, askedAt).ok ? "proposed" : "stale";
12307
12599
  }
12308
- async function chooseFolder(deps) {
12309
- if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
12310
- try {
12311
- const path = await deps.chooseFolder();
12312
- return {
12313
- status: 200,
12314
- body: path ? { path } : { cancelled: true }
12315
- };
12316
- } catch (error) {
12317
- return refuse(500, message(error));
12318
- }
12600
+ /** What the conversation shows for the turn, in words, never the JSON. */
12601
+ function reassessTurnText(target, turn, outcome) {
12602
+ const from = target.from ?? "ungraded";
12603
+ const answer = turn.answer;
12604
+ if (!answer) return `${turn.raw.trim()}\n\n*This answer was not in the form a suggestion needs, so nothing was suggested and the grade is unchanged.*`;
12605
+ if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
12606
+ const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
12607
+ return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
12319
12608
  }
12320
12609
  //#endregion
12321
12610
  //#region ../../packages/review-harness/src/workspace/ask.ts
@@ -13575,6 +13864,7 @@ function handleIndex(req, res, params, indexToken) {
13575
13864
  const cookie = indexCookieHeader(indexToken);
13576
13865
  if (onward) return redirect(res, onward, cookie);
13577
13866
  const states = analysisStates();
13867
+ const operations = liveOperations();
13578
13868
  const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
13579
13869
  const everyGroup = groupByLineage(reviews);
13580
13870
  const queue = settleQueue(everyGroup);
@@ -13586,10 +13876,11 @@ function handleIndex(req, res, params, indexToken) {
13586
13876
  groups,
13587
13877
  queue: queue.filter((id) => rendered.has(id)),
13588
13878
  analyses: states,
13879
+ operations,
13589
13880
  moved: movedReviews(),
13590
13881
  prStates: prStatesByRequest(),
13591
13882
  activity,
13592
- revision: reviewsRevision(states),
13883
+ revision: reviewsRevision(states, operations),
13593
13884
  unreadNotifications: unreadCount$1(),
13594
13885
  driving: isDriving(),
13595
13886
  theme: readPreferences().theme,
@@ -14082,9 +14373,10 @@ function establishBaseline(reviewIds) {
14082
14373
  function handleRevision(req, res, indexToken) {
14083
14374
  if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
14084
14375
  const states = analysisStates();
14376
+ const operations = liveOperations();
14085
14377
  send(res, 200, {
14086
- revision: reviewsRevision(states),
14087
- live: anythingRunning(states),
14378
+ revision: reviewsRevision(states, operations),
14379
+ live: anythingRunning(states, operations),
14088
14380
  driving: isDriving(),
14089
14381
  unread: unreadCount$1()
14090
14382
  });
@@ -14189,7 +14481,7 @@ async function handleSections(req, res, indexToken) {
14189
14481
  if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
14190
14482
  if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
14191
14483
  const body = await readJson(req);
14192
- if (!isIndexSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
14484
+ if (!isListSection(body?.section) || typeof body?.collapsed !== "boolean") return send(res, 400, { error: "Not a section this page can fold." });
14193
14485
  writeSectionCollapsed(body.section, body.collapsed);
14194
14486
  send(res, 200, { ok: true });
14195
14487
  }