prreviewbuddy 0.78.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.
@@ -435,7 +450,8 @@ var PR_FILTER_OPTIONS = [
435
450
  * once a second about a review that finished failing an hour ago, for as long as the tab stayed
436
451
  * open.
437
452
  */
438
- function anythingRunning(states) {
453
+ function anythingRunning(states, operations = []) {
454
+ if (operations.length > 0) return true;
439
455
  for (const state of states.values()) if (state.phase !== "failed") return true;
440
456
  return false;
441
457
  }
@@ -9396,6 +9412,98 @@ async function checkPrStateNow(workspace, deps = defaultDeps()) {
9396
9412
  } catch {}
9397
9413
  }
9398
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
9399
9507
  //#region ../../packages/review-harness/src/workspace/reviews_page.ts
9400
9508
  function renderReviewsPage(view) {
9401
9509
  const now = view.now ?? Date.now();
@@ -9412,7 +9520,9 @@ function renderReviewsPage(view) {
9412
9520
  };
9413
9521
  for (const group of ordered) sections[indexSectionOf(group.latest.pinned === true, group.latest.lifecycle, activityOf(group), "all")].push(group);
9414
9522
  const rank = new Map(ordered.map((group, index) => [group, index]));
9415
- 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("");
9416
9526
  const storedReviews = view.groups.reduce((total, group) => total + group.history.length, 0);
9417
9527
  const queue = view.queue ?? [];
9418
9528
  const positions = new Map(queue.map((id, position) => [id, position]));
@@ -9426,7 +9536,7 @@ function renderReviewsPage(view) {
9426
9536
  <link rel="stylesheet" href="/assets/sidebar.css">
9427
9537
  <link rel="stylesheet" href="/assets/shell.css">
9428
9538
  </head>
9429
- <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)}">
9430
9540
  ${renderGlobalBar({
9431
9541
  current: "reviews",
9432
9542
  unread: view.unreadNotifications ?? 0,
@@ -9447,7 +9557,8 @@ ${renderWorkspaceRail({
9447
9557
  <p class="prb-index-count" data-count>${escapeHtml$1(countLabel(storedReviews, ordered.length))}</p>
9448
9558
  <button type="button" class="prb-index-select" data-select-mode aria-pressed="false">Select</button>
9449
9559
  </div>`}
9450
- ${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 ")}
9451
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>`}
9452
9563
  ${ordered.length === 0 ? "" : renderBulkBar()}
9453
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>
@@ -9551,10 +9662,10 @@ function renderLoadMore$1(section) {
9551
9662
  * empty and hidden rather than left out, so a card the browser moves here after a mark or a pin has
9552
9663
  * somewhere to go, and an empty section keeps its fold for when something arrives.
9553
9664
  */
9554
- function renderSection(section, groups, renderCards, open) {
9665
+ function renderSection(section, groups, renderCards, open, shown = groups.length) {
9555
9666
  const title = INDEX_SECTION_NAMES[section].title;
9556
- return `<details class="prb-index-section" data-section="${section}"${open ? " open" : ""}${groups.length === 0 ? " hidden" : ""}>
9557
- <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>
9558
9669
  <div class="prb-cards" data-cards="${section}">
9559
9670
  ${renderCards(groups)}
9560
9671
  </div>
@@ -9562,6 +9673,87 @@ function renderSection(section, groups, renderCards, open) {
9562
9673
  </details>`;
9563
9674
  }
9564
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
+ /**
9565
9757
  * The bulk bar, with its counts left out.
9566
9758
  *
9567
9759
  * Same division as the pager: every word is written here, where the house style tests read it, and
@@ -9618,13 +9810,13 @@ function renderBulkBar() {
9618
9810
  * card and is shown only in selection mode, so entering that mode is a stylesheet change rather
9619
9811
  * than a rebuild of the list.
9620
9812
  */
9621
- function renderCard(group, now, activity, rank, analysis, queued, moved, prState) {
9813
+ function renderCard(group, now, activity, rank, analysis, queued, moved, prState, inProgressOnly = false) {
9622
9814
  const review = group.latest;
9623
9815
  const status = statusOf(review);
9624
9816
  const chip = cardLifecycleChip(review.pinned === true, review.lifecycle, activity);
9625
9817
  const href = `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}`;
9626
9818
  const followed = review.targetType === "commit" ? `commit ${(review.commitSha ?? review.branch).slice(0, 7)}` : followedRefName(review.branch, review.headResolvedRef) ?? review.branch;
9627
- 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" : ""}>
9628
9820
  <input type="checkbox" class="prb-card-select" data-select="${escapeHtml$1(review.id)}" aria-label="Select ${escapeHtml$1(followed)}">
9629
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>
9630
9822
  ${renderCardMenu(group, followed, queued !== void 0, status, activity)}
@@ -10039,7 +10231,7 @@ function writeChangeMapRegister(register) {
10039
10231
  function readReviewSections(value) {
10040
10232
  if (!value || typeof value !== "object") return {};
10041
10233
  const sections = {};
10042
- for (const section of INDEX_SECTIONS) {
10234
+ for (const section of LIST_SECTIONS) {
10043
10235
  const collapsed = value[section];
10044
10236
  if (typeof collapsed === "boolean") sections[section] = collapsed;
10045
10237
  }
@@ -11023,545 +11215,105 @@ function renderNotificationRow(notification, gone) {
11023
11215
  </li>`;
11024
11216
  }
11025
11217
  //#endregion
11026
- //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
11027
- /**
11028
- * Whether anything the reviews index draws has changed, as one token.
11029
- *
11030
- * The index reacts to a change by reloading, which is what makes an aggregate the right shape: it
11031
- * never needs to know *which* review changed, so it asks one question whose cost is the same for
11032
- * five reviews and five hundred. Per-card revisions would be more information than the page has any
11033
- * use for, and a request per card to carry it.
11034
- *
11035
- * Opaque and **equality only**, the same contract as `workspaceRevision` in `store.ts`: compare two
11036
- * tokens for equality, never order them, never parse one. The implementation below is a hash of
11037
- * facts the page happens to draw today, and the whole point of saying so here is that it can be
11038
- * replaced by a counter, a watch, or anything else without a page having to be told.
11039
- *
11040
- * ## What is in it, and what is deliberately not
11041
- *
11042
- * In: every review's id and file revision, and the phase of every analysis running right now.
11043
- * Between them those cover everything a card shows — the summary, the risk badge, the counts, the
11044
- * pin, the reviewed mark, the running line — because every one of those reaches disk through
11045
- * `writeWorkspace`, and the set of ids covers a review appearing or being deleted.
11046
- *
11047
- * Not in: **`job.updatedAt` and `job.progress`.** This is the load-bearing omission. An agent
11048
- * streams its progress into the job record every few seconds, so a token derived from job file
11049
- * mtimes would differ on nearly every poll, and a page that reloads whenever the token differs
11050
- * would then reload every few seconds for the whole length of an analysis — a page thrashing
11051
- * itself, with the reviewer's scroll position going with it. Phase only, which changes four times
11052
- * in a run.
11053
- */
11218
+ //#region ../../packages/review-harness/src/workspace/working_folders.ts
11054
11219
  /**
11055
- * What is being done to each review, or was last done and stopped.
11056
- *
11057
- * Keyed by review, valued by the phase the card should name. Two states reach it and a third
11058
- * deliberately does not:
11059
- *
11060
- * - **Running.** A job that is not terminal *and* whose driver holds a live claim. The claim is the
11061
- * load-bearing half: a job whose driver was killed sits in a working phase for up to half an hour
11062
- * before the sweep calls it stopped, and narrating one of those as "Analysing the change" would
11063
- * be the index confidently reporting work that nothing is doing.
11064
- * - **`failed`.** Reported whether or not anything still holds the claim, which is the whole reason
11065
- * this function is not simply `runningJobs`. It was: `runningJobs` drops terminal jobs, and
11066
- * `isTerminal` counts `failed` as terminal, so a failed analysis could never reach the renderer
11067
- * and the card's "Analysis stopped" branch was unreachable in production while passing a unit
11068
- * test that handed it a state the product could not produce. A review whose analysis failed went
11069
- * back to reading "No standard review yet" the moment its driver let go.
11070
- * - **`done`, and an unclaimed job still in a working phase.** Nothing. A finished job has its
11071
- * result on the card already, and an abandoned one is the sweep's to judge rather than this
11072
- * renderer's to pre-empt.
11073
- *
11074
- * **Every job a review has, not the latest one.** A review accumulates them -- a standard analysis,
11075
- * a KISS run, a retry -- and two of them genuinely run at once: `handleKiss` refuses only a second
11076
- * KISS run, and the review page's poll is built around one finishing while the other goes on. An
11077
- * earlier cut picked the single newest job and then asked what it was, so a KISS run that ended
11078
- * first spoke for a standard analysis that was still working, and the card fell silent on work that
11079
- * was happening in front of the reviewer.
11220
+ * The local Git working folders PR Review Buddy can find right now.
11080
11221
  *
11081
- * So the precedence is running, then the most recent ending, and it is stated in that order because
11082
- * 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.
11083
11226
  *
11084
- * `jobs` is injected only so a test can hand the same set in two different orders and prove the
11085
- * 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.
11086
11230
  *
11087
- * A failure is visible for as long as the job record survives, which is the sweep's retention. Once
11088
- * that passes the card quietly returns to saying nothing, which is the same silence every review
11089
- * had before this existed.
11231
+ * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
11090
11232
  */
11091
- function analysisStates(now = Date.now(), jobs = allJobs()) {
11092
- const byReview = /* @__PURE__ */ new Map();
11093
- for (const job of jobs) {
11094
- if (purposeOf(job) === "ask") continue;
11095
- const held = byReview.get(job.workspaceId);
11096
- if (held) held.push(job);
11097
- else byReview.set(job.workspaceId, [job]);
11233
+ function canonical(path) {
11234
+ try {
11235
+ return realpathSync(path);
11236
+ } catch {
11237
+ return path;
11098
11238
  }
11099
- /**
11100
- * Both comparators are **total**, and that is a requirement rather than tidiness.
11101
- *
11102
- * `allJobs` reads a directory, so a tie leaves the winner to whatever order the filesystem
11103
- * happened to return. This function's output is hashed into the index token, and the page reloads
11104
- * whenever that token changes -- so an arbitrary winner is not a cosmetic wobble, it is the page
11105
- * reloading itself for as long as the tie exists, with no cause a reviewer could ever see. The
11106
- * same hazard the sorting rule was written for, one level further down.
11107
- *
11108
- * Ties are real. Two jobs can carry the same timestamp to the millisecond, and a record written
11109
- * before `finishedAt` existed falls back to `updatedAt`, which two records can share. The id is
11110
- * the last resort because it is the only field guaranteed to differ.
11111
- */
11112
- const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
11113
- const startedLast = (a, b) => b.startedAt - a.startedAt || byId(a, b);
11114
- const endedLast = (a, b) => endedAt(b) - endedAt(a) || b.startedAt - a.startedAt || byId(a, b);
11115
- const states = /* @__PURE__ */ new Map();
11116
- for (const [reviewId, jobs] of byReview) {
11117
- const running = jobs.filter((job) => !isTerminal(job) && isClaimed(job.id, now)).sort(startedLast);
11118
- if (running.length > 0) {
11119
- states.set(reviewId, {
11120
- phase: running[0].phase,
11121
- 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
11122
11283
  });
11123
- continue;
11124
- }
11125
- const ended = jobs.filter((job) => isTerminal(job) && purposeOf(job) === "review").sort(endedLast);
11126
- if (ended[0]?.phase === "failed") states.set(reviewId, {
11127
- phase: "failed",
11128
- purpose: "review",
11129
- ...ended[0].failure?.stopped ? { stopped: true } : {}
11130
11284
  });
11131
11285
  }
11132
- 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());
11133
11297
  }
11134
11298
  /**
11135
- * The token.
11299
+ * The folders a pull request could be reviewed in, the suggested one first.
11136
11300
  *
11137
- * Takes the live analyses rather than reading them, so that one request answers with a token and a
11138
- * running state that describe the same instant. Computing them separately would let a phase change
11139
- * land between the two, and the page would then be told "nothing has changed" alongside a state it
11140
- * 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.
11141
11306
  */
11142
- function reviewsRevision(live = analysisStates()) {
11143
- const hash = createHash("sha1");
11144
- for (const [id, revision] of workspaceStamps()) hash.update(`${id}:${revision}\n`);
11145
- for (const id of [...live.keys()].sort()) {
11146
- const state = live.get(id);
11147
- hash.update(`${id}@${state.purpose}:${state.phase}${state.stopped ? ":stopped" : ""}\n`);
11148
- }
11149
- hash.update(`queue:${queueStamp()}\n`);
11150
- for (const [id, kind] of [...movedReviews()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`moved:${id}:${kind}\n`);
11151
- for (const [key, state] of [...prStatesByRequest()].sort(([a], [b]) => a.localeCompare(b))) hash.update(`pr:${key}:${state}\n`);
11152
- 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`);
11153
- 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`);
11154
- 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));
11155
11312
  }
11156
- function asKind(value) {
11157
- return value === "issue" || value === "question" ? value : null;
11158
- }
11159
- /** What the page sent, or null when it is not a request a host understands. */
11160
- function parseReassessmentRequest(value) {
11161
- const body = value;
11162
- if (body?.op === "decide") {
11163
- if (typeof body.proposalId !== "string" || body.proposalId === "") return null;
11164
- if (body.decision !== "accepted" && body.decision !== "declined") return null;
11165
- return {
11166
- op: "decide",
11167
- proposalId: body.proposalId,
11168
- decision: body.decision
11169
- };
11170
- }
11171
- if (body?.op === "set") {
11172
- const kind = asKind(body.kind);
11173
- if (!kind || typeof body.id !== "string" || body.id === "") return null;
11174
- if (!isGradeFor(kind, body.to)) return null;
11175
- if (body.from !== null && !isGradeFor(kind, body.from)) return null;
11176
- if (body.reason !== void 0 && typeof body.reason !== "string") return null;
11177
- const reason = typeof body.reason === "string" ? body.reason.trim().slice(0, 500) : "";
11178
- return {
11179
- op: "set",
11180
- kind,
11181
- findingId: body.id,
11182
- from: body.from,
11183
- to: body.to,
11184
- ...reason ? { reason } : {}
11185
- };
11186
- }
11187
- return null;
11188
- }
11189
- var BAD_REASSESSMENT_REQUEST = "A reassessment either moves a finding to a grade on its scale, or accepts or declines a suggestion.";
11190
- var REASSESSMENT_REFUSED = {
11191
- "not-a-grade": "That is not a grade this finding can have.",
11192
- unchanged: "The finding already has that grade.",
11193
- stale: "The grade has changed since this page was drawn. The card now shows it as it stands.",
11194
- outdated: "That suggestion was made against an earlier version of this review. Reassess again to judge it as it stands.",
11195
- "no-reason": "A suggestion needs a reason.",
11196
- unknown: "This review has no suggestion with that id.",
11197
- decided: "That suggestion has already been answered.",
11198
- "no-finding": "This review has no such finding."
11199
- };
11200
- function gradeFragments$1(session, kind, id) {
11201
- const finding = findingIn(session, kind, id);
11202
- if (!finding) return null;
11203
- const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
11204
- const options = {
11205
- editable: true,
11206
- askMore: true
11207
- };
11208
- return {
11209
- kind,
11210
- id,
11211
- current: assessment.current,
11212
- cardClass: cardGradeClass(kind, assessment.current),
11213
- grade: renderGradeControl(kind, assessment, options),
11214
- region: renderAssessmentRegion(kind, assessment, options),
11215
- copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
11216
- };
11217
- }
11218
- /** The finding a request is about: named outright, or through the proposal it decides. */
11219
- function findingOfRequest(session, request) {
11220
- if (request.op === "set") return {
11221
- kind: request.kind,
11222
- id: request.findingId
11223
- };
11224
- const proposal = session.findingReassessments?.find((entry) => entry.id === request.proposalId);
11225
- return proposal ? {
11226
- kind: proposal.kind,
11227
- id: proposal.findingId
11228
- } : null;
11229
- }
11230
- /** A refusal, with the card as the session has it so the page is brought up to date by the answer. */
11231
- function refusedReply(session, request, refusal) {
11232
- const finding = findingOfRequest(session, request);
11233
- const card = finding ? gradeFragments$1(session, finding.kind, finding.id) : null;
11234
- return {
11235
- status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
11236
- body: {
11237
- error: REASSESSMENT_REFUSED[refusal],
11238
- refusal,
11239
- ...card ? { card } : {}
11240
- }
11241
- };
11242
- }
11243
- //#endregion
11244
- //#region ../../packages/review-harness/src/workspace/reassessment_state.ts
11245
- /**
11246
- * The reviewer's grade moves and their answers to proposals, as `/state` receives them.
11247
- *
11248
- * The request, its refusals and the card parts a reply carries are shared with the extension
11249
- * (domain/ui/components/reassessment_reply.ts). What is the workspace's own is the store: checked
11250
- * against a fresh read before writing, so a refusal leaves the file alone, and checked again inside
11251
- * the write, where the answer is final.
11252
- */
11253
- /** A stored review's card parts for one finding. */
11254
- function gradeFragments(workspace, kind, id) {
11255
- return gradeFragments$1(workspace.session, kind, id);
11256
- }
11257
- /**
11258
- * One write, onto the review as it is now, answered with the card as it now stands. `now` is
11259
- * injectable so a test can name the time an entry records.
11260
- */
11261
- function applyReassessmentRequest(workspaceId, value, now = Date.now) {
11262
- const request = parseReassessmentRequest(value);
11263
- if (!request) return {
11264
- status: 400,
11265
- body: { error: BAD_REASSESSMENT_REQUEST }
11266
- };
11267
- const before = loadWorkspace(workspaceId);
11268
- if (!before) return {
11269
- status: 404,
11270
- body: { error: "No such review" }
11271
- };
11272
- const early = reassessIn(structuredClone(before.session), request, now());
11273
- if (!early.ok) return refusedReply(before.session, request, early.refusal);
11274
- let outcome = null;
11275
- const saved = mutateWorkspace(workspaceId, (current) => {
11276
- outcome = reassessIn(current.session, request, now());
11277
- });
11278
- if (!saved || !outcome) return {
11279
- status: 404,
11280
- body: { error: "No such review" }
11281
- };
11282
- const written = outcome;
11283
- if (!written.ok) return refusedReply(saved.session, request, written.refusal);
11284
- const card = gradeFragments(saved, written.entry.kind, written.entry.findingId);
11285
- return {
11286
- status: 200,
11287
- body: {
11288
- ok: true,
11289
- entry: written.entry,
11290
- ...card ? { card } : {}
11291
- }
11292
- };
11293
- }
11294
- //#endregion
11295
- //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
11296
- /** More than any real answer cites; a list longer than this is padding, not evidence. */
11297
- var MAX_EVIDENCE = 10;
11298
- function scaleText(kind) {
11299
- return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
11300
- }
11301
- /** What the grade means, in the words the analysis prompt uses for it. */
11302
- function gradeMeaning(kind) {
11303
- 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.";
11304
- }
11305
- function findingText(kind, finding) {
11306
- if (kind === "issue") {
11307
- const issue = finding;
11308
- const where = issue.file ? `\nLocation: ${issue.file}${issue.line ? `:${issue.line}` : ""}` : "";
11309
- return `Issue: ${issue.title.trim()}\n${issue.description.trim()}${where}`;
11310
- }
11311
- const question = finding;
11312
- const why = question.whyThisMatters?.trim() ? `\nWhy it matters: ${question.whyThisMatters.trim()}` : "";
11313
- return `Question: ${question.question.trim()}${why}${question.file ? `\nFile: ${question.file}` : ""}`;
11314
- }
11315
- function historyText(assessment) {
11316
- if (!assessment.history.length) return "It has not been reassessed before.";
11317
- 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");
11318
- }
11319
- function threadText(thread) {
11320
- if (!thread) return [];
11321
- const comments = thread.comments.filter((comment) => !comment.authorIsBot && comment.body.trim());
11322
- if (!comments.length) return [];
11323
- return [`THE PULL REQUEST THREAD THIS FINDING WAS TIED TO (${thread.resolved ? "resolved" : "unresolved"}):`, ...comments.map((comment) => `@${comment.author}: ${comment.body.trim()}`)];
11324
- }
11325
- /** The block a Reassess turn adds to the ordinary Buddy prompt. */
11326
- function buildReassessInstructions(input) {
11327
- const { kind, finding, assessment } = input;
11328
- const noun = kind === "issue" ? "severity" : "importance";
11329
- const current = assessment.current;
11330
- return [
11331
- "---",
11332
- `THE REVIEWER ASKED YOU TO REASSESS THE ${noun.toUpperCase()} OF THIS ${kind.toUpperCase()}:`,
11333
- findingText(kind, finding),
11334
- `Its ${noun} now: ${promptGrade(assessment)}.`,
11335
- historyText(assessment),
11336
- ...input.explanation?.trim() ? ["An explanation of it you gave earlier:", input.explanation.trim()] : [],
11337
- ...threadText(input.thread),
11338
- "---",
11339
- `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.`,
11340
- `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.`,
11341
- "Reply with a single JSON object and nothing else: no prose before or after it, no Markdown fence. Exactly this shape:",
11342
- `{ "verdict": "keep" | "change", "to"?: ${scaleText(kind)}, "reason": string, "evidence"?: [{ "file": string, "line"?: number }] }`,
11343
- `"to" is the new ${noun}, given only with "change", and must differ from ${current ? `"${current}"` : "the current one"}.`,
11344
- "\"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.",
11345
- "\"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."
11346
- ];
11347
- }
11348
- var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
11349
- function readEvidence(value) {
11350
- if (!Array.isArray(value)) return [];
11351
- const out = [];
11352
- for (const item of value) {
11353
- const entry = item;
11354
- const file = typeof entry?.file === "string" ? entry.file.trim().replace(/^\.\//, "") : "";
11355
- if (!file) continue;
11356
- const line = typeof entry?.line === "number" && Number.isInteger(entry.line) && entry.line > 0 ? entry.line : void 0;
11357
- out.push(line === void 0 ? { file } : {
11358
- file,
11359
- line
11360
- });
11361
- if (out.length === MAX_EVIDENCE) break;
11362
- }
11363
- return out;
11364
- }
11365
- /**
11366
- * The answer, or null when it cannot be read as one. Strict where it matters: a `change` must name a
11367
- * grade on this finding's scale that differs from the current one, and every answer must give a
11368
- * reason. A thin or confused answer is not repaired into a suggestion; the turn says it could not
11369
- * use it, and nothing is proposed.
11370
- */
11371
- function parseReassessAnswer(raw, kind, current) {
11372
- let parsed;
11373
- try {
11374
- parsed = JSON.parse(extractJsonText(raw, REASSESS_ANSWER));
11375
- } catch {
11376
- return null;
11377
- }
11378
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
11379
- const obj = parsed;
11380
- const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
11381
- if (!reason) return null;
11382
- const evidence = readEvidence(obj.evidence);
11383
- if (obj.verdict === "keep") return {
11384
- verdict: "keep",
11385
- reason,
11386
- evidence
11387
- };
11388
- if (obj.verdict !== "change") return null;
11389
- if (!isGradeFor(kind, obj.to) || obj.to === current) return null;
11390
- return {
11391
- verdict: "change",
11392
- to: obj.to,
11393
- reason,
11394
- evidence
11395
- };
11396
- }
11397
- //#endregion
11398
- //#region ../../packages/review-harness/src/workspace/reassess_turn.ts
11399
- /**
11400
- * The answer, with every cited place checked against the commit the review describes. A path that
11401
- * is not there is dropped and the suggestion kept: the judgement may stand even when the model
11402
- * misremembered where it read it, and a citation that leads nowhere is worse than none. If the check
11403
- * itself fails, no evidence is kept rather than unchecked evidence.
11404
- *
11405
- * Kept, but not disguised: how many were dropped is recorded, so the card says the suggestion has
11406
- * no verified code evidence rather than drawing it like one whose citations held. Acceptance does
11407
- * not depend on it; a reason may be sound without a citation.
11408
- */
11409
- async function readReassessTurn(raw, target, where, check = existingPaths) {
11410
- const answer = parseReassessAnswer(raw, target.kind, target.from);
11411
- if (!answer || !answer.evidence.length) return {
11412
- answer,
11413
- raw,
11414
- droppedEvidence: 0
11415
- };
11416
- let real;
11417
- try {
11418
- real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
11419
- } catch {
11420
- real = /* @__PURE__ */ new Set();
11421
- }
11422
- const evidence = answer.evidence.filter((place) => real.has(place.file));
11423
- return {
11424
- answer: {
11425
- ...answer,
11426
- evidence
11427
- },
11428
- raw,
11429
- droppedEvidence: answer.evidence.length - evidence.length
11430
- };
11431
- }
11432
- /**
11433
- * Writes the suggestion onto the session, when there is one to write. Run inside the store write on
11434
- * the fresh copy: the grade is checked against what is on disk now, so a move the reviewer made
11435
- * while Buddy AI was answering makes the suggestion out of date rather than letting it land on a
11436
- * grade it never saw.
11437
- */
11438
- function keepReassessTurn(session, target, turn, askedAt) {
11439
- if (!turn.answer) return "unreadable";
11440
- if (turn.answer.verdict === "keep" || !turn.answer.to) return "kept";
11441
- return reassessIn(session, {
11442
- op: "propose",
11443
- kind: target.kind,
11444
- findingId: target.id,
11445
- from: target.from,
11446
- to: turn.answer.to,
11447
- reason: turn.answer.reason,
11448
- ...turn.answer.evidence.length ? { evidence: turn.answer.evidence } : {},
11449
- ...turn.droppedEvidence ? { droppedEvidence: turn.droppedEvidence } : {},
11450
- source: {
11451
- kind: "buddy",
11452
- askedAt
11453
- }
11454
- }, askedAt).ok ? "proposed" : "stale";
11455
- }
11456
- /** What the conversation shows for the turn, in words, never the JSON. */
11457
- function reassessTurnText(target, turn, outcome) {
11458
- const from = target.from ?? "ungraded";
11459
- const answer = turn.answer;
11460
- 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.*`;
11461
- if (answer.verdict === "keep") return `**Keep ${from}.** ${answer.reason}`;
11462
- const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
11463
- 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.*`;
11464
- }
11465
- //#endregion
11466
- //#region ../../packages/review-harness/src/workspace/working_folders.ts
11467
- /**
11468
- * The local Git working folders PR Review Buddy can find right now.
11469
- *
11470
- * One question and nothing more: which folders on this machine could a review run in. The answer is
11471
- * derived each time from what is already known, the folders past reviews ran in and the other
11472
- * worktrees of those repositories, and nothing is stored. A worktree an agent made yesterday shows
11473
- * up because `git worktree list` names it, not because anything remembered it.
11474
- *
11475
- * A folder is a folder. A linked worktree is its own entry with its own path and is never folded
11476
- * into the clone it belongs to: a review is filed under the folder it ran in, and the worktree is
11477
- * where the branch is checked out.
11478
- *
11479
- * Not a registry. If one is wanted later it replaces `discoverWorkingFolders` and keeps the shape.
11480
- */
11481
- function canonical(path) {
11482
- try {
11483
- return realpathSync(path);
11484
- } catch {
11485
- return path;
11486
- }
11487
- }
11488
- /** `git worktree list --porcelain`, main worktree first, bare and prunable entries left out. */
11489
- async function worktreesOf(repoPath) {
11490
- let output;
11491
- try {
11492
- output = await git(repoPath, [
11493
- "worktree",
11494
- "list",
11495
- "--porcelain"
11496
- ]);
11497
- } catch {
11498
- return [];
11499
- }
11500
- const entries = [];
11501
- for (const block of output.split(/\n\s*\n/)) {
11502
- const lines = block.split("\n");
11503
- const path = lines.find((line) => line.startsWith("worktree "))?.slice(9);
11504
- if (!path || lines.includes("bare") || lines.some((line) => line.startsWith("prunable"))) continue;
11505
- const ref = lines.find((line) => line.startsWith("branch "))?.slice(7) ?? null;
11506
- entries.push({
11507
- path,
11508
- branch: ref ? ref.replace(/^refs\/heads\//, "") : null
11509
- });
11510
- }
11511
- return entries;
11512
- }
11513
- async function discoverWorkingFolders(summaries = recentWorkspaces(Number.MAX_SAFE_INTEGER)) {
11514
- const lastReviewed = /* @__PURE__ */ new Map();
11515
- for (const summary of summaries) {
11516
- if (!summary.repoPath) continue;
11517
- const key = canonical(summary.repoPath);
11518
- lastReviewed.set(key, Math.max(lastReviewed.get(key) ?? 0, summary.createdAt));
11519
- }
11520
- const found = /* @__PURE__ */ new Map();
11521
- for (const seed of lastReviewed.keys()) {
11522
- if (found.has(seed) || !existsSync(seed)) continue;
11523
- (await worktreesOf(seed)).forEach((entry, index) => {
11524
- const key = canonical(entry.path);
11525
- if (!found.has(key) && existsSync(key)) found.set(key, {
11526
- entry: {
11527
- ...entry,
11528
- path: key
11529
- },
11530
- main: index === 0
11531
- });
11532
- });
11533
- }
11534
- return (await Promise.all([...found.values()].map(async ({ entry, main }) => ({
11535
- path: entry.path,
11536
- remote: await originRemote(entry.path),
11537
- branch: entry.branch,
11538
- main,
11539
- lastReviewedAt: lastReviewed.get(entry.path) ?? null
11540
- })))).sort((a, b) => (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0) || Number(b.main) - Number(a.main) || a.path.localeCompare(b.path));
11541
- }
11542
- /** Whether a remote is this github.com repository. GitHub names are case-insensitive. */
11543
- function isGitHubRepository(remote, owner, repo) {
11544
- return Boolean(remote && remote.host === "github.com" && remote.owner.toLowerCase() === owner.toLowerCase() && remote.repo.toLowerCase() === repo.toLowerCase());
11545
- }
11546
- /**
11547
- * The folders a pull request could be reviewed in, the suggested one first.
11548
- *
11549
- * Every match is returned, because which one is right is the reviewer's call: an agent's worktree
11550
- * and the main clone hold the same objects and differ in what is checked out. The order only
11551
- * decides the default. A folder that already has a review of this pull request comes first, so
11552
- * reviewing it again finds that review rather than starting a second one filed under another
11553
- * folder; then the main worktree; then whichever was reviewed in most recently.
11554
- */
11555
- function candidatesFor(folders, link, summaries) {
11556
- const reviewedHere = new Set(summaries.filter((summary) => summary.prNumber === link.number).map((summary) => canonical(summary.repoPath)));
11557
- const matching = folders.filter((folder) => isGitHubRepository(folder.remote, link.owner, link.repo));
11558
- const rank = (folder) => reviewedHere.has(folder.path) ? 0 : folder.main ? 1 : 2;
11559
- return [...matching].sort((a, b) => rank(a) - rank(b) || (b.lastReviewedAt ?? 0) - (a.lastReviewedAt ?? 0));
11560
- }
11561
- /** The review of this pull request already filed under one of these folders, if there is one. */
11562
- function reviewedIn(folders, link, summaries) {
11563
- const paths = new Set(folders.map((folder) => folder.path));
11564
- 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;
11565
11317
  }
11566
11318
  //#endregion
11567
11319
  //#region ../../packages/review-harness/src/workspace/choose_folder.ts
@@ -11714,625 +11466,1145 @@ function baseChoiceOf(error) {
11714
11466
  };
11715
11467
  }
11716
11468
  /**
11717
- * What the panel says for an error a review could not get past.
11718
- *
11719
- * Two errors are known by their class and said in the panel's own words. Anything else keeps its
11720
- * message, which is already written for a person, but only its first paragraph is the sentence:
11721
- * the rest (ref listings, commands to run) goes behind Technical details, kept whole for whoever
11722
- * 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.
11723
11741
  */
11724
- function refusalOf(error) {
11725
- const text = message(error);
11726
- if (error instanceof AmbiguousRefError) {
11727
- const choice = baseChoiceOf(error);
11728
- return {
11729
- error: `\`${choice.name}\` is both a local and a remote branch, and they are different commits. Choose which one to compare against.`,
11730
- detail: text,
11731
- baseChoice: choice
11732
- };
11733
- }
11734
- 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." };
11735
- return briefly(text);
11736
- }
11737
- /** A message's first paragraph as the sentence, and the whole of it as the detail when there is more. */
11738
- function briefly(text) {
11739
- const first = text.split(/\n\s*\n/)[0].trim();
11740
- return first && first !== text.trim() ? {
11741
- error: first,
11742
- detail: text
11743
- } : { error: text };
11744
- }
11745
- /** A path as a person reads it, with their home folder as `~`. */
11746
- function displayPath(path, home) {
11747
- 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);
11748
11745
  }
11749
- /** A path a person typed, made absolute: `~` is their home folder, and anything relative is refused. */
11750
- function expandPath(raw, home) {
11751
- const trimmed = raw.trim().replace(/(.)\/+$/, "$1");
11752
- if (!trimmed) return null;
11753
- const expanded = trimmed === "~" ? home : trimmed.startsWith("~/") ? `${home}${trimmed.slice(1)}` : trimmed;
11754
- 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);
11755
11753
  }
11756
- function message(error) {
11757
- 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;
11758
11766
  }
11759
- /** Why no agent could be used, in a sentence for the panel. The CLI's own wording names flags. */
11760
- function unresolvedAgent(resolution) {
11761
- switch (resolution.kind) {
11762
- case "none": return "No review agent is installed on this machine. Install one of the agents PR Review Buddy supports, then try again.";
11763
- 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.`;
11764
- 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));
11765
11782
  }
11783
+ follow(batch, settleRow(row, deps.observe(reviewId)), deps, false);
11766
11784
  }
11767
11785
  /**
11768
- * The agent a submission runs under: the one named under Review options, or else the same answer
11769
- * the CLI reaches with no flag (the configured default, or the only one installed). Never the
11770
- * 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.
11771
11789
  */
11772
- async function agentFor(flag, deps) {
11773
- if (flag !== void 0 && typeof flag !== "string") return refuse(400, "Say which agent by its id.", { field: "agent" });
11774
- const resolution = await deps.resolveAgent(flag || void 0);
11775
- if (resolution.kind === "resolved") return { agentId: resolution.agent.id };
11776
- if (resolution.kind === "ambiguous") return refuse(400, "More than one review agent is installed. Choose one under Review options.", { field: "agent" });
11777
- 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;
11778
11792
  }
11779
- function modelOf(value) {
11780
- if (value === void 0 || value === null || value === "") return void 0;
11781
- if (typeof value !== "string" || value.length > 200) return refuse(400, "The model must be a short name.", { field: "model" });
11782
- return value.trim() || void 0;
11793
+ function keptOf(agentId, model, deps) {
11794
+ return {
11795
+ agent: deps.agentName(agentId),
11796
+ ...model ? { model } : {}
11797
+ };
11783
11798
  }
11784
11799
  /**
11785
- * The tail of each target's queue of creations, by batch identity. In-process, like the fetch
11786
- * 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.
11787
11802
  *
11788
- * `prepareReview` looks for an existing review and then creates one, and nothing between the two
11789
- * stops a second start of the same target, from another tab or another paste, from looking in the
11790
- * same gap and creating a second review of it. One at a time per target closes that gap for every
11791
- * 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.
11792
11805
  */
11793
- var creations = /* @__PURE__ */ new Map();
11794
- function oneCreationAtATime(target, work) {
11795
- const key = batchIdentity(target);
11796
- const mine = (creations.get(key) ?? Promise.resolve()).then(work, work);
11797
- const tail = mine.catch(() => {});
11798
- creations.set(key, tail);
11799
- tail.then(() => {
11800
- if (creations.get(key) === tail) creations.delete(key);
11801
- });
11802
- 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
+ };
11803
11897
  }
11804
- var isAnswer = (value) => typeof value === "object" && value !== null && "status" in value && "body" in value;
11805
- async function addReviewOptions(deps) {
11806
- 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);
11807
11942
  return {
11808
11943
  status: 200,
11809
11944
  body: {
11810
- agents,
11811
- agent: resolution.kind === "resolved" ? {
11812
- kind: "resolved",
11813
- id: resolution.agent.id,
11814
- displayName: resolution.agent.displayName
11815
- } : resolution.kind === "ambiguous" ? { kind: "ambiguous" } : {
11816
- kind: "refused",
11817
- error: unresolvedAgent(resolution)
11818
- },
11819
- canChooseFolder: deps.chooseFolder !== null
11945
+ rows: rowsOf(batch),
11946
+ finished: false
11820
11947
  }
11821
11948
  };
11822
11949
  }
11823
- function reviewedRow(reviewId, deps) {
11824
- const kept = deps.resumeOf(reviewId);
11825
- return {
11826
- reviewId,
11827
- ...kept ? { kept } : {}
11828
- };
11829
- }
11830
- function folderOption(folder, home) {
11950
+ /** The recent and discoverable folders, for the Branch panel's list. */
11951
+ async function listFolders(deps) {
11831
11952
  return {
11832
- path: folder.path,
11833
- display: displayPath(folder.path, home),
11834
- 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
+ })) }
11835
11958
  };
11836
11959
  }
11837
11960
  /**
11838
- * What a paste would start, without starting anything: rows, the folders each could run in, and
11839
- * which already have a review. No forge is asked and nothing is fetched, so it is cheap enough to
11840
- * 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.
11841
11963
  *
11842
- * Every link gets a row, however many there are. Past the batch limit the panel refuses to start
11843
- * rather than this quietly dropping the tail: a reviewer who pasted twenty-one pull requests and
11844
- * 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.
11845
11967
  */
11846
- async function previewPullRequests(text, deps) {
11847
- if (typeof text !== "string") return refuse(400, "Paste the pull request links to review.");
11848
- const links = extractPullRequestLinks(text);
11849
- const [folders, summaries] = links.some((link) => link.kind === "github") ? [await deps.folders(), deps.summaries()] : [[], []];
11850
- return {
11968
+ async function inspectFolder(rawPath, deps, rawBase) {
11969
+ const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
11970
+ if (!path) return {
11851
11971
  status: 200,
11852
11972
  body: {
11853
- rows: links.map((link) => {
11854
- if (link.kind === "unsupported") return {
11855
- key: link.key,
11856
- label: `${link.provider} ${link.provider === "GitLab" ? "merge request" : "pull request"}`,
11857
- url: link.url,
11858
- ok: false,
11859
- reason: unsupportedReason(link.provider),
11860
- folders: []
11861
- };
11862
- const label = `${link.owner}/${link.repo} #${link.number}`;
11863
- const candidates = candidatesFor(folders, link, summaries);
11864
- if (candidates.length === 0) return {
11865
- key: link.key,
11866
- label,
11867
- url: link.url,
11868
- ok: false,
11869
- reason: "Local repository not found",
11870
- needsFolder: true,
11871
- folders: []
11872
- };
11873
- const reviewed = reviewedIn(candidates, link, summaries);
11874
- return {
11875
- key: link.key,
11876
- label,
11877
- url: link.url,
11878
- ok: true,
11879
- folders: candidates.map((folder) => folderOption(folder, deps.home)),
11880
- folder: candidates[0].path,
11881
- ...reviewed ? { reviewed: reviewedRow(reviewed.id, deps) } : {}
11882
- };
11883
- }),
11884
- limit: MAX_BATCH
11973
+ ok: false,
11974
+ error: "Enter the full path of a folder, or choose one."
11885
11975
  }
11886
11976
  };
11887
- }
11888
- var batches = /* @__PURE__ */ new Map();
11889
- function forgetFinished(now) {
11890
- for (const [id, batch] of batches) if (batch.finishedAt !== null && now - Math.max(batch.finishedAt, batch.seenAt) > FINISHED_BATCH_TTL_MS) batches.delete(id);
11891
- }
11892
- function newBatch(rows) {
11893
- forgetFinished(Date.now());
11894
- const now = Date.now();
11895
- const batch = {
11896
- id: randomUUID(),
11897
- rows,
11898
- finishedAt: now,
11899
- seenAt: now,
11900
- running: 0,
11901
- 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
+ }
11902
12011
  };
11903
- batches.set(batch.id, batch);
11904
- return batch;
11905
12012
  }
11906
12013
  /**
11907
- * Count work against a batch, inside the restart guard. The batch is finished only when nothing
11908
- * 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.
11909
12024
  */
11910
- function follow(batch, work, deps, driving = true) {
11911
- batch.running += 1;
11912
- batch.finishedAt = null;
11913
- (driving ? deps.track(work) : work).catch(() => {}).finally(() => {
11914
- batch.running -= 1;
11915
- 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
11916
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);
11917
12128
  }
11918
- function fail(row, error) {
11919
- const refusal = refusalOf(error);
11920
- row.status = "failed";
11921
- row.reason = refusal.error;
11922
- if (refusal.detail) row.detail = refusal.detail;
11923
- }
11924
- var rowsOf = (batch) => batch.rows.map((row) => ({ ...row }));
11925
- /**
11926
- * Create the review for a row's target, or find the one it has, under the per-target lock. The
11927
- * caller holds an analysis slot: nothing is created without one, so nothing created waits.
11928
- */
11929
- /**
11930
- * `fresh` is a fresh start's: a new review of the target as it is now, keeping whatever review the
11931
- * branch or request already has, as `--fresh` does. Without it the lookup finds the latest review of
11932
- * the same branch or request, which for a fresh start is the very failure being started over.
11933
- */
11934
- async function prepareRow(row, target, agentId, model, deps, fresh = false) {
11935
- const prepared = await oneCreationAtATime(target, () => deps.prepare(target, agentId, fresh, model));
11936
- row.reviewId = prepared.kind === "new" ? prepared.workspaceId : prepared.existing.id;
11937
- if (prepared.kind === "existing") row.status = prepared.existing.inProgress ? "reviewing" : "already-reviewed";
11938
- else row.status = "reviewing";
11939
- if (prepared.kind === "resume") row.kept = keptOf(prepared.failure.agentId, prepared.failure.requestedModel, deps);
11940
- else delete row.kept;
11941
- 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
+ }
11942
12140
  }
12141
+ //#endregion
12142
+ //#region ../../packages/review-harness/src/workspace/live_operations.ts
11943
12143
  /**
11944
- * Take a prepared row to its end. A new or resumed review is run here, holding the slot. One that
11945
- * something else is already analysing is watched instead, never driven: the watch is handed to the
11946
- * batch rather than awaited, so the caller returns, and its slot goes back, at once, while the row
11947
- * 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.
11948
12164
  */
11949
- async function completeRow(row, prepared, batch, deps) {
11950
- if (prepared.kind !== "existing") return settleRow(row, deps.settle(row.reviewId));
11951
- if (prepared.existing.inProgress) follow(batch, settleRow(row, deps.observe(row.reviewId)), deps, false);
11952
- }
11953
- async function settleRow(row, settling) {
11954
- const settled = await settling;
11955
- if (settled.ok) row.status = "ready";
11956
- else if (settled.stopped) {
11957
- row.status = "failed";
11958
- row.reason = "Stopped";
11959
- } 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);
11960
12167
  }
12168
+ //#endregion
12169
+ //#region ../../packages/review-harness/src/workspace/reviews_revision.ts
11961
12170
  /**
11962
- * A retry runs against the commit the row was first resolved to, never a newer one: a retry
11963
- * resumes what was asked for, and a moved branch or pull request is a fresh review's business. When
11964
- * that commit has gone (a force push, then a prune), there is nothing left to retry, and the row
11965
- * 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.
11966
12196
  */
11967
- async function stillThere(row, target, what, deps) {
11968
- if (await deps.commitPresent(target.originRepoPath, target.sha)) return true;
11969
- row.status = "failed";
11970
- 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.`;
11971
- row.stale = true;
11972
- return false;
11973
- }
11974
12197
  /**
11975
- * Retry a row whose review exists, by that review's id. Not through the lookup a start uses, which
11976
- * finds the latest review of the branch or request: one made since, of newer commits, would be
11977
- * 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.
11978
12199
  *
11979
- * Resumed when it is still a failure nothing is running, as its own page's Retry does. Otherwise
11980
- * 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.
11981
12233
  */
11982
- async function retryReview(row, batch, deps) {
11983
- const reviewId = row.reviewId;
11984
- const kept = deps.resumeOf(reviewId);
11985
- row.status = "reviewing";
11986
- if (kept) {
11987
- row.kept = kept;
11988
- 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]);
11989
12241
  }
11990
- 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;
11991
12276
  }
11992
12277
  /**
11993
- * A fresh start that ended before it made its review leaves the row exactly as it was: the old
11994
- * target, the old review, and still `stale`. So the row keeps offering the fresh start, and a plain
11995
- * 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.
11996
12284
  */
11997
- function keepFreshPending(row, replaced) {
11998
- 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");
11999
12299
  }
12000
- function keptOf(agentId, model, deps) {
12001
- return {
12002
- agent: deps.agentName(agentId),
12003
- ...model ? { model } : {}
12004
- };
12300
+ function asKind(value) {
12301
+ return value === "issue" || value === "question" ? value : null;
12005
12302
  }
12006
- /**
12007
- * Start a batch and answer at once. The analyses take minutes, and the panel is not a progress
12008
- * dialog: it may watch the batch while it stays open, and the reviews appear in the list either way.
12009
- *
12010
- * Each pull request waits for one of the server's analysis slots (`deps.slots`), shared with every
12011
- * other batch and every branch review, and is resolved and created only once it has one.
12012
- */
12013
- async function startPullRequestBatch(body, deps) {
12014
- const raw = Array.isArray(body?.items) ? body.items : null;
12015
- if (!raw || raw.length === 0) return refuse(400, "Say which pull requests to review.");
12016
- if (raw.length > MAX_BATCH) return refuse(400, `At most ${MAX_BATCH} pull requests can be started at once.`);
12017
- const items = [];
12018
- for (const entry of raw) {
12019
- const url = typeof entry?.url === "string" ? entry.url : "";
12020
- const link = parsePullRequestLink(url);
12021
- if (!link || link.kind !== "github") return refuse(400, `${url || "That"} is not a github.com pull request link.`);
12022
- const folder = typeof entry?.folder === "string" ? expandPath(entry.folder, deps.home) : null;
12023
- if (!folder) return refuse(400, `Choose a local folder for ${link.owner}/${link.repo} #${link.number}.`);
12024
- items.push({
12025
- url: link.url,
12026
- owner: link.owner,
12027
- repo: link.repo,
12028
- number: link.number,
12029
- key: link.key,
12030
- folder
12031
- });
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
+ };
12032
12330
  }
12033
- const model = modelOf(body?.model);
12034
- if (isAnswer(model)) return model;
12035
- const agent = await agentFor(body?.agent, deps);
12036
- if (isAnswer(agent)) return agent;
12037
- const batch = newBatch(items.map((item) => ({
12038
- key: item.key,
12039
- label: `${item.owner}/${item.repo} #${item.number}`,
12040
- status: "waiting"
12041
- })));
12042
- /** Which row claimed each pull request, by what it resolved to rather than how it was spelled. */
12043
- const claimed = /* @__PURE__ */ new Map();
12044
- /** Each row's pull request as it was first resolved: head, base and all. What a retry runs against. */
12045
- const resolved = [];
12046
- const lane = async (item, index, fresh = false) => {
12047
- const row = batch.rows[index];
12048
- const release = await deps.slots.acquire();
12049
- row.status = "reviewing";
12050
- /** A fresh start has made its review, so the row now belongs to the new target. */
12051
- let replaced = false;
12052
- try {
12053
- let target = fresh ? void 0 : resolved[index];
12054
- if (target) {
12055
- if (!await stillThere(row, target, "pull request", deps)) return;
12056
- if (row.reviewId) return await retryReview(row, batch, deps);
12057
- } else {
12058
- const remote = await deps.remoteOf(item.folder);
12059
- if (!isGitHubRepository(remote, item.owner, item.repo)) {
12060
- row.status = "failed";
12061
- 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}`;
12062
- return;
12063
- }
12064
- target = await deps.resolvePullRequest(item.folder, item.url);
12065
- if (!fresh) resolved[index] = target;
12066
- }
12067
- const identity = batchIdentity(target);
12068
- const first = claimed.get(identity);
12069
- if (first !== void 0 && first !== index) {
12070
- row.status = "already-reviewed";
12071
- row.reason = `Same pull request as ${batch.rows[first].label}`;
12072
- return;
12073
- }
12074
- claimed.set(identity, index);
12075
- const prepared = await prepareRow(row, target, agent.agentId, model, deps, fresh);
12076
- if (fresh) {
12077
- resolved[index] = target;
12078
- replaced = true;
12079
- }
12080
- await completeRow(row, prepared, batch, deps);
12081
- } catch (error) {
12082
- fail(row, error);
12083
- } finally {
12084
- if (fresh) keepFreshPending(row, replaced);
12085
- release();
12086
- }
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
12087
12351
  };
12088
- batch.rerun = (index, fresh) => follow(batch, lane(items[index], index, fresh), deps);
12089
- follow(batch, runBatch(items, {
12090
- concurrency: deps.concurrency,
12091
- run: lane
12092
- }), deps);
12093
12352
  return {
12094
- status: 200,
12095
- body: {
12096
- batchId: batch.id,
12097
- rows: rowsOf(batch)
12098
- }
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)
12099
12360
  };
12100
12361
  }
12101
- /**
12102
- * A batch's rows as they stand. Asking counts as watching: a batch an open panel is still asking
12103
- * about is never forgotten, so its Retry stays good for as long as the panel is open.
12104
- */
12105
- function batchRows(id) {
12106
- const batch = batches.get(id);
12107
- if (!batch) return lostBatch();
12108
- 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;
12109
12378
  return {
12110
- status: 200,
12379
+ status: refusal === "stale" || refusal === "outdated" || refusal === "decided" ? 409 : 400,
12111
12380
  body: {
12112
- rows: rowsOf(batch),
12113
- finished: batch.finishedAt !== null
12381
+ error: REASSESSMENT_REFUSED[refusal],
12382
+ refusal,
12383
+ ...card ? { card } : {}
12114
12384
  }
12115
12385
  };
12116
12386
  }
12387
+ //#endregion
12388
+ //#region ../../packages/review-harness/src/workspace/reassessment_state.ts
12117
12389
  /**
12118
- * Said without guessing why. A restart forgets every batch, and a batch nobody has asked about for
12119
- * a while is forgotten too; the server cannot tell which happened, so it says what follows from
12120
- * 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.
12121
12396
  */
12122
- 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
+ }
12123
12401
  /**
12124
- * Run one failed row of a batch again, as it was first asked for: the same folder, agent, model
12125
- * and base, and the same commits, never the branch or pull request as it has since moved on. A
12126
- * review that was created and then failed resumes, as a Retry does on its own page. `fresh` is the
12127
- * one exception, sent only when those commits have gone: it looks again at what is there now.
12128
- * 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.
12129
12404
  */
12130
- function retryBatchRow(id, key, fresh = false) {
12131
- const batch = batches.get(id);
12132
- if (!batch) return lostBatch();
12133
- const index = batch.rows.findIndex((row) => row.key === key);
12134
- const row = batch.rows[index];
12135
- if (!row) return refuse(404, "That review is not part of this batch.");
12136
- if (row.status !== "failed") return refuse(409, "Only a review that failed can be tried again.");
12137
- row.status = "waiting";
12138
- delete row.reason;
12139
- delete row.detail;
12140
- delete row.stale;
12141
- batch.seenAt = Date.now();
12142
- 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);
12143
12429
  return {
12144
12430
  status: 200,
12145
12431
  body: {
12146
- rows: rowsOf(batch),
12147
- finished: false
12432
+ ok: true,
12433
+ entry: written.entry,
12434
+ ...card ? { card } : {}
12148
12435
  }
12149
12436
  };
12150
12437
  }
12151
- /** The recent and discoverable folders, for the Branch panel's list. */
12152
- 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;
12153
12534
  return {
12154
- status: 200,
12155
- body: { folders: (await deps.folders()).map((folder) => ({
12156
- ...folderOption(folder, deps.home),
12157
- repo: folder.remote ? folder.remote.repo : basename(folder.path)
12158
- })) }
12535
+ verdict: "change",
12536
+ to: obj.to,
12537
+ reason,
12538
+ evidence
12159
12539
  };
12160
12540
  }
12541
+ //#endregion
12542
+ //#region ../../packages/review-harness/src/workspace/reassess_turn.ts
12161
12543
  /**
12162
- * What reviewing this folder would review, read without changing anything: the same target a bare
12163
- * `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.
12164
12548
  *
12165
- * With the base the reviewer named, when they named one, so what this checks is the comparison
12166
- * that would be submitted. Without it, a repository whose default base is ambiguous could never be
12167
- * 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.
12168
12552
  */
12169
- async function inspectFolder(rawPath, deps, rawBase) {
12170
- const path = typeof rawPath === "string" ? expandPath(rawPath, deps.home) : null;
12171
- if (!path) return {
12172
- status: 200,
12173
- body: {
12174
- ok: false,
12175
- error: "Enter the full path of a folder, or choose one."
12176
- }
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
12177
12559
  };
12178
- const named = typeof rawBase === "string" && rawBase.trim() ? rawBase.trim() : void 0;
12179
- let target;
12560
+ let real;
12180
12561
  try {
12181
- target = await deps.resolveBranch(path, named);
12182
- } catch (error) {
12183
- return {
12184
- status: 200,
12185
- body: {
12186
- ok: false,
12187
- ...refusalOf(error)
12188
- }
12189
- };
12562
+ real = await check(where.repoPath, [...new Set(answer.evidence.map((place) => place.file))], where.commitSha);
12563
+ } catch {
12564
+ real = /* @__PURE__ */ new Set();
12190
12565
  }
12191
- const [remote, base, existing] = await Promise.all([
12192
- deps.remoteOf(target.originRepoPath),
12193
- named ? Promise.resolve(named) : deps.defaultBase(target.originRepoPath),
12194
- deps.findExisting(target)
12195
- ]);
12196
- const kept = existing ? deps.resumeOf(existing.id) : null;
12566
+ const evidence = answer.evidence.filter((place) => real.has(place.file));
12197
12567
  return {
12198
- status: 200,
12199
- body: {
12200
- ok: true,
12201
- path: target.originRepoPath,
12202
- display: displayPath(target.originRepoPath, deps.home),
12203
- repo: remote ? remote.repo : basename(target.originRepoPath),
12204
- branch: target.branch,
12205
- base,
12206
- ...existing ? { reviewed: {
12207
- reviewId: existing.id,
12208
- inProgress: existing.inProgress,
12209
- ...kept ? { kept } : {}
12210
- } } : {}
12211
- }
12568
+ answer: {
12569
+ ...answer,
12570
+ evidence
12571
+ },
12572
+ raw,
12573
+ droppedEvidence: answer.evidence.length - evidence.length
12212
12574
  };
12213
12575
  }
12214
12576
  /**
12215
- * Review the branch checked out in a folder: `prreviewbuddy` run there.
12216
- *
12217
- * Always answers with a one-row batch, which is what the panel follows, so a branch shows its
12218
- * progress exactly as a pull request does. With a free analysis slot it answers once the review
12219
- * exists, and the analysis runs on in this process, counted by `track` like a Retry. With none free
12220
- * it answers `waiting` and creates nothing until a slot frees: a review created to wait is a job
12221
- * nobody is running, which the sweep would fail. A review the branch already has needs no slot.
12222
- *
12223
- * A refusal before anything exists comes back as a refusal, with the base question when that is
12224
- * 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.
12225
12581
  */
12226
- async function startBranchReview(body, deps) {
12227
- const path = typeof body?.path === "string" ? expandPath(body.path, deps.home) : null;
12228
- if (!path) return refuse(400, "Choose the working folder to review.");
12229
- const base = typeof body?.base === "string" && body.base.trim() ? body.base.trim() : void 0;
12230
- const model = modelOf(body?.model);
12231
- if (isAnswer(model)) return model;
12232
- let target;
12233
- try {
12234
- target = await deps.resolveBranch(path, base);
12235
- } catch (error) {
12236
- return {
12237
- status: 400,
12238
- body: refusalOf(error)
12239
- };
12240
- }
12241
- const agent = await agentFor(body?.agent, deps);
12242
- if (isAnswer(agent)) return agent;
12243
- const row = {
12244
- key: batchIdentity(target),
12245
- label: target.branch,
12246
- status: "waiting"
12247
- };
12248
- const batch = newBatch([row]);
12249
- /**
12250
- * The branch's run, holding a slot. A retry runs against the commit first resolved, wherever the
12251
- * branch has moved since, unless `fresh`, which looks at the folder again as it is now.
12252
- */
12253
- const run = async (release, again) => {
12254
- const fresh = again === "fresh";
12255
- let replaced = false;
12256
- try {
12257
- const next = fresh ? await deps.resolveBranch(path, base) : target;
12258
- if (again === "retry") {
12259
- if (!await stillThere(row, target, "branch", deps)) return;
12260
- if (row.reviewId) return await retryReview(row, batch, deps);
12261
- }
12262
- const prepared = await prepareRow(row, next, agent.agentId, model, deps, fresh);
12263
- target = next;
12264
- replaced = fresh;
12265
- await completeRow(row, prepared, batch, deps);
12266
- } catch (error) {
12267
- fail(row, error);
12268
- } finally {
12269
- if (fresh) keepFreshPending(row, replaced);
12270
- release();
12271
- }
12272
- };
12273
- batch.rerun = (_index, fresh) => follow(batch, (async () => run(await deps.slots.acquire(), fresh ? "fresh" : "retry"))(), deps);
12274
- const answer = (outcome) => ({
12275
- status: 200,
12276
- body: {
12277
- outcome,
12278
- batchId: batch.id,
12279
- rows: rowsOf(batch),
12280
- ...row.reviewId ? { reviewId: row.reviewId } : {},
12281
- ...row.kept ? { kept: row.kept } : {}
12282
- }
12283
- });
12284
- const release = deps.slots.tryAcquire();
12285
- if (!release) {
12286
- const existing = await deps.findExisting(target);
12287
- if (existing && !deps.resumeOf(existing.id)) {
12288
- row.reviewId = existing.id;
12289
- if (!existing.inProgress) {
12290
- row.status = "already-reviewed";
12291
- return answer("existing");
12292
- }
12293
- row.status = "reviewing";
12294
- follow(batch, settleRow(row, deps.observe(existing.id)), deps, false);
12295
- return answer("existing");
12296
- }
12297
- follow(batch, (async () => run(await deps.slots.acquire(), "first"))(), deps);
12298
- return answer("waiting");
12299
- }
12300
- let prepared;
12301
- try {
12302
- prepared = await prepareRow(row, target, agent.agentId, model, deps);
12303
- } catch (error) {
12304
- release();
12305
- batches.delete(batch.id);
12306
- return {
12307
- status: 409,
12308
- body: refusalOf(error)
12309
- };
12310
- }
12311
- if (prepared.kind === "existing") {
12312
- await completeRow(row, prepared, batch, deps);
12313
- release();
12314
- } else follow(batch, (async () => {
12315
- try {
12316
- await settleRow(row, deps.settle(row.reviewId));
12317
- } catch (error) {
12318
- fail(row, error);
12319
- } finally {
12320
- 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
12321
12597
  }
12322
- })(), deps);
12323
- return answer(prepared.kind);
12598
+ }, askedAt).ok ? "proposed" : "stale";
12324
12599
  }
12325
- async function chooseFolder(deps) {
12326
- if (!deps.chooseFolder) return refuse(404, "Choosing a folder is not available on this system. Type its path instead.");
12327
- try {
12328
- const path = await deps.chooseFolder();
12329
- return {
12330
- status: 200,
12331
- body: path ? { path } : { cancelled: true }
12332
- };
12333
- } catch (error) {
12334
- return refuse(500, message(error));
12335
- }
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.*`;
12336
12608
  }
12337
12609
  //#endregion
12338
12610
  //#region ../../packages/review-harness/src/workspace/ask.ts
@@ -13592,6 +13864,7 @@ function handleIndex(req, res, params, indexToken) {
13592
13864
  const cookie = indexCookieHeader(indexToken);
13593
13865
  if (onward) return redirect(res, onward, cookie);
13594
13866
  const states = analysisStates();
13867
+ const operations = liveOperations();
13595
13868
  const reviews = recentWorkspaces(Number.MAX_SAFE_INTEGER);
13596
13869
  const everyGroup = groupByLineage(reviews);
13597
13870
  const queue = settleQueue(everyGroup);
@@ -13603,10 +13876,11 @@ function handleIndex(req, res, params, indexToken) {
13603
13876
  groups,
13604
13877
  queue: queue.filter((id) => rendered.has(id)),
13605
13878
  analyses: states,
13879
+ operations,
13606
13880
  moved: movedReviews(),
13607
13881
  prStates: prStatesByRequest(),
13608
13882
  activity,
13609
- revision: reviewsRevision(states),
13883
+ revision: reviewsRevision(states, operations),
13610
13884
  unreadNotifications: unreadCount$1(),
13611
13885
  driving: isDriving(),
13612
13886
  theme: readPreferences().theme,
@@ -14099,9 +14373,10 @@ function establishBaseline(reviewIds) {
14099
14373
  function handleRevision(req, res, indexToken) {
14100
14374
  if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
14101
14375
  const states = analysisStates();
14376
+ const operations = liveOperations();
14102
14377
  send(res, 200, {
14103
- revision: reviewsRevision(states),
14104
- live: anythingRunning(states),
14378
+ revision: reviewsRevision(states, operations),
14379
+ live: anythingRunning(states, operations),
14105
14380
  driving: isDriving(),
14106
14381
  unread: unreadCount$1()
14107
14382
  });
@@ -14206,7 +14481,7 @@ async function handleSections(req, res, indexToken) {
14206
14481
  if (req.method !== "POST") return send(res, 405, { error: "Use POST." });
14207
14482
  if (!indexToken || !secretMatches(indexToken, indexCookie(req))) return send(res, 403, { error: "Not authorised." });
14208
14483
  const body = await readJson(req);
14209
- 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." });
14210
14485
  writeSectionCollapsed(body.section, body.collapsed);
14211
14486
  send(res, 200, { ok: true });
14212
14487
  }