prreviewbuddy 0.83.3 → 0.86.1

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 MIN_WORKSPACE_PORT, $i as resolveModel, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as isGradeFor, An as escapeHtml$1, At as loadArchitecture, Bi as WITHHELD_REASON, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as ANALYSIS_ONLY, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as findingAssessment, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as currentGradeIn, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as followUpFindingId, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as isCommitReview, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as resolveTarget, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as asString, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as shortNameOf, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as resolveBase, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as extractJsonText, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as reassessIn, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as sourcePhrase, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as findingIn, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as wasReassessed, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveAgent, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as objectWithAnyOf, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as withExplanation, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as assessmentLookup, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as originRemote, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as kissOutcome, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as DetachedHeadError, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as git, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as shorthandOf, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as refExists, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _i as processDiscussion, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as STORE_ROOT, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, bi as explanationOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as agentFor$1, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as isAgentId, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as agentIdOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as parkedReason, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as AgentUnavailableError, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as promptGrade, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as gradeScale, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as agentIdentityOf, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as AgentCancelledError, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as readAgentPreference, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as AGENT_IDS, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as runnableAgents, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as AmbiguousRefError, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as writeAgentPreference, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as agentById, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as clearAgentPreference, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ua as detectAgents, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, vi as recommendationsOf, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as analysisRefOf, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xi as isExplanationAction, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, yi as explanationActionOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as describeAuthorship, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-BED4mPxB.js";
1
+ import { $ as MIN_WORKSPACE_PORT, $i as AmbiguousRefError, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as findingAssessment, An as escapeHtml$1, At as loadArchitecture, Bi as wasReassessed, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as withExplanation, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as assessmentLookup, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as analysisRefOf, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as isOffered, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as describeAuthorship, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as asString, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as promptGrade, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as resolveTarget, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as WITHHELD_REASON, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as reassessIn, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as findingStatus, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as gradeScale, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as closesFinding, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as isGradeFor, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveBase, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as sourcePhrase, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as isExplanationAction, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as NO_STATUS, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as extractJsonText, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as followUpFindingId, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as objectWithAnyOf, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as DetachedHeadError, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as originRemote, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as isCommitReview, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _ as runningJobs, _a as isAgentId, _i as verdictClosure, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as resolveModel, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, ba as AgentCancelledError, bi as explanationActionOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as readAgentPreference, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as STORE_ROOT, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as shortNameOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as AGENT_IDS, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, ga as detectAgents, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as agentIdentityOf, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ia as resolveAgent, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as findingIn, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as currentGradeIn, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as writeAgentPreference, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as agentFor$1, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as git, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as agentIdOf, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as agentById, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as kissOutcome, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as refExists, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as clearAgentPreference, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as shorthandOf, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, va as parkedReason, vi as processDiscussion, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as ANALYSIS_ONLY, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xa as AgentUnavailableError, xi as explanationOf, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, ya as runnableAgents, yi as recommendationsOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as statusLookup, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-rwn_-SnJ.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";
@@ -553,6 +553,8 @@ var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
553
553
  var MESSAGE_SOURCE_ATTR = "data-copy-source";
554
554
  /** A finding's text for a feedback draft, on its feedback checkbox. Copy's text less the reviewer's own working. */
555
555
  var FEEDBACK_SOURCE_ATTR = "data-feedback-source";
556
+ /** The finding's readable reference (`I3`), beside its feedback text, where the host draws references. */
557
+ var FEEDBACK_REF_ATTR = "data-feedback-ref";
556
558
  var STATES$1 = {
557
559
  idle: {
558
560
  icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
@@ -705,8 +707,8 @@ function renderFeedbackSelectButton() {
705
707
  * to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
706
708
  * counts are the way back to each pane.
707
709
  */
708
- function renderFeedbackTray() {
709
- return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
710
+ function renderFeedbackTray(reviewTag) {
711
+ return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback"${reviewTag ? ` data-review-tag="${escapeHtml$1(reviewTag)}"` : ""} hidden>
710
712
  <p class="prb-selection-count" aria-live="polite">
711
713
  <span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
712
714
  </p>
@@ -1300,6 +1302,63 @@ function renderFindingExplanation(explanation, options = {}) {
1300
1302
  </details>`;
1301
1303
  }
1302
1304
  //#endregion
1305
+ //#region ../../packages/review-harness/src/domain/ai_review/finding_ref.ts
1306
+ var PREFIX = {
1307
+ issue: "I",
1308
+ question: "Q"
1309
+ };
1310
+ function findingRef(kind, index) {
1311
+ return `${PREFIX[kind]}${index + 1}`;
1312
+ }
1313
+ /** Every finding's reference by its id, for a renderer that meets the findings out of order. */
1314
+ function findingRefs(result) {
1315
+ const refs = /* @__PURE__ */ new Map();
1316
+ result.issues.forEach((issue, index) => refs.set(`issue:${issue.id}`, findingRef("issue", index)));
1317
+ result.questions.forEach((question, index) => refs.set(`question:${question.id}`, findingRef("question", index)));
1318
+ return refs;
1319
+ }
1320
+ /** The reference of one finding, or undefined when the analysis does not hold it. */
1321
+ function refOf(refs, kind, id) {
1322
+ return refs.get(`${kind}:${id}`);
1323
+ }
1324
+ /**
1325
+ * The finding a reference names, or null. Only the exact form (`I3`, `q2` is accepted as `Q2`):
1326
+ * a reference out of range names nothing rather than the nearest finding.
1327
+ */
1328
+ function findingAtRef(result, ref) {
1329
+ const match = /^([IQ])([1-9]\d*)$/i.exec(ref.trim());
1330
+ if (!match) return null;
1331
+ const index = Number(match[2]) - 1;
1332
+ if (match[1].toUpperCase() === "I") {
1333
+ const finding = result.issues[index];
1334
+ return finding === void 0 ? null : {
1335
+ kind: "issue",
1336
+ index,
1337
+ finding
1338
+ };
1339
+ }
1340
+ const finding = result.questions[index];
1341
+ return finding === void 0 ? null : {
1342
+ kind: "question",
1343
+ index,
1344
+ finding
1345
+ };
1346
+ }
1347
+ /**
1348
+ * The review a set of references belongs to: `PRB-` and the first eight characters of the analysis's
1349
+ * session id. An update keeps the session, and so its references; a fresh analysis or a reanalysis
1350
+ * starts a new session and numbers its findings again, so its tag differs. A feedback draft carries
1351
+ * it, and Reconcile reads it back, so a reply to an older draft is never read against findings that
1352
+ * a later analysis happens to number the same way.
1353
+ */
1354
+ function reviewTag(sessionId) {
1355
+ return `PRB-${sessionId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase()}`;
1356
+ }
1357
+ /** Every review tag in a text, as `reviewTag` writes them. */
1358
+ function reviewTagsIn(text) {
1359
+ return [...new Set([...text.matchAll(/\bPRB-([a-z0-9]{8})\b/gi)].map((match) => `PRB-${match[1].toLowerCase()}`))];
1360
+ }
1361
+ //#endregion
1303
1362
  //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
1304
1363
  /**
1305
1364
  * What every pill in the sidebar actually means, in one place.
@@ -2050,7 +2109,8 @@ function renderReassessmentLine(kind, assessment, now) {
2050
2109
  /** Who is suggesting it, by the proposal's source. */
2051
2110
  function proposer(source) {
2052
2111
  switch (source.kind) {
2053
- case "buddy": return "Buddy AI";
2112
+ case "buddy":
2113
+ case "buddy-conversation": return "Buddy AI";
2054
2114
  case "investigation": return "The repository investigation";
2055
2115
  case "update": return "The update";
2056
2116
  case "reviewer": return "The review";
@@ -2089,9 +2149,92 @@ function renderWaitingSuggestions(count) {
2089
2149
  if (count === 0) return "";
2090
2150
  return `<span class="pr-helper-closed-findings-waiting" data-closed-findings-waiting> ${count === 1 ? "1 suggestion waiting" : `${count} suggestions waiting`}</span>`;
2091
2151
  }
2152
+ /** A verdict as a card names it. */
2153
+ function verdictLabel$1(verdict) {
2154
+ switch (verdict) {
2155
+ case "resolved": return "Resolved";
2156
+ case "needs-investigation": return "Needs investigation";
2157
+ case "open": return "Open again";
2158
+ case "answered": return "Answered";
2159
+ case "partially-answered": return "Partly answered";
2160
+ case "unanswered": return "Unanswered again";
2161
+ }
2162
+ }
2163
+ /**
2164
+ * The words the evidence rests on, as the reviewer pasted them. Quoted, never paraphrased: they are
2165
+ * someone else's claim, and the card shows the claim rather than vouching for it.
2166
+ */
2167
+ function renderQuotes(quotes) {
2168
+ if (!quotes?.length) return "";
2169
+ return quotes.map((quote) => `<blockquote class="pr-helper-reassessment-quote">${renderInlineText(quote.text)}</blockquote>`).join("");
2170
+ }
2171
+ /** What a resolution rests on, said before the places that show it. Nothing on any other verdict. */
2172
+ function renderBasis(basis) {
2173
+ if (!basis) return "";
2174
+ return `<p class="pr-helper-reassessment-basis" data-reassessment-basis="${basis}">${escapeHtml$1(basis === "code" ? "Buddy AI read this in the code at the reviewed commit. Check it before accepting:" : "Buddy AI read the reply as showing an assumption the issue made is false.")}</p>`;
2175
+ }
2176
+ function renderAnswer(answer) {
2177
+ return answer ? `<p class="pr-helper-reassessment-answer">${renderInlineText(answer)}</p>` : "";
2178
+ }
2179
+ function renderStatusStep(step, now) {
2180
+ return `<li class="pr-helper-reassessment-step">
2181
+ <span class="pr-helper-reassessment-step-move">${step.from ? `${escapeHtml$1(verdictLabel$1(step.from))} → ` : ""}<span class="pr-helper-reassessment-to">${escapeHtml$1(verdictLabel$1(step.to))}</span></span>
2182
+ <span class="pr-helper-reassessment-step-source">${escapeHtml$1(capitalise(sourcePhrase(step.source)))} · ${escapeHtml$1(formatRelativeTime(step.at, now))}</span>
2183
+ ${step.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(step.reason)}</p>` : ""}
2184
+ ${renderAnswer(step.answer)}
2185
+ ${renderQuotes(step.quotes)}
2186
+ ${renderBasis(step.basis)}
2187
+ ${renderEvidence$1(step.evidence)}
2188
+ </li>`;
2189
+ }
2190
+ /**
2191
+ * The status the reviewer accepted from later evidence, and History behind it. Says whether the
2192
+ * finding is still open, because an accepted status is not a closed one: a partial answer and an
2193
+ * issue that needs investigating stay in the work.
2194
+ */
2195
+ function renderStatusLine(status, now) {
2196
+ const current = status.current;
2197
+ if (!current) return "";
2198
+ const last = status.history[status.history.length - 1];
2199
+ const open = closesFinding(current) ? "" : " Still open.";
2200
+ return `<div class="pr-helper-reassessment pr-helper-finding-verdict" data-finding-verdict="${escapeHtml$1(current)}">
2201
+ <p class="pr-helper-reassessment-line"><span class="pr-helper-reassessment-to">${escapeHtml$1(verdictLabel$1(current))}</span> <span class="pr-helper-reassessment-source">· Accepted ${escapeHtml$1(sourcePhrase(last.source))}.${open}</span></p>
2202
+ ${renderAnswer(last.answer)}
2203
+ <details class="pr-helper-reassessment-history">
2204
+ <summary class="pr-helper-reassessment-history-summary">Status history</summary>
2205
+ <ol class="pr-helper-reassessment-steps">${status.history.map((step) => renderStatusStep(step, now)).join("")}</ol>
2206
+ </details>
2207
+ </div>`;
2208
+ }
2209
+ /**
2210
+ * A status waiting on the reviewer. The same answers as a grade's, under the same attribute, so a
2211
+ * host's Accept and Keep need no second wiring: Accept applies exactly this verdict, Keep declines it.
2212
+ * A proposal that would take back an accepted closure says so first.
2213
+ */
2214
+ function renderStatusProposal(status, options) {
2215
+ const proposal = status.pending;
2216
+ if (!proposal) return "";
2217
+ const actions = options.editable ? `<div class="pr-helper-reassessment-actions">
2218
+ <button type="button" class="pr-helper-reassessment-accept" data-reassessment-decide="accepted">Accept</button>
2219
+ <button type="button" class="pr-helper-reassessment-keep" data-reassessment-decide="declined">Keep as it is</button>
2220
+ ${options.askMore ? "<button type=\"button\" class=\"pr-helper-reassessment-more\" data-reassessment-ask-more>Ask more →</button>" : ""}
2221
+ </div>` : "";
2222
+ const contradiction = proposal.contradicts && proposal.from ? `<p class="pr-helper-reassessment-contradicts" data-reassessment-contradicts>This would take back “${escapeHtml$1(verdictLabel$1(proposal.from))}”, which you accepted earlier.</p>` : "";
2223
+ return `<div class="pr-helper-reassessment-proposal" data-reassessment-proposal="${escapeHtml$1(proposal.id)}" data-status-proposal="${escapeHtml$1(proposal.to)}">
2224
+ <p class="pr-helper-reassessment-proposal-title">${escapeHtml$1(proposer(proposal.source))} suggests: ${escapeHtml$1(verdictLabel$1(proposal.to))}</p>
2225
+ ${contradiction}
2226
+ ${renderAnswer(proposal.answer)}
2227
+ ${proposal.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(proposal.reason)}</p>` : ""}
2228
+ ${renderQuotes(proposal.quotes)}
2229
+ ${renderBasis(proposal.basis)}
2230
+ ${renderEvidence$1(proposal.evidence)}
2231
+ ${actions}
2232
+ </div>`;
2233
+ }
2092
2234
  /** The line, its History and any proposal, in the region a host replaces after a write. */
2093
- function renderAssessmentRegion(kind, assessment, options = {}) {
2094
- return `<div class="pr-helper-reassessment-region" data-assessment-region>${renderReassessmentLine(kind, assessment, options.now ?? Date.now())}${renderProposal(assessment, options)}</div>`;
2235
+ function renderAssessmentRegion(kind, assessment, options = {}, status) {
2236
+ const now = options.now ?? Date.now();
2237
+ return `<div class="pr-helper-reassessment-region" data-assessment-region>${status ? `${renderStatusLine(status, now)}${renderStatusProposal(status, options)}` : ""}${renderReassessmentLine(kind, assessment, now)}${renderProposal(assessment, options)}</div>`;
2095
2238
  }
2096
2239
  //#endregion
2097
2240
  //#region ../../packages/review-harness/src/domain/ui/components/review_tabs.ts
@@ -2534,7 +2677,7 @@ function possibleReason(summary, hasDescription) {
2534
2677
  */
2535
2678
  function renderUnclearMotive(motivation, session, questions) {
2536
2679
  const intent = questions.find((q) => q.category === "intent");
2537
- const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? [])) : false;
2680
+ const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question) : false;
2538
2681
  const hide = (yes) => yes ? " hidden" : "";
2539
2682
  return `
2540
2683
  <section class="pr-helper-why pr-helper-why-unclear" aria-labelledby="pr-helper-why-heading"${intent ? ` data-intent-question="${escapeHtml$1(intent.id)}"` : ""}>
@@ -2562,11 +2705,11 @@ function renderOverviewTab(session, iconUrl = "", options) {
2562
2705
  return `
2563
2706
  ${options?.authorContext ? renderAuthorContext(session, options.descriptionLink) : renderDescriptionLink(session, options?.descriptionLink)}
2564
2707
  ${renderWhyThisChange(result.motivation, session, result.questions, options)}
2565
- <section class="pr-helper-whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
2708
+ <section class="pr-helper-whats-changing" data-tour="whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
2566
2709
  <h4 class="pr-helper-overview-heading" id="pr-helper-whats-changing-heading">What's changing</h4>
2567
2710
  <div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
2568
2711
  </section>
2569
- <div class="pr-helper-risk-headline">
2712
+ <div class="pr-helper-risk-headline" data-tour="risk">
2570
2713
  <div class="pr-helper-risk-headline-top">
2571
2714
  <span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
2572
2715
  <span class="pr-helper-risk-headline-label">${escapeHtml$1(titleCase(result.overallRisk))} Risk</span>
@@ -2575,10 +2718,10 @@ function renderOverviewTab(session, iconUrl = "", options) {
2575
2718
  ${renderProvisionalReason(session)}
2576
2719
  ${result.recommendation ? `<div class="pr-helper-risk-headline-takeaway pr-helper-markdown">${renderMarkdown(result.recommendation)}</div>` : ""}
2577
2720
  </div>
2578
- <div class="pr-helper-overview-stats">
2721
+ <div class="pr-helper-overview-stats" data-tour="stats">
2579
2722
  ${renderOverviewStat(metrics.sections, "Themes", "guide")}
2580
2723
  ${renderIssuesStat(issuesOutstanding(result.issues, reviewerDispositions(session)))}
2581
- ${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
2724
+ ${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question), "Questions", "questions", "answered")}
2582
2725
  ${renderCoverageStat(session, metrics.files)}
2583
2726
  </div>
2584
2727
  ${renderSuggestedPath(result.sections, "Suggested review path")}
@@ -2621,13 +2764,13 @@ function discussionDetails(session) {
2621
2764
  if (reviewThreads > 0) {
2622
2765
  const outdated = stats.outdatedThreads ?? 0;
2623
2766
  const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
2624
- parts.push(`${reviewThreads} ${plural$2(reviewThreads, "review thread")} (${state})`);
2767
+ parts.push(`${reviewThreads} ${plural$3(reviewThreads, "review thread")} (${state})`);
2625
2768
  }
2626
- if (conversation > 0) parts.push(`${conversation} ${plural$2(conversation, "conversation comment")}`);
2627
- if (decisions > 0) parts.push(`${decisions} ${plural$2(decisions, "review decision")}`);
2769
+ if (conversation > 0) parts.push(`${conversation} ${plural$3(conversation, "conversation comment")}`);
2770
+ if (decisions > 0) parts.push(`${decisions} ${plural$3(decisions, "review decision")}`);
2628
2771
  return parts;
2629
2772
  }
2630
- function plural$2(count, word) {
2773
+ function plural$3(count, word) {
2631
2774
  return count === 1 ? word : `${word}s`;
2632
2775
  }
2633
2776
  function renderExistingDiscussion(ref) {
@@ -2698,7 +2841,7 @@ function renderSuggestedPath(sections, heading) {
2698
2841
  if (sections.length === 0) return "";
2699
2842
  const items = sections.map((s) => `<li>${escapeHtml$1(s.title)}</li>`).join("");
2700
2843
  return `
2701
- <div class="pr-helper-suggested-path">
2844
+ <div class="pr-helper-suggested-path" data-tour="suggested-path">
2702
2845
  <h4 class="pr-helper-suggested-path-title">${escapeHtml$1(heading)}</h4>
2703
2846
  <ol class="pr-helper-suggested-path-list">${items}</ol>
2704
2847
  <p class="pr-helper-suggested-path-time">Estimated review time ≈${estimateTotalMinutes(sections)} minutes</p>
@@ -3068,8 +3211,11 @@ function renderIssuesTab(session, investigatingId, options) {
3068
3211
  const dismissals = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
3069
3212
  const dispositions = reviewerDispositions(session);
3070
3213
  const assess = assessmentLookup(session);
3214
+ const statusOf = statusLookup(session);
3071
3215
  const grading = gradeOptions(options, now);
3216
+ const refs = options?.findingRefs ? findingRefs(result) : null;
3072
3217
  const card = (issue) => {
3218
+ const ref = refs ? refOf(refs, "issue", issue.id) : void 0;
3073
3219
  const isDismissed = dispositions.isDismissed(issue);
3074
3220
  const stale = dispositions.isStale(issue);
3075
3221
  const assessment = assess("issue", issue);
@@ -3077,7 +3223,8 @@ function renderIssuesTab(session, investigatingId, options) {
3077
3223
  <div class="pr-helper-issue-card ${cardGradeClass("issue", assessment.current)}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
3078
3224
  <div class="pr-helper-issue-header">
3079
3225
  <div class="pr-helper-issue-title-row">
3080
- ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue)) : ""}
3226
+ ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue), ref) : ""}
3227
+ ${renderFindingRef(ref)}
3081
3228
  <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
3082
3229
  ${renderFindingCopySource(issueMarkdown(issue, assessment), "issue")}
3083
3230
  ${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
@@ -3092,7 +3239,7 @@ function renderIssuesTab(session, investigatingId, options) {
3092
3239
  </div>
3093
3240
  </div>
3094
3241
  ${renderStaleDismissal(stale)}
3095
- ${renderAssessmentRegion("issue", assessment, grading)}
3242
+ ${renderAssessmentRegion("issue", assessment, grading, statusOf("issue", issue.id))}
3096
3243
  <p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(explanationOf(session, "issue", issue.id), options)}
3097
3244
  ${renderReconciliationNote(issue.reconciliationNote)}
3098
3245
  ${renderStatusHistory(issue.statusHistory, now)}
@@ -3103,7 +3250,7 @@ function renderIssuesTab(session, investigatingId, options) {
3103
3250
  ${renderInvestigationProvenance("issue", issue.id, records, now)}
3104
3251
  <div class="pr-helper-issue-actions">
3105
3252
  ${renderInvestigationActions("issue", issue.id, records, investigatingId)}
3106
- ${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id))}
3253
+ ${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id), dispositions.isClosedByVerdict(issue))}
3107
3254
  </div>
3108
3255
  </div>
3109
3256
  `;
@@ -3113,7 +3260,7 @@ function renderIssuesTab(session, investigatingId, options) {
3113
3260
  const open = result.issues.filter((issue) => isIssueOutstanding(issue, dispositions)).map(card).join("");
3114
3261
  const closedIssues = result.issues.filter((issue) => !isIssueOutstanding(issue, dispositions));
3115
3262
  const closed = closedIssues.map(card).join("");
3116
- return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending).length)}`;
3263
+ return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending || statusOf("issue", issue.id).pending).length)}`;
3117
3264
  }
3118
3265
  /**
3119
3266
  * What to call the closed pile, by what is actually in it.
@@ -3130,7 +3277,9 @@ function closedFindingsSummary(counts) {
3130
3277
  if (byConversation === counts.done) return `Resolved by the conversation (${counts.done})`;
3131
3278
  return `Resolved (${counts.done})`;
3132
3279
  }
3133
- if (byCommits === 0) return `Dismissed by you (${counts.done})`;
3280
+ const byVerdict = counts.byVerdict ?? 0;
3281
+ if (byCommits === 0 && byVerdict === 0) return `Dismissed by you (${counts.done})`;
3282
+ if (byCommits === 0 && byVerdict === counts.done) return `Accepted as resolved (${counts.done})`;
3134
3283
  return `Dealt with (${counts.done})`;
3135
3284
  }
3136
3285
  /**
@@ -3169,11 +3318,11 @@ function renderStaleDismissal(stale) {
3169
3318
  * toggle is. A reviewer agreeing with a fix changes no count, and a control that vanishes on some
3170
3319
  * cards is harder to explain than one that occasionally changes nothing.
3171
3320
  */
3172
- function renderDismissToggle(issue, isDismissed, stale, dismissal) {
3321
+ function renderDismissToggle(issue, isDismissed, stale, dismissal, closedByVerdict = false) {
3173
3322
  const label = stale ? "Dismiss again" : isDismissed ? "Reopen" : "Dismiss";
3174
3323
  const settled = issue.reconciliationStatus === "resolved";
3175
3324
  const settledBy = settled ? ` data-settled-by="${resolvedByConversation(issue) ? "conversation" : "code"}"` : "";
3176
- return `<button type="button" class="pr-helper-issue-dismiss-toggle" data-issue-dismissed="${escapeHtml$1(issue.id)}" data-dismissed="${isDismissed}"${stale ? " data-stale=\"true\"" : ""}${dismissal ? ` data-dismissed-in="${escapeHtml$1(dismissal.sessionId)}"` : ""}${settled ? ` data-settled="true"${settledBy}` : ""}>${label}</button>`;
3325
+ return `<button type="button" class="pr-helper-issue-dismiss-toggle" data-issue-dismissed="${escapeHtml$1(issue.id)}" data-dismissed="${isDismissed}"${stale ? " data-stale=\"true\"" : ""}${dismissal ? ` data-dismissed-in="${escapeHtml$1(dismissal.sessionId)}"` : ""}${settled ? ` data-settled="true"${settledBy}` : ""}${closedByVerdict ? " data-verdict-closed=\"true\"" : ""}>${label}</button>`;
3177
3326
  }
3178
3327
  /**
3179
3328
  * The group an Intent question lives in, keyed like a theme's so the workspace client finds its
@@ -3185,19 +3334,21 @@ var INTENT_QUESTION_GROUP = "prb-intent";
3185
3334
  function questionHome(q) {
3186
3335
  return q.category === "intent" ? INTENT_QUESTION_GROUP : q.sectionId;
3187
3336
  }
3188
- function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options, explanation, assessment = ANALYSIS_ONLY("question", q)) {
3337
+ function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options, explanation, assessment = ANALYSIS_ONLY("question", q), ref, status = NO_STATUS("question", q.id)) {
3189
3338
  const grading = gradeOptions(options, now);
3190
3339
  const importanceBadge = renderGradeControl("question", assessment, grading);
3191
3340
  const jumpLink = renderQuestionJump(q.file, options);
3192
3341
  const settled = q.reconciliationStatus === "answered";
3193
- const answerToggle = `<button type="button" class="pr-helper-question-answer-toggle" data-question-answered="${escapeHtml$1(q.id)}" data-answered="${answered}"${settled ? " data-settled=\"true\"" : ""}>${answered ? "Mark as unanswered" : "Mark answered"}</button>`;
3342
+ const verdictClosed = closesFinding(status.current);
3343
+ const answerToggle = `<button type="button" class="pr-helper-question-answer-toggle" data-question-answered="${escapeHtml$1(q.id)}" data-answered="${answered}"${settled ? " data-settled=\"true\"" : ""}${verdictClosed ? " data-verdict-closed=\"true\"" : ""}>${answered ? "Mark as unanswered" : "Mark answered"}</button>`;
3194
3344
  const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
3195
3345
  const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>${escapeHtml$1(reconciledAnswerHeading(q.reconciledAgainst))}</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
3196
3346
  return `
3197
3347
  <div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${questionHome(q) ? ` data-section-id="${escapeHtml$1(questionHome(q))}"` : ""}>
3198
3348
  <div class="pr-helper-issue-header">
3199
3349
  <div class="pr-helper-issue-title-row">
3200
- ${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q)) : ""}
3350
+ ${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q), ref) : ""}
3351
+ ${renderFindingRef(ref)}
3201
3352
  <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
3202
3353
  ${renderFindingCopySource(questionMarkdown(q, assessment), "question")}
3203
3354
  ${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
@@ -3214,7 +3365,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
3214
3365
  ${importanceBadge}
3215
3366
  </div>
3216
3367
  </div>
3217
- ${renderAssessmentRegion("question", assessment, grading)}
3368
+ ${renderAssessmentRegion("question", assessment, grading, status)}
3218
3369
  ${q.whyThisMatters ? `<h5>Why this matters</h5><p class="pr-helper-issue-desc">${renderInlineText(q.whyThisMatters)}</p>` : ""}${renderCardExplanation(explanation, options)}
3219
3370
  ${reconciliationAnswer}
3220
3371
  ${renderReconciliationNote(q.reconciliationNote)}
@@ -3245,8 +3396,8 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
3245
3396
  function questionsProgressBody(total, outstanding) {
3246
3397
  return outstanding === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">✓</span><span class="pr-helper-questions-progress-count">All ${total} questions answered</span>` : `<span class="pr-helper-questions-progress-count">${outstanding} of ${total} unanswered</span>`;
3247
3398
  }
3248
- function renderQuestionsProgress(questions, answered) {
3249
- const counts = questionsOutstanding(questions, answered);
3399
+ function renderQuestionsProgress(questions, answered, closedByVerdict) {
3400
+ const counts = questionsOutstanding(questions, answered, closedByVerdict);
3250
3401
  if (counts.total < 2) return "";
3251
3402
  return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
3252
3403
  }
@@ -3261,8 +3412,13 @@ function renderQuestionsProgress(questions, answered) {
3261
3412
  * It carries the text the card gives a feedback draft, which is narrower than its Copy: see
3262
3413
  * finding_copy.ts.
3263
3414
  */
3264
- function renderFeedbackPick(kind, title, feedback) {
3265
- return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" ${FEEDBACK_SOURCE_ATTR}="${escapeHtml$1(feedback)}" autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
3415
+ function renderFeedbackPick(kind, title, feedback, ref) {
3416
+ const refAttr = ref ? ` ${FEEDBACK_REF_ATTR}="${ref}"` : "";
3417
+ return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" ${FEEDBACK_SOURCE_ATTR}="${escapeHtml$1(feedback)}"${refAttr} autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
3418
+ }
3419
+ /** The card's readable reference, ahead of its title. Drawn only where the host asked for references. */
3420
+ function renderFindingRef(ref) {
3421
+ return ref ? `<span class="pr-helper-finding-ref" data-finding-ref="${ref}">${ref}</span>` : "";
3266
3422
  }
3267
3423
  function renderQuestionJump(file, options) {
3268
3424
  if (!file || options?.suppressFileJump) return "";
@@ -3279,14 +3435,17 @@ function renderQuestionsTab(session, investigatingId, options) {
3279
3435
  if (result.questions.length === 0) return emptyState("No open questions.");
3280
3436
  const sections = buildSectionLookup(result);
3281
3437
  const answered = new Set(session.answeredQuestionIds ?? []);
3282
- const progress = renderQuestionsProgress(result.questions, answered);
3438
+ const progress = renderQuestionsProgress(result.questions, answered, verdictClosure(session).question);
3283
3439
  const records = session.investigations;
3284
3440
  const now = Date.now();
3285
3441
  const assess = assessmentLookup(session);
3286
- const card = (q) => renderQuestionCard(q, sections, answered.has(q.id), records, now, investigatingId, options, explanationOf(session, "question", q.id), assess("question", q));
3287
- const open = result.questions.filter((q) => isQuestionOutstanding(q, answered));
3288
- const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered));
3289
- const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0, settled.filter((q) => assess("question", q).pending).length);
3442
+ const statusOf = statusLookup(session);
3443
+ const closedByVerdict = verdictClosure(session).question;
3444
+ const refs = options?.findingRefs ? findingRefs(result) : null;
3445
+ const card = (q) => renderQuestionCard(q, sections, answered.has(q.id), records, now, investigatingId, options, explanationOf(session, "question", q.id), assess("question", q), refs ? refOf(refs, "question", q.id) : void 0, statusOf("question", q.id));
3446
+ const open = result.questions.filter((q) => isQuestionOutstanding(q, answered, closedByVerdict));
3447
+ const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered, closedByVerdict));
3448
+ const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0, settled.filter((q) => assess("question", q).pending || statusOf("question", q.id).pending).length);
3290
3449
  const intent = open.filter((q) => q.category === "intent");
3291
3450
  const intentHtml = result.questions.some((q) => q.category === "intent") ? `
3292
3451
  <div class="pr-helper-question-group" data-question-group="${INTENT_QUESTION_GROUP}"${intent.length ? "" : " hidden"}>
@@ -3474,6 +3633,41 @@ function renderThemePicker(sections, selectedId) {
3474
3633
  return `<div class="prb-guide-picker">${sections.map((section, index) => `<a class="prb-guide-pick" href="${escapeHtml$1(hashForSection(section.id))}" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}" aria-current="${section.id === selectedId}"><span class="prb-guide-pick-order">${index + 1}</span>${escapeHtml$1(section.title)}</a>`).join("")}</div>`;
3475
3634
  }
3476
3635
  //#endregion
3636
+ //#region ../../packages/review-harness/src/domain/ui/components/turn_actions.ts
3637
+ /**
3638
+ * What a finished Buddy AI turn offers under its answer, drawn the same by the page and by the client
3639
+ * on a live answer, so a reload never changes it.
3640
+ *
3641
+ * Every completed turn offers Reconcile with findings. Always, rather than when a turn looks like an
3642
+ * author's reply: whether a turn bears on the findings is the judgement the reconcile itself makes,
3643
+ * and a control that appears only sometimes is one a reviewer cannot count on. A reconcile turn
3644
+ * offers Accept all instead, while any suggestion it made is still waiting.
3645
+ */
3646
+ /**
3647
+ * The supporting sentence, said beside the action. It names the scope, because the action reads only
3648
+ * the message of the turn it sits under: a reply split across several messages is reconciled one
3649
+ * message at a time (the route takes several turns; choosing them on the page is not built yet).
3650
+ */
3651
+ var RECONCILE_HELP = "Reassesses existing issues and questions using only your message in this turn.";
3652
+ function renderTurnActions(turn, waiting = []) {
3653
+ if (turn.reconciles) return renderAcceptAll(waiting);
3654
+ return `<div class="prb-turn-actions" data-turn-actions="${turn.askedAt}">
3655
+ <button type="button" class="prb-turn-reconcile" data-reconcile-turn="${turn.askedAt}">Reconcile with findings</button>
3656
+ <span class="prb-turn-help">${escapeHtml$1(RECONCILE_HELP)}</span>
3657
+ </div>`;
3658
+ }
3659
+ /**
3660
+ * Accept every suggestion a reconcile made that is still waiting, each on its own card exactly as its
3661
+ * own Accept would. Nothing when none waits.
3662
+ */
3663
+ function renderAcceptAll(waiting) {
3664
+ if (!waiting.length) return "";
3665
+ const label = waiting.length === 1 ? "Accept the suggestion" : `Accept all ${waiting.length} suggestions`;
3666
+ return `<div class="prb-turn-actions">
3667
+ <button type="button" class="prb-turn-accept-all" data-reconcile-accept-all="${escapeHtml$1(waiting.join(" "))}">${escapeHtml$1(label)}</button>
3668
+ </div>`;
3669
+ }
3670
+ //#endregion
3477
3671
  //#region ../../packages/review-harness/src/workspace/diagram_view.ts
3478
3672
  /**
3479
3673
  * The diagrams a review holds, drawn as cards in one scrolling pane.
@@ -3555,6 +3749,42 @@ function renderDrawnFrom(diagram, reviewedSha) {
3555
3749
  return `<p class="prb-diagram-stale">Drawn from <code>${escapeHtml$1(diagram.commitSha.slice(0, 7))}</code>, before this review was brought up to date. Regenerate to draw it from the commit the review now describes.</p>`;
3556
3750
  }
3557
3751
  //#endregion
3752
+ //#region ../../packages/review-harness/src/workspace/guided_tour.ts
3753
+ function isGuidedTourState(value) {
3754
+ return value === "completed" || value === "dismissed";
3755
+ }
3756
+ /**
3757
+ * Whether a review page opens the tour by itself: a live page (one with a server behind it) over a
3758
+ * standard result that no update is replacing, for a reviewer who has not already finished or
3759
+ * skipped it. Running reviews never qualify, so the tour cannot interrupt one.
3760
+ */
3761
+ function offersGuidedTour(page) {
3762
+ return page.live && page.hasResult && !page.updating && page.state === void 0;
3763
+ }
3764
+ /** The hash Replay opens a review at. Not a place the page knows, so the page drops it once read. */
3765
+ var TOUR_REPLAY_HASH = "#tour";
3766
+ /**
3767
+ * Whether the tour can be shown on a review: its analysis finished, and nothing is analysing or
3768
+ * updating it now. Whether the reviewer has marked it Reviewed has nothing to do with it.
3769
+ */
3770
+ function tourEligible(review, busy) {
3771
+ return review.hasStandardReview && !busy.has(review.id);
3772
+ }
3773
+ /**
3774
+ * The review Replay from Help opens: the one last opened, when the tour can be shown on it, and
3775
+ * otherwise the newest it can be shown on. Never a busy review, so none is chosen only when no
3776
+ * review qualifies. Replay from a review's own menu does not ask: it uses the review on screen.
3777
+ */
3778
+ function chooseTourReview(reviews, busy) {
3779
+ const last = reviews.filter((review) => review.lastViewedAt !== void 0).reduce((best, review) => !best || review.lastViewedAt > best.lastViewedAt ? review : best, null);
3780
+ if (last && tourEligible(last, busy)) return last;
3781
+ return reviews.filter((review) => tourEligible(review, busy)).reduce((best, review) => !best || review.createdAt > best.createdAt ? review : best, null);
3782
+ }
3783
+ function tourUnavailable(reviews, busy) {
3784
+ if (chooseTourReview(reviews, busy)) return null;
3785
+ return reviews.some((review) => review.hasStandardReview) ? "busy" : "none";
3786
+ }
3787
+ //#endregion
3558
3788
  //#region ../../packages/review-harness/src/domain/change_map/types.ts
3559
3789
  var MAP_ROLES = [
3560
3790
  "intent",
@@ -5546,7 +5776,7 @@ function updateFacts(outcome) {
5546
5776
  counts.affected ? `${counts.affected} to look at again` : "",
5547
5777
  counts.questionsAnswered ? `${plural(counts.questionsAnswered, "question", "questions")} answered` : ""
5548
5778
  ].filter(Boolean);
5549
- const grades = counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [];
5779
+ const grades = [...counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [], ...counts.decisionsChallenged ? [`${plural(counts.decisionsChallenged, "accepted answer", "accepted answers")} put back to you`] : []];
5550
5780
  const basis = outcome.reassessed;
5551
5781
  return [...basis && !basis.code ? [escapeHtml$1("Reassessed against the conversation")] : [`<code>${escapeHtml$1(outcome.toSha.slice(0, 7))}</code>`, ...basis?.discussion ? [escapeHtml$1("with the conversation")] : []], ...[...changes.length ? changes : ["No change to the findings"], ...grades].map(escapeHtml$1)];
5552
5782
  }
@@ -6151,7 +6381,12 @@ function renderWorkspace(view) {
6151
6381
  <link rel="stylesheet" href="/assets/sidebar.css">
6152
6382
  <link rel="stylesheet" href="/assets/shell.css">
6153
6383
  </head>
6154
- <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}">
6384
+ <body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}"${offersGuidedTour({
6385
+ live: view.token !== void 0,
6386
+ hasResult: Boolean(session.result),
6387
+ updating: updating(view),
6388
+ state: view.guidedTour
6389
+ }) ? " data-guided-tour=\"offer\"" : ""}${view.tourBusy ? " data-tour-busy" : ""}>
6155
6390
  ${renderGlobalBar({
6156
6391
  unread: view.unreadNotifications ?? 0,
6157
6392
  live: Boolean(view.token),
@@ -6230,7 +6465,7 @@ function renderReviewBody(view, sections) {
6230
6465
  ${renderRail(view, sections, updating(view))}
6231
6466
  <main class="prb-doc">
6232
6467
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
6233
- ${offersFeedback(view) ? renderFeedbackTray() : ""}
6468
+ ${offersFeedback(view) ? renderFeedbackTray(reviewTag(view.session.id)) : ""}
6234
6469
  </main>
6235
6470
  ${result ? renderAsk(view, sections, updating(view)) : ""}
6236
6471
  ${result ? renderAskTab() : ""}
@@ -6405,7 +6640,8 @@ function renderStandardViews(view, sections) {
6405
6640
  ${renderIssuesTab(session, null, {
6406
6641
  askBuddy: true,
6407
6642
  feedbackSelect: true,
6408
- reassess: true
6643
+ reassess: true,
6644
+ findingRefs: true
6409
6645
  })}
6410
6646
  </section>
6411
6647
  <section class="prb-view" data-view="questions" hidden>
@@ -6414,7 +6650,8 @@ function renderStandardViews(view, sections) {
6414
6650
  suppressFileJump: true,
6415
6651
  askBuddy: true,
6416
6652
  feedbackSelect: true,
6417
- reassess: true
6653
+ reassess: true,
6654
+ findingRefs: true
6418
6655
  })}
6419
6656
  </section>
6420
6657
  <section class="prb-view" data-view="prcontext" hidden>
@@ -6689,6 +6926,7 @@ function renderIdMenu(view, freshness) {
6689
6926
  branchItems ? `<button type="button" class="prb-menu-item" data-queue-action="${view.queued ? "remove" : "add"}">${queueItemLabel(view.queued === true)}</button>` : "",
6690
6927
  canWrite ? `<button type="button" class="prb-menu-item" data-pin-toggle aria-pressed="${view.pinned === true}">${pinItemLabel(view.pinned === true)}</button>` : "",
6691
6928
  `<button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>`,
6929
+ canWrite && view.session.result ? "<button type=\"button\" class=\"prb-menu-item\" data-tour-replay>Replay guided tour</button>" : "",
6692
6930
  canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""
6693
6931
  ].filter(Boolean);
6694
6932
  return `<div class="prb-menu" data-menu data-review-menu${[
@@ -6798,7 +7036,7 @@ function renderRail(view, sections, stale = false) {
6798
7036
  </div>
6799
7037
  ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
6800
7038
  ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
6801
- ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
7039
+ ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered, verdictClosure(view.session).question), "question", "answered"), void 0, void 0, true)}
6802
7040
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
6803
7041
  ${view.prContext ? railLink("prcontext", "prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "prcontext", "PR context", null, "No pull request was connected to this review")}`
6804
7042
  }) : "";
@@ -6960,7 +7198,7 @@ function totalBadge(count, noun) {
6960
7198
  * with all of them", and a tick would claim the second on a review that earned the first.
6961
7199
  */
6962
7200
  /** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
6963
- function plural$1(n, noun) {
7201
+ function plural$2(n, noun) {
6964
7202
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
6965
7203
  }
6966
7204
  function remainingBadge(counts, noun, verb) {
@@ -6970,16 +7208,16 @@ function remainingBadge(counts, noun, verb) {
6970
7208
  };
6971
7209
  if (counts.open === 0) return {
6972
7210
  text: "✓",
6973
- label: `All ${plural$1(counts.total, noun)} ${verb}`,
7211
+ label: `All ${plural$2(counts.total, noun)} ${verb}`,
6974
7212
  done: true
6975
7213
  };
6976
7214
  if (counts.done === 0) return {
6977
7215
  text: String(counts.open),
6978
- label: plural$1(counts.total, noun)
7216
+ label: plural$2(counts.total, noun)
6979
7217
  };
6980
7218
  return {
6981
7219
  text: String(counts.open),
6982
- label: `${counts.open} of ${plural$1(counts.total, noun)} left, ${counts.done} ${verb}`
7220
+ label: `${counts.open} of ${plural$2(counts.total, noun)} left, ${counts.done} ${verb}`
6983
7221
  };
6984
7222
  }
6985
7223
  /**
@@ -7003,9 +7241,9 @@ function findingsBadge(counts) {
7003
7241
  */
7004
7242
  function themeFindingsNote(counts) {
7005
7243
  if (counts.total === 0) return null;
7006
- if (counts.open === 0) return `all ${plural$1(counts.total, "issue")} ${doneVerb(counts)}`;
7007
- if (counts.done === 0) return plural$1(counts.open, "issue");
7008
- return `${counts.open} of ${plural$1(counts.total, "issue")} left`;
7244
+ if (counts.open === 0) return `all ${plural$2(counts.total, "issue")} ${doneVerb(counts)}`;
7245
+ if (counts.done === 0) return plural$2(counts.open, "issue");
7246
+ return `${counts.open} of ${plural$2(counts.total, "issue")} left`;
7009
7247
  }
7010
7248
  /**
7011
7249
  * `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
@@ -7297,7 +7535,8 @@ function renderAsk(view, sections, paused = false) {
7297
7535
  ${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
7298
7536
  <div class="prb-ask-log" data-ask-log>
7299
7537
  ${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
7300
- <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
7538
+ <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>
7539
+ ${paused ? "" : renderTurnActions(exchange, waitingProposals(view.session, exchange.reconciles?.proposals))}`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
7301
7540
  <p>Ask about this change. Answers come only from the review, the diff and the repository on disk.</p>
7302
7541
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
7303
7542
  </div>`}
@@ -7319,6 +7558,12 @@ function renderAsk(view, sections, paused = false) {
7319
7558
  * The list is always present, even when empty: an empty bank with an Add button explains what
7320
7559
  * the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
7321
7560
  */
7561
+ /** Of the suggestions a reconcile made, the ones still waiting on the reviewer on this analysis. */
7562
+ function waitingProposals(session, ids) {
7563
+ if (!ids?.length) return [];
7564
+ const wanted = new Set(ids);
7565
+ return (session.findingReassessments ?? []).filter((entry) => wanted.has(entry.id) && isOffered(session, entry)).map((entry) => entry.id);
7566
+ }
7322
7567
  function renderPromptBank(prompts, collapsed = false) {
7323
7568
  return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
7324
7569
  <div class="prb-bank-head">
@@ -8216,7 +8461,7 @@ function readPrStates() {
8216
8461
  if (parsed.version !== VERSION$2) return {};
8217
8462
  const entries = parsed.entries;
8218
8463
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8219
- return normalise$1(entries);
8464
+ return normalise$2(entries);
8220
8465
  } catch {
8221
8466
  return {};
8222
8467
  }
@@ -8241,7 +8486,7 @@ function prunePrStates(states, referenced) {
8241
8486
  * state with no time or a time with no state says nothing, and an unknown state must not be read
8242
8487
  * as one this build knows.
8243
8488
  */
8244
- function normalise$1(entries) {
8489
+ function normalise$2(entries) {
8245
8490
  const states = {};
8246
8491
  for (const [key, value] of Object.entries(entries)) {
8247
8492
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -8356,7 +8601,7 @@ function readWatchDocument() {
8356
8601
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return null;
8357
8602
  return {
8358
8603
  version,
8359
- entries: normalise(entries)
8604
+ entries: normalise$1(entries)
8360
8605
  };
8361
8606
  } catch {
8362
8607
  return null;
@@ -8369,7 +8614,7 @@ function readWatch() {
8369
8614
  if (parsed.version !== VERSION) return {};
8370
8615
  const entries = parsed.entries;
8371
8616
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8372
- return normalise(entries);
8617
+ return normalise$1(entries);
8373
8618
  } catch {
8374
8619
  return {};
8375
8620
  }
@@ -8410,7 +8655,7 @@ function pruneWatch(watched) {
8410
8655
  * `notifiedSha` that survived as a number would compare unequal to every real sha and announce on
8411
8656
  * every cycle for ever.
8412
8657
  */
8413
- function normalise(entries) {
8658
+ function normalise$1(entries) {
8414
8659
  const state = {};
8415
8660
  for (const [id, value] of Object.entries(entries)) {
8416
8661
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -10673,7 +10918,7 @@ function readPreferences() {
10673
10918
  try {
10674
10919
  const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
10675
10920
  if (!parsed || typeof parsed !== "object") return {};
10676
- const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout, sidebar } = parsed;
10921
+ const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout, sidebar, guidedTour } = parsed;
10677
10922
  return {
10678
10923
  ...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
10679
10924
  ...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
@@ -10683,7 +10928,8 @@ function readPreferences() {
10683
10928
  ...readReviewSections(reviewSections),
10684
10929
  ...readRunsSections(runsSections),
10685
10930
  ...readLayout(reviewLayout),
10686
- ...readSidebar(sidebar)
10931
+ ...readSidebar(sidebar),
10932
+ ...isGuidedTourState(guidedTour) ? { guidedTour } : {}
10687
10933
  };
10688
10934
  } catch {
10689
10935
  return {};
@@ -10745,6 +10991,21 @@ function writeSidebar(patch) {
10745
10991
  }, null, 2)}\n`);
10746
10992
  } catch {}
10747
10993
  }
10994
+ /**
10995
+ * Remember how the guided tour was first answered. The first answer stands: a replay from Help, or
10996
+ * a second window finishing a tour another already skipped, is not a new answer to the offer.
10997
+ */
10998
+ function writeGuidedTour(state) {
10999
+ try {
11000
+ const current = readPreferences();
11001
+ if (current.guidedTour) return;
11002
+ mkdirSync(STORE_ROOT, { recursive: true });
11003
+ writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
11004
+ ...current,
11005
+ guidedTour: state
11006
+ }, null, 2)}\n`);
11007
+ } catch {}
11008
+ }
10748
11009
  /** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
10749
11010
  function writePromptsCollapsed(collapsed) {
10750
11011
  try {
@@ -11143,7 +11404,7 @@ function renderPrivacySection(settings) {
11143
11404
  * A record with no facts -- every one written before 0.74.0 -- gets no line, and its row looks as
11144
11405
  * it always did.
11145
11406
  */
11146
- var plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
11407
+ var plural$1 = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
11147
11408
  function shortSha(sha) {
11148
11409
  return sha.slice(0, 7);
11149
11410
  }
@@ -11155,15 +11416,15 @@ function headMoveLine(notification, facts) {
11155
11416
  const where = notification.source === "branch" ? "locally" : "on the pull request";
11156
11417
  const parts = [`${step(facts.fromSha, notification.headSha)} ${where}`];
11157
11418
  if (facts.rewritten) parts.push("branch rewritten since the review");
11158
- else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural(facts.behind, "commit")} behind`);
11419
+ else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural$1(facts.behind, "commit")} behind`);
11159
11420
  else parts.push("review behind");
11160
11421
  return parts.join(" · ");
11161
11422
  }
11162
11423
  /** The card's own words for what is left (`whatIsLeft`), so a row and a card never disagree in vocabulary. */
11163
11424
  function reviewLine(facts) {
11164
11425
  const parts = [];
11165
- if (facts.openIssues > 0) parts.push(plural(facts.openIssues, "issue"));
11166
- if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
11426
+ if (facts.openIssues > 0) parts.push(plural$1(facts.openIssues, "issue"));
11427
+ if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
11167
11428
  if (parts.length === 0) parts.push("Nothing flagged");
11168
11429
  if (facts.risk) parts.push(`${facts.risk} risk`);
11169
11430
  return parts.join(" · ");
@@ -11186,8 +11447,8 @@ function updateLine(facts) {
11186
11447
  const delta = updateDelta(facts);
11187
11448
  parts.push(...delta.length > 0 ? delta : ["findings unchanged"]);
11188
11449
  if (facts.stillOpen > 0) parts.push(`${facts.stillOpen} still open`);
11189
- if (facts.questionsAnswered > 0) parts.push(`${plural(facts.questionsAnswered, "question")} answered`);
11190
- if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
11450
+ if (facts.questionsAnswered > 0) parts.push(`${plural$1(facts.questionsAnswered, "question")} answered`);
11451
+ if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
11191
11452
  if (facts.fetchFailed) parts.push("remote not reached");
11192
11453
  else if (facts.upToDate) parts.push("up to date");
11193
11454
  return parts.join(" · ");
@@ -11223,7 +11484,7 @@ function toastFact(notification) {
11223
11484
  return delta.length > 0 ? delta.slice(0, 2).join(", ") : "findings unchanged";
11224
11485
  }
11225
11486
  const facts = notification.facts;
11226
- return facts.openIssues > 0 ? plural(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
11487
+ return facts.openIssues > 0 ? plural$1(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
11227
11488
  }
11228
11489
  /**
11229
11490
  * A toast's whole line: what happened, to what, and at most one fact.
@@ -11474,6 +11735,30 @@ var AGENT_PRODUCTS = ((names) => names.length <= 1 ? names[0] ?? "" : `${names.s
11474
11735
  * says what to type and points here for what it means. The diagrams are text on purpose.
11475
11736
  */
11476
11737
  var TOPICS = [
11738
+ {
11739
+ id: "welcome",
11740
+ title: "Welcome",
11741
+ heading: `<p class="prb-help-eyebrow">Welcome to PR Review Buddy</p>
11742
+ <h2 class="prb-help-headline">Understand before you approve.</h2>`,
11743
+ body: `<p>Code review isn't just about finding bugs. It's about understanding what changed, why it
11744
+ changed, and whether those changes make sense.</p>
11745
+ <p>AI can generate code faster than ever. But someone still needs to understand it, challenge its
11746
+ assumptions, and take responsibility for approving it.</p>
11747
+ <p>That's why we built PR Review Buddy.</p>
11748
+ <p>PR Review Buddy helps you make sense of changes before you approve them. It brings together
11749
+ code, context, conversations and AI-assisted analysis so you can focus on what matters: making
11750
+ informed engineering decisions.</p>
11751
+ <p>The goal isn't to replace the reviewer. It's to make the reviewer better informed.</p>
11752
+ <h3 class="prb-help-eyebrow">Our principles</h3>
11753
+ <dl class="prb-help-principles">
11754
+ <div><dt>Understanding over automation</dt>
11755
+ <dd>AI should help you reason about changes, not make decisions on your behalf.</dd></div>
11756
+ <div><dt>Evidence over assertions</dt>
11757
+ <dd>Findings should be grounded in the code and context, so you can investigate and challenge them.</dd></div>
11758
+ <div><dt>You remain in control</dt>
11759
+ <dd>Choose your AI agent, explore the analysis, and make the final judgement yourself.</dd></div>
11760
+ </dl>`
11761
+ },
11477
11762
  {
11478
11763
  id: "getting-started",
11479
11764
  title: "Getting started",
@@ -11570,6 +11855,26 @@ feature: E</pre>
11570
11855
  <li><b>Delete</b> removes a review and the checkouts it was made in. See
11571
11856
  <a href="#reviews-and-ids">Reviews and review IDs</a>.</li>
11572
11857
  </ul>`
11858
+ },
11859
+ {
11860
+ id: "author-replies",
11861
+ title: "Asking the author",
11862
+ body: `<p>Every issue and question has a reference, such as <b>I3</b> or <b>Q2</b>, shown on its
11863
+ card. <b>Prepare feedback</b> on Issues or Questions turns the ones you choose into a draft to send
11864
+ the author, each led by its reference, so the author can answer “Q2: …”.</p>
11865
+ <p>When the reply comes back, paste it into Buddy AI as one message and press <b>Reconcile with
11866
+ findings</b> under it. Only that message is read. Buddy AI reads it against the review and
11867
+ suggests what it settles: a question answered or partly answered, an issue resolved, or one that
11868
+ needs investigating. Each suggestion appears on its card with the words it rests on, and nothing
11869
+ changes until you accept it.</p>
11870
+ <p>The draft ends with the review it came from. A reply naming a different review, such as one
11871
+ analysed afresh since, is not reconciled, because its references number different findings.</p>
11872
+ <p>Buddy AI is asked to read the code before suggesting an issue is resolved, and to cite the lines
11873
+ that show it. The review checks that the cited files exist, not that the code fixes anything, so
11874
+ read the cited lines before you accept. A fix the author has not pushed, or has only promised, is
11875
+ never suggested as resolved. A partly answered question and an issue that needs investigating stay
11876
+ open. If a later reply or new commits contradict an answer you accepted, you are asked again
11877
+ rather than the answer being undone.</p>`
11573
11878
  },
11574
11879
  {
11575
11880
  id: "reviews-and-ids",
@@ -11675,7 +11980,27 @@ feature: E</pre>
11675
11980
  <div data-shortcut-table></div>`
11676
11981
  }
11677
11982
  ];
11678
- /** Every topic by name at the top, so one in the middle is found by reading a list, not a page. */
11983
+ /**
11984
+ * Every topic by name beside the topics, so one in the middle is found by reading a list, not a
11985
+ * page. First in the document so a keyboard or a screen reader reaches it before the prose; the
11986
+ * stylesheet places it. `help_index.ts` marks the topic being read.
11987
+ */
11988
+ /**
11989
+ * Where the tour lives once it has been seen. `/tour` picks the review when Replay is pressed (see
11990
+ * `chooseTourReview`); with none to pick, Help says why rather than offering a button that would
11991
+ * open a review the tour cannot start on.
11992
+ */
11993
+ function renderTourAction(tour, failed) {
11994
+ const lead = failed ? "<strong>The guided tour couldn't start.</strong> " : "";
11995
+ const role = failed ? " role=\"status\"" : "";
11996
+ if (tour === "available") return `<p class="prb-help-tour">The guided tour walks through a completed review, one part at a time,
11997
+ starting with the one you opened last. A review's &#8943; menu replays it on that review.</p>
11998
+ <p><a class="prb-help-tour-action" href="/tour">Replay guided tour</a></p>`;
11999
+ if (tour === "busy") return `<p class="prb-help-tour"${role}>${lead}The guided tour walks through a completed review, and every
12000
+ one you have is being analysed or updated right now. Replay it from here once that finishes.</p>`;
12001
+ return `<p class="prb-help-tour"${role}>${lead}The guided tour walks through a completed review, and there
12002
+ isn't one yet. Start a review from <a href="/">Reviews</a>, then replay the tour from here once it has finished.</p>`;
12003
+ }
11679
12004
  function renderContents() {
11680
12005
  return `<nav class="prb-help-contents" aria-label="Help topics">
11681
12006
  <ul>${TOPICS.map((topic) => `<li><a href="#${topic.id}">${escapeHtml$1(topic.title)}</a></li>`).join("")}</ul>
@@ -11706,13 +12031,16 @@ ${renderWorkspaceRail({
11706
12031
  sidebar: view.sidebar
11707
12032
  })}
11708
12033
  <main class="prb-index prb-help">
11709
- <div class="prb-page-panel">
12034
+ <div class="prb-help-layout">
11710
12035
  ${renderContents()}
12036
+ <div class="prb-page-panel">
11711
12037
  ${TOPICS.map((topic) => `<section class="prb-help-topic" id="${topic.id}">
11712
- <h2>${escapeHtml$1(topic.title)}</h2>
12038
+ ${topic.heading ?? `<h2>${escapeHtml$1(topic.title)}</h2>`}
11713
12039
  ${topic.body}
12040
+ ${topic.id === "welcome" ? renderTourAction(view.tour, view.tourFailed === true) : ""}
11714
12041
  </section>`).join("")}
11715
12042
  </div>
12043
+ </div>
11716
12044
  <p class="prb-help-version">PR Review Buddy v${escapeHtml$1(BUILD_VERSION)}</p>
11717
12045
  </main>
11718
12046
  </div>
@@ -13664,6 +13992,7 @@ function gradeFragments$1(session, kind, id) {
13664
13992
  const finding = findingIn(session, kind, id);
13665
13993
  if (!finding) return null;
13666
13994
  const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
13995
+ const status = findingStatus(session.findingReassessments, kind, id, analysisRefOf(session));
13667
13996
  const options = {
13668
13997
  editable: true,
13669
13998
  askMore: true
@@ -13674,8 +14003,9 @@ function gradeFragments$1(session, kind, id) {
13674
14003
  current: assessment.current,
13675
14004
  cardClass: cardGradeClass(kind, assessment.current),
13676
14005
  grade: renderGradeControl(kind, assessment, options),
13677
- region: renderAssessmentRegion(kind, assessment, options),
13678
- copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
14006
+ region: renderAssessmentRegion(kind, assessment, options, status),
14007
+ copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment),
14008
+ verdictClosed: closesFinding(status.current)
13679
14009
  };
13680
14010
  }
13681
14011
  /** The finding a request is about: named outright, or through the proposal it decides. */
@@ -13757,7 +14087,7 @@ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
13757
14087
  //#endregion
13758
14088
  //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
13759
14089
  /** More than any real answer cites; a list longer than this is padding, not evidence. */
13760
- var MAX_EVIDENCE = 10;
14090
+ var MAX_EVIDENCE$1 = 10;
13761
14091
  function scaleText(kind) {
13762
14092
  return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
13763
14093
  }
@@ -13809,7 +14139,7 @@ function buildReassessInstructions(input) {
13809
14139
  ];
13810
14140
  }
13811
14141
  var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
13812
- function readEvidence(value) {
14142
+ function readEvidence$1(value) {
13813
14143
  if (!Array.isArray(value)) return [];
13814
14144
  const out = [];
13815
14145
  for (const item of value) {
@@ -13821,7 +14151,7 @@ function readEvidence(value) {
13821
14151
  file,
13822
14152
  line
13823
14153
  });
13824
- if (out.length === MAX_EVIDENCE) break;
14154
+ if (out.length === MAX_EVIDENCE$1) break;
13825
14155
  }
13826
14156
  return out;
13827
14157
  }
@@ -13842,7 +14172,7 @@ function parseReassessAnswer(raw, kind, current) {
13842
14172
  const obj = parsed;
13843
14173
  const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
13844
14174
  if (!reason) return null;
13845
- const evidence = readEvidence(obj.evidence);
14175
+ const evidence = readEvidence$1(obj.evidence);
13846
14176
  if (obj.verdict === "keep") return {
13847
14177
  verdict: "keep",
13848
14178
  reason,
@@ -13925,6 +14255,267 @@ function reassessTurnText(target, turn, outcome) {
13925
14255
  const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
13926
14256
  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.*`;
13927
14257
  }
14258
+ /** More than any finding a real reply rests on; past this it is padding. */
14259
+ var MAX_QUOTES = 4;
14260
+ var MAX_EVIDENCE = 10;
14261
+ /** The turns, labelled in the order the reviewer wrote them. */
14262
+ function labelTurns(turns) {
14263
+ return [...turns].sort((a, b) => a.askedAt - b.askedAt).map((turn, index) => ({
14264
+ label: `A${index + 1}`,
14265
+ askedAt: turn.askedAt,
14266
+ text: turn.question
14267
+ }));
14268
+ }
14269
+ function issueLine(issue, index, assess, status) {
14270
+ const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
14271
+ const severity = ` [severity: ${promptGrade(assess("issue", issue))}]`;
14272
+ return `${findingRef("issue", index)}.${severity}${where}${priorStatus(issue.reconciliationStatus === "resolved" ? "resolved by the code" : void 0)}${acceptedLine(status("issue", issue.id))} ${issue.title.trim()}\n ${issue.description.trim().replace(/\n/g, "\n ")}`;
14273
+ }
14274
+ function questionLine(question, index, assess, status, ticked) {
14275
+ const importance = assess("question", question).current ? ` [importance: ${promptGrade(assess("question", question))}]` : "";
14276
+ const where = question.file ? ` [${question.file}]` : "";
14277
+ const why = question.whyThisMatters?.trim() ? `\n ${question.whyThisMatters.trim()}` : "";
14278
+ const prior = question.reconciliationStatus === "answered" ? "answered by the code" : ticked ? "marked answered by the reviewer" : void 0;
14279
+ return `${findingRef("question", index)}.${importance}${where}${priorStatus(prior)}${acceptedLine(status("question", question.id))} ${question.question.trim()}${why}`;
14280
+ }
14281
+ function priorStatus(text) {
14282
+ return text ? ` [${text}]` : "";
14283
+ }
14284
+ /**
14285
+ * The verdict the reviewer accepted earlier, said as their decision and not as a fact, with what it
14286
+ * rested on, so evidence that contradicts it is recognised as a contradiction.
14287
+ */
14288
+ function acceptedLine(status) {
14289
+ if (!status.current) return "";
14290
+ const last = status.history[status.history.length - 1];
14291
+ return ` [the reviewer accepted "${status.current}" ${sourcePhrase(last.source)}${last.reason ? `: ${last.reason}` : ""}]`;
14292
+ }
14293
+ var SHAPE = `{
14294
+ "findings": [
14295
+ {
14296
+ "ref": string,
14297
+ "status": "answered" | "partially-answered" | "still-open" | "resolved" | "needs-investigation",
14298
+ "reason": string,
14299
+ "answer"?: string,
14300
+ "basis"?: "premise" | "code",
14301
+ "evidence"?: [{ "file": string, "line"?: number }],
14302
+ "quotes": [{ "turn": string, "text": string }],
14303
+ "severity"?: ${gradeScale("issue").map((grade) => `"${grade}"`).join(" | ")},
14304
+ "importance"?: ${gradeScale("question").map((grade) => `"${grade}"`).join(" | ")},
14305
+ "gradeReason"?: string
14306
+ }
14307
+ ],
14308
+ "unmatched": [{ "turn": string, "text": string, "candidates": string[], "why": string }]
14309
+ }`;
14310
+ /** The block a reconcile turn adds to Buddy AI's prompt: the turns, the findings, the rules and the shape. */
14311
+ function buildReplyReconciliationInstructions(input) {
14312
+ const { result, assess, status } = input;
14313
+ const issues = result.issues.length ? result.issues.map((issue, index) => issueLine(issue, index, assess, status)).join("\n") : "(The review raised no issues.)";
14314
+ const questions = result.questions.length ? result.questions.map((question, index) => questionLine(question, index, assess, status, input.answeredQuestionIds.has(question.id))).join("\n") : "(The review raised no questions.)";
14315
+ return [
14316
+ "---",
14317
+ "RECONCILE WITH FINDINGS",
14318
+ "The reviewer has asked you to read the turns below, which they wrote in this conversation, as evidence about the review's existing issues and questions. Most often they are an author's replies to feedback the reviewer sent, pasted in. Nothing you return changes a finding: each judgement becomes a suggestion the reviewer accepts or rejects on its own.",
14319
+ "",
14320
+ "THE TURNS (the reviewer's own messages, labelled; Buddy AI's answers are not evidence):",
14321
+ ...input.turns.map((turn) => `[${turn.label}]\n${turn.text.trim()}`),
14322
+ "",
14323
+ input.review.named ? `The turns name this review (${input.review.tag}), so the references in them are this review's.` : `The turns do not name this review (${input.review.tag}). A reference in them may come from a draft of an earlier analysis whose findings were numbered differently: use a reference only where what the passage says matches that finding, and otherwise treat the passage as unmatched.`,
14324
+ "",
14325
+ "THE FINDINGS, by the reference the review's cards and its feedback drafts use:",
14326
+ "Issues:",
14327
+ issues,
14328
+ "Questions:",
14329
+ questions,
14330
+ "",
14331
+ "RULES",
14332
+ "- Match a passage to a finding by its reference where it names one (\"Q2:\", \"I3.\"), and otherwise only by what it unmistakably addresses. Never match a passage in order to produce a judgement. Where a passage could be about more than one finding, or you cannot tell which it answers, put it in \"unmatched\" with its candidate references and leave those findings out. The turns need not follow the order of the findings, one passage may address several findings, and one finding may be addressed across several turns.",
14333
+ "- Include only the findings the turns address. Leave every other finding out: an omitted finding is one the turns do not bear on, not one that is still open.",
14334
+ "- A question is \"answered\" only when the turns answer its substance. A reply that acknowledges it, defers it, restates it or answers a different question does not answer it. Where they answer part of it, use \"partially-answered\" and say in \"answer\" what they establish and what is still missing. Where they address it without answering it, use \"still-open\". For \"answered\" and \"partially-answered\", put the answer itself in \"answer\", in a sentence or two.",
14335
+ "- What an author says about an issue is one of three things, and they are judged differently:",
14336
+ " 1. The concern never held: an assumption the issue made is false (the code concerned is unreachable, is being removed, or a requirement it assumed does not exist). That may be \"resolved\" with \"basis\": \"premise\". Check the claim against the code wherever the code can settle it, and where the code contradicts it, it is not resolved.",
14337
+ " 2. It is already implemented: the code at the reviewed commit is said to handle it. That may be \"resolved\" with \"basis\": \"code\" only after you have opened that code and read it, and only when it addresses this concern, not merely when the file exists or touches the same area. List in \"evidence\" each place, with its line, where the code does so. A file name on its own is not evidence, and neither is code that exists but does not address the concern.",
14338
+ " 3. It is fixed somewhere you cannot read, or will be: fixed locally, in another branch, in a commit not yet pushed, or planned. That is a claim about code, not evidence, and never \"resolved\". Use \"needs-investigation\" where the claim deserves checking once the code exists, otherwise \"still-open\". Agreeing that it is a problem, with a plan to fix it later, is \"still-open\".",
14339
+ "- \"needs-investigation\": the turns raise something that bears on the issue and that only further reading or a further question can settle. Say what in \"reason\".",
14340
+ "- Treat what the author asserts as context, not proof. Where you can check a claim in the repository, check it before relying on it.",
14341
+ "- A verdict the reviewer accepted earlier is marked on its finding. It was their decision, not a fact. Where the turns contradict it, report the finding as \"still-open\", with the contradiction in \"reason\", so it is put to them.",
14342
+ "- Every judgement must quote, in \"quotes\", the exact words from the turns it rests on, copied character for character (a sentence or two each), with the turn's label. A judgement whose words cannot be found in the turn it names is discarded.",
14343
+ `- "severity" on an issue or "importance" on a question is optional: only where the turns change how much the finding matters, never the grade it already has, and always with "gradeReason".`,
14344
+ "- In \"reason\" and \"answer\", name the author by role (\"the author confirms the job is retired\"), and write no references or labels: the reviewer reads those sentences beside the finding.",
14345
+ "",
14346
+ "Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:",
14347
+ SHAPE,
14348
+ "\"status\" on a question (a Q reference) is \"answered\", \"partially-answered\" or \"still-open\"; on an issue (an I reference) it is \"resolved\", \"needs-investigation\" or \"still-open\". \"basis\" and \"evidence\" go only with \"resolved\". \"severity\" goes only on an issue and \"importance\" only on a question.",
14349
+ "Empty arrays are valid and far better than judgements the turns do not support."
14350
+ ];
14351
+ }
14352
+ var QUESTION_STATUSES = [
14353
+ "answered",
14354
+ "partially-answered",
14355
+ "still-open"
14356
+ ];
14357
+ var ISSUE_STATUSES = [
14358
+ "resolved",
14359
+ "needs-investigation",
14360
+ "still-open"
14361
+ ];
14362
+ var ANSWER = objectWithAnyOf("findings", "unmatched");
14363
+ /** Whitespace and quote marks as a model may retype them: two texts are the same words if these agree. */
14364
+ function normalise(text) {
14365
+ return text.replace(/[‘’]/g, "'").replace(/[“”]/g, "\"").replace(/\s+/g, " ").trim();
14366
+ }
14367
+ /** The quotes found, word for word, in the turns they name. Nothing else is kept. */
14368
+ function verifiedQuotes(value, turns) {
14369
+ if (!Array.isArray(value)) return [];
14370
+ const byLabel = new Map(turns.map((turn) => [turn.label.toUpperCase(), turn]));
14371
+ const kept = [];
14372
+ for (const item of value) {
14373
+ const quote = item;
14374
+ if (typeof quote?.turn !== "string" || typeof quote.text !== "string") continue;
14375
+ const turn = byLabel.get(quote.turn.trim().replace(/^\[|\]$/g, "").toUpperCase());
14376
+ const text = normalise(quote.text);
14377
+ if (!turn || !text || text.length > 600) continue;
14378
+ if (!normalise(turn.text).includes(text)) continue;
14379
+ if (kept.some((existing) => existing.askedAt === turn.askedAt && existing.text === text)) continue;
14380
+ kept.push({
14381
+ askedAt: turn.askedAt,
14382
+ text
14383
+ });
14384
+ if (kept.length === MAX_QUOTES) break;
14385
+ }
14386
+ return kept;
14387
+ }
14388
+ function readEvidence(value) {
14389
+ if (!Array.isArray(value)) return [];
14390
+ return value.flatMap((item) => {
14391
+ const place = item;
14392
+ if (typeof place?.file !== "string" || !place.file.trim()) return [];
14393
+ const line = typeof place.line === "number" && Number.isInteger(place.line) && place.line > 0 ? place.line : void 0;
14394
+ return [{
14395
+ file: place.file.trim(),
14396
+ ...line ? { line } : {}
14397
+ }];
14398
+ }).slice(0, MAX_EVIDENCE);
14399
+ }
14400
+ function text(value) {
14401
+ return typeof value === "string" ? value.trim() : "";
14402
+ }
14403
+ /**
14404
+ * The answer, read strictly. Null when it is not an answer at all; otherwise every judgement that
14405
+ * names a finding the review holds, with a status on that finding's scale, a reason, and at least one
14406
+ * quote found in the turns. A judgement missing any of those is counted and dropped, never repaired.
14407
+ */
14408
+ function parseReplyReconciliation(raw, result, turns) {
14409
+ let parsed;
14410
+ try {
14411
+ parsed = JSON.parse(extractJsonText(raw, ANSWER));
14412
+ } catch {
14413
+ return null;
14414
+ }
14415
+ if (!ANSWER(parsed)) return null;
14416
+ const obj = parsed;
14417
+ const discarded = {
14418
+ unknownRef: 0,
14419
+ unquoted: 0
14420
+ };
14421
+ const judgements = [];
14422
+ const seen = /* @__PURE__ */ new Set();
14423
+ for (const item of Array.isArray(obj.findings) ? obj.findings : []) {
14424
+ const entry = item;
14425
+ const at = typeof entry?.ref === "string" ? findingAtRef(result, entry.ref) : null;
14426
+ if (!at) {
14427
+ discarded.unknownRef += 1;
14428
+ continue;
14429
+ }
14430
+ const allowed = at.kind === "issue" ? ISSUE_STATUSES : QUESTION_STATUSES;
14431
+ const status = entry.status;
14432
+ const reason = text(entry.reason);
14433
+ if (typeof status !== "string" || !allowed.includes(status) || !reason) {
14434
+ discarded.unknownRef += 1;
14435
+ continue;
14436
+ }
14437
+ const quotes = verifiedQuotes(entry.quotes, turns);
14438
+ if (!quotes.length) {
14439
+ discarded.unquoted += 1;
14440
+ continue;
14441
+ }
14442
+ const key = `${at.kind}:${at.index}`;
14443
+ if (seen.has(key)) continue;
14444
+ seen.add(key);
14445
+ const answer = text(entry.answer);
14446
+ const basis = entry.basis === "premise" || entry.basis === "code" ? entry.basis : void 0;
14447
+ const gradeTo = at.kind === "issue" ? entry.severity : entry.importance;
14448
+ const gradeReason = text(entry.gradeReason);
14449
+ judgements.push({
14450
+ kind: at.kind,
14451
+ index: at.index,
14452
+ findingId: at.finding.id,
14453
+ ref: findingRef(at.kind, at.index),
14454
+ status,
14455
+ reason,
14456
+ ...answer ? { answer } : {},
14457
+ ...basis ? { basis } : {},
14458
+ evidence: readEvidence(entry.evidence),
14459
+ quotes,
14460
+ ...isGradeFor(at.kind, gradeTo) && gradeReason ? { grade: {
14461
+ to: gradeTo,
14462
+ reason: gradeReason
14463
+ } } : {}
14464
+ });
14465
+ }
14466
+ return {
14467
+ judgements,
14468
+ unmatched: (Array.isArray(obj.unmatched) ? obj.unmatched : []).flatMap((item) => {
14469
+ const passage = item;
14470
+ const words = text(passage?.text);
14471
+ if (!words) return [];
14472
+ const candidates = (Array.isArray(passage.candidates) ? passage.candidates : []).filter((ref) => typeof ref === "string" && findingAtRef(result, ref) !== null).map((ref) => ref.trim().toUpperCase());
14473
+ return [{
14474
+ text: words.slice(0, 600),
14475
+ candidates,
14476
+ why: text(passage.why)
14477
+ }];
14478
+ }),
14479
+ discarded
14480
+ };
14481
+ }
14482
+ /** Said beside a resolution the harness could not let stand as one. */
14483
+ var UNSUPPORTED_RESOLUTION = "Treated as needing investigation: the reply was read as resolving it, but cited no line of the reviewed code that shows it.";
14484
+ /**
14485
+ * A resolution, checked against what it says it rests on, as far as the harness can check it.
14486
+ *
14487
+ * `premise` stands as the model judged it. `code` stands only with at least one cited place, with a
14488
+ * line, in a file that exists at the reviewed commit (`existing` is the set of paths found there).
14489
+ * Anything else is downgraded to "needs investigation", with the reason saying so.
14490
+ *
14491
+ * This is citation validation, not verification: a file that exists does not show that the code in it
14492
+ * fixes anything. Whether it does is the model's reading, which the prompt requires it to make by
14493
+ * opening the code, and the reviewer's to confirm before accepting. The card says as much.
14494
+ */
14495
+ function groundResolution(judgement, existing) {
14496
+ if (judgement.kind !== "issue" || judgement.status !== "resolved") return judgement;
14497
+ const evidence = judgement.evidence.filter((place) => existing.has(place.file));
14498
+ if (judgement.basis === "premise") return {
14499
+ ...judgement,
14500
+ evidence
14501
+ };
14502
+ if (judgement.basis === "code" && evidence.some((place) => place.line !== void 0)) return {
14503
+ ...judgement,
14504
+ evidence
14505
+ };
14506
+ return {
14507
+ ...judgement,
14508
+ status: "needs-investigation",
14509
+ evidence,
14510
+ reason: `${judgement.reason} ${UNSUPPORTED_RESOLUTION}`
14511
+ };
14512
+ }
14513
+ /** A proposal a judgement becomes on the status axis, or null when it is not one. */
14514
+ function verdictFor(judgement, current) {
14515
+ if (judgement.status !== "still-open") return judgement.status;
14516
+ if (!closesFinding(current)) return null;
14517
+ return judgement.kind === "issue" ? "open" : "unanswered";
14518
+ }
13928
14519
  //#endregion
13929
14520
  //#region ../../packages/review-harness/src/workspace/ask.ts
13930
14521
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
@@ -14039,6 +14630,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14039
14630
  "REVIEW GUIDE (JSON):",
14040
14631
  JSON.stringify(workspace.session.result),
14041
14632
  ...reassessedGrades(workspace),
14633
+ ...acceptedStatuses(workspace),
14042
14634
  "---",
14043
14635
  "DIFF:",
14044
14636
  workspace.diff,
@@ -14056,6 +14648,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14056
14648
  ...continuingFrom(workspace, context.continuing),
14057
14649
  ...explainingFinding(workspace, context.explaining),
14058
14650
  ...reassessingFinding(workspace, context.reassessing),
14651
+ ...reconcilingTurns(workspace, context.reconciling),
14059
14652
  "---",
14060
14653
  `QUESTION: ${question}`
14061
14654
  ].join("\n");
@@ -14119,6 +14712,33 @@ function reassessedGrades(workspace) {
14119
14712
  ];
14120
14713
  }
14121
14714
  /**
14715
+ * The statuses the reviewer has accepted from later evidence, such as an author's reply. Listed only
14716
+ * when there are some, as the reviewer's decisions rather than facts, so a turn neither re-asks a
14717
+ * question they have accepted an answer to nor treats that answer as beyond question.
14718
+ */
14719
+ function acceptedStatuses(workspace) {
14720
+ const result = workspace.session.result;
14721
+ if (!result) return [];
14722
+ const status = statusLookup(workspace.session);
14723
+ const lines = [...result.issues.map((issue) => ({
14724
+ name: `ISSUE "${issue.title.trim()}"`,
14725
+ status: status("issue", issue.id)
14726
+ })), ...result.questions.map((q) => ({
14727
+ name: `QUESTION "${q.question.trim()}"`,
14728
+ status: status("question", q.id)
14729
+ }))].flatMap(({ name, status: s }) => {
14730
+ if (!s.current) return [];
14731
+ const last = s.history[s.history.length - 1];
14732
+ return [`- ${name}: accepted as ${s.current}${last.reason ? ` (${last.reason})` : ""}${last.answer ? `. Answer: ${last.answer}` : ""}`];
14733
+ });
14734
+ if (!lines.length) return [];
14735
+ return [
14736
+ "---",
14737
+ "STATUSES THE REVIEWER HAS ACCEPTED FROM LATER EVIDENCE (their decisions, which later evidence may still contradict):",
14738
+ ...lines
14739
+ ];
14740
+ }
14741
+ /**
14122
14742
  * Reassess on a finding: everything the judgement needs, and the exact shape of the answer. The
14123
14743
  * thread the analysis tied the finding to goes in when it resolves in the conversation this review
14124
14744
  * holds; when it does not, the turn goes ahead without it.
@@ -14138,12 +14758,290 @@ function reassessingFinding(workspace, reassessing) {
14138
14758
  ...thread ? { thread } : {}
14139
14759
  });
14140
14760
  }
14761
+ /**
14762
+ * Reconcile with findings: the chosen turns, every finding by its reference with the grade and any
14763
+ * verdict the review holds now, the evidence rules and the exact shape of the answer.
14764
+ */
14765
+ function reconcilingTurns(workspace, reconciling) {
14766
+ const result = workspace.session.result;
14767
+ if (!reconciling || !result) return [];
14768
+ return buildReplyReconciliationInstructions({
14769
+ result,
14770
+ assess: assessmentLookup(workspace.session),
14771
+ status: statusLookup(workspace.session),
14772
+ answeredQuestionIds: new Set(workspace.session.answeredQuestionIds ?? []),
14773
+ turns: reconciling.turns,
14774
+ review: reconciling.review
14775
+ });
14776
+ }
14141
14777
  /** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
14142
14778
  function findingName(workspace, finding) {
14143
14779
  const result = workspace.session.result;
14144
14780
  const title = finding.kind === "issue" ? result?.issues.find((issue) => issue.id === finding.id)?.title : result?.questions.find((question) => question.id === finding.id)?.question;
14145
14781
  return `THE ${finding.kind.toUpperCase()}${title ? ` "${title.trim()}"` : ""}`;
14146
14782
  }
14783
+ var TURNS_REFUSED = {
14784
+ none: "Choose at least one turn of this conversation to reconcile with the findings.",
14785
+ unknown: "This conversation has no such turn to reconcile.",
14786
+ "too-many": `Reconcile reads at most 10 turns at once. Choose fewer.`,
14787
+ "too-long": "Those turns are too long to read in one reconcile. Choose fewer, or paste the reply in parts."
14788
+ };
14789
+ /**
14790
+ * The turns a request names, from the transcript as stored. Only the reviewer's own turns with words
14791
+ * in them: a reconcile turn is not evidence, and neither is anything Buddy AI wrote.
14792
+ */
14793
+ function reconcileTurnsOf(transcript, value) {
14794
+ const asked = value?.exchanges;
14795
+ if (!Array.isArray(asked) || asked.length === 0) return {
14796
+ ok: false,
14797
+ refusal: "none"
14798
+ };
14799
+ const wanted = [...new Set(asked)];
14800
+ if (wanted.length > 10) return {
14801
+ ok: false,
14802
+ refusal: "too-many"
14803
+ };
14804
+ const turns = [];
14805
+ for (const askedAt of wanted) {
14806
+ const turn = typeof askedAt === "number" ? transcript.find((exchange) => exchange.askedAt === askedAt) : void 0;
14807
+ if (!turn || turn.reconciles || !turn.question.trim()) return {
14808
+ ok: false,
14809
+ refusal: "unknown"
14810
+ };
14811
+ turns.push(turn);
14812
+ }
14813
+ if (turns.reduce((total, turn) => total + turn.question.length, 0) > 4e4) return {
14814
+ ok: false,
14815
+ refusal: "too-long"
14816
+ };
14817
+ return {
14818
+ ok: true,
14819
+ turns
14820
+ };
14821
+ }
14822
+ function reviewIdentityOf(turns, sessionId) {
14823
+ const tag = reviewTag(sessionId);
14824
+ const named = [...new Set(turns.flatMap((turn) => reviewTagsIn(turn.question)))];
14825
+ if (!named.length) return {
14826
+ kind: "unnamed",
14827
+ tag
14828
+ };
14829
+ if (named.some((other) => other !== tag)) return {
14830
+ kind: "other",
14831
+ tag,
14832
+ named
14833
+ };
14834
+ return {
14835
+ kind: "named",
14836
+ tag
14837
+ };
14838
+ }
14839
+ /** Why a reply naming another review is refused, in the words the page shows. */
14840
+ function otherReviewRefusal(identity) {
14841
+ return `This reply names review ${identity.named.filter((named) => named !== identity.tag).join(" and ")}, but these findings are numbered for review ${identity.tag}. A fresh analysis numbers its findings again, so its references cannot be read against these ones. Nothing was reconciled. Prepare feedback again from this review, or reconcile the reply on the review it names.`;
14842
+ }
14843
+ /**
14844
+ * The answer, with every resolution checked against what it rests on (`groundResolution`). A check
14845
+ * that fails keeps no code evidence rather than unchecked evidence, so a code-based resolution then
14846
+ * falls to "needs investigation".
14847
+ */
14848
+ async function readReconcileTurn(raw, result, turns, where, check = existingPaths) {
14849
+ const reading = parseReplyReconciliation(raw, result, turns);
14850
+ if (!reading) return {
14851
+ reading: null,
14852
+ raw
14853
+ };
14854
+ const cited = [...new Set(reading.judgements.flatMap((judgement) => judgement.status === "resolved" ? judgement.evidence.map((place) => place.file) : []))];
14855
+ let existing = /* @__PURE__ */ new Set();
14856
+ if (cited.length) try {
14857
+ existing = await check(where.repoPath, cited, where.commitSha);
14858
+ } catch {
14859
+ existing = /* @__PURE__ */ new Set();
14860
+ }
14861
+ return {
14862
+ reading: {
14863
+ ...reading,
14864
+ judgements: reading.judgements.map((judgement) => groundResolution(judgement, existing))
14865
+ },
14866
+ raw
14867
+ };
14868
+ }
14869
+ function sameSuggestion(entry, axis, to) {
14870
+ return entry.axis === "status" === (axis === "status") && entry.to === to;
14871
+ }
14872
+ /** The exchanges a buddy-conversation entry read, or none for any other source. */
14873
+ function exchangesOf(source) {
14874
+ return source.kind === "buddy-conversation" ? source.exchanges : [];
14875
+ }
14876
+ /**
14877
+ * Whether this suggestion is already on the ledger for this finding, in a way that means it should
14878
+ * not be written again: offered on the card now (`isOffered`, the projection's own answer), or
14879
+ * declined by the reviewer on evidence that covered every turn this one read. A proposal still marked
14880
+ * proposed but outdated or overtaken is offered by no card, so it holds nothing back. A decline on
14881
+ * less evidence does not either: later turns are new evidence.
14882
+ */
14883
+ function heldBack(session, kind, findingId, axis, to, exchanges) {
14884
+ for (const entry of session.findingReassessments ?? []) {
14885
+ if (entry.kind !== kind || entry.findingId !== findingId || !sameSuggestion(entry, axis, to)) continue;
14886
+ if (isOffered(session, entry)) return {
14887
+ kind: "waiting",
14888
+ entry
14889
+ };
14890
+ if (entry.state === "declined") {
14891
+ const read = new Set(exchangesOf(entry.source));
14892
+ if (exchanges.every((askedAt) => read.has(askedAt))) return { kind: "declined-before" };
14893
+ }
14894
+ }
14895
+ return null;
14896
+ }
14897
+ /**
14898
+ * Writes each judgement's proposals onto the session, checked against it as it is now. Run inside
14899
+ * the store write on the fresh copy, so a finding the reviewer decided while Buddy AI was reading is
14900
+ * written against what they decided, or not at all.
14901
+ */
14902
+ function keepReconcileTurn(session, read, exchanges, askedAt) {
14903
+ const outcome = { judgements: [] };
14904
+ if (!read.reading) return outcome;
14905
+ const source = {
14906
+ kind: "buddy-conversation",
14907
+ exchanges: [...exchanges].sort((a, b) => a - b)
14908
+ };
14909
+ const analysis = analysisRefOf(session);
14910
+ for (const judgement of read.reading.judgements) {
14911
+ const finding = findingIn(session, judgement.kind, judgement.findingId);
14912
+ if (!finding) {
14913
+ outcome.judgements.push({
14914
+ judgement,
14915
+ status: { kind: "stale" },
14916
+ grade: null
14917
+ });
14918
+ continue;
14919
+ }
14920
+ const current = findingStatus(session.findingReassessments, judgement.kind, judgement.findingId, analysis).current;
14921
+ const to = verdictFor(judgement, current);
14922
+ let status;
14923
+ if (!to) status = { kind: "still-open" };
14924
+ else {
14925
+ const held = heldBack(session, judgement.kind, judgement.findingId, "status", to, source.exchanges);
14926
+ if (held) status = held;
14927
+ else {
14928
+ const write = reassessIn(session, {
14929
+ op: "propose-status",
14930
+ kind: judgement.kind,
14931
+ findingId: judgement.findingId,
14932
+ from: current,
14933
+ to,
14934
+ reason: judgement.reason,
14935
+ ...judgement.answer ? { answer: judgement.answer } : {},
14936
+ ...judgement.basis && to === "resolved" ? { basis: judgement.basis } : {},
14937
+ ...judgement.evidence.length ? { evidence: judgement.evidence } : {},
14938
+ quotes: judgement.quotes,
14939
+ source
14940
+ }, askedAt);
14941
+ status = write.ok ? {
14942
+ kind: "proposed",
14943
+ entry: write.entry
14944
+ } : { kind: "stale" };
14945
+ }
14946
+ }
14947
+ let grade = null;
14948
+ if (judgement.grade) {
14949
+ const from = findingAssessment(session.findingReassessments, judgement.kind, finding, analysis).current;
14950
+ grade = heldBack(session, judgement.kind, judgement.findingId, "grade", judgement.grade.to, source.exchanges);
14951
+ if (!grade) {
14952
+ const write = reassessIn(session, {
14953
+ op: "propose",
14954
+ kind: judgement.kind,
14955
+ findingId: judgement.findingId,
14956
+ from,
14957
+ to: judgement.grade.to,
14958
+ reason: judgement.grade.reason,
14959
+ source
14960
+ }, askedAt);
14961
+ grade = write.ok ? {
14962
+ kind: "proposed",
14963
+ entry: write.entry
14964
+ } : write.refusal === "unchanged" ? null : { kind: "stale" };
14965
+ }
14966
+ }
14967
+ outcome.judgements.push({
14968
+ judgement,
14969
+ status,
14970
+ grade
14971
+ });
14972
+ }
14973
+ return outcome;
14974
+ }
14975
+ /** The ids of every proposal the turn wrote, for the transcript and for Accept all. */
14976
+ function proposedIds(outcome) {
14977
+ return outcome.judgements.flatMap(({ status, grade }) => [status, grade].flatMap((result) => result && result.kind === "proposed" ? [result.entry.id] : []));
14978
+ }
14979
+ /** One judgement's line, as the conversation says it: always a suggestion, never a change made. */
14980
+ function statusPhrase(judgement, status) {
14981
+ const to = status.kind === "proposed" || status.kind === "waiting" ? status.entry.to : null;
14982
+ switch (status.kind) {
14983
+ case "still-open": return "still open after this reply";
14984
+ case "declined-before": return "not suggested again: you kept it as it was on the same evidence";
14985
+ case "stale": return "not suggested: it changed while this was being read";
14986
+ case "waiting":
14987
+ case "proposed": {
14988
+ const phrase = to === "answered" ? "may be answered" : to === "partially-answered" ? "may be partly answered" : to === "resolved" ? "may be resolved" : to === "needs-investigation" ? "needs further investigation" : to === "unanswered" ? "may no longer be answered: this reply contradicts what you accepted" : to === "open" ? "may no longer be resolved: this reply contradicts what you accepted" : null;
14989
+ if (!phrase) return null;
14990
+ return status.kind === "waiting" ? `${phrase} (already waiting on the card)` : phrase;
14991
+ }
14992
+ }
14993
+ }
14994
+ function plural(n, noun) {
14995
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
14996
+ }
14997
+ /** "3 questions may be answered · 1 issue may be resolved · 1 needs further investigation". */
14998
+ function headline(outcome) {
14999
+ const count = (kind, to) => outcome.judgements.filter(({ judgement, status }) => judgement.kind === kind && status.kind === "proposed" && status.entry.to === to).length;
15000
+ return [
15001
+ [count("question", "answered"), (n) => `${plural(n, "question")} may be answered`],
15002
+ [count("question", "partially-answered"), (n) => `${plural(n, "question")} may be partly answered`],
15003
+ [count("issue", "resolved"), (n) => `${plural(n, "issue")} may be resolved`],
15004
+ [count("issue", "needs-investigation"), (n) => `${n} ${n === 1 ? "needs" : "need"} further investigation`],
15005
+ [outcome.judgements.filter(({ status }) => status.kind === "proposed" && (status.entry.to === "open" || status.entry.to === "unanswered")).length, (n) => `${n} earlier ${n === 1 ? "decision" : "decisions"} contradicted`],
15006
+ [outcome.judgements.filter(({ grade }) => grade?.kind === "proposed").length, (n) => `${plural(n, "grade change")} suggested`]
15007
+ ].flatMap(([n, say]) => n > 0 ? [say(n)] : []).join(" · ");
15008
+ }
15009
+ /**
15010
+ * What the conversation shows for the turn: the suggestions, finding by finding, and nothing that
15011
+ * reads as a change made. Never the JSON. Findings the reply did not address are not mentioned,
15012
+ * because they were not judged.
15013
+ */
15014
+ function reconcileTurnText(read, outcome, identity) {
15015
+ if (!read.reading) return `${read.raw.trim()}\n\n*This answer was not in the form suggestions need, so nothing was suggested and no finding changed.*`;
15016
+ const lines = outcome.judgements.flatMap(({ judgement, status, grade }) => {
15017
+ const said = [];
15018
+ const phrase = statusPhrase(judgement, status);
15019
+ if (phrase) said.push(phrase);
15020
+ if (grade?.kind === "proposed" || grade?.kind === "waiting") said.push(`its ${judgement.kind === "issue" ? "severity" : "importance"} may change to ${grade.entry.to}`);
15021
+ return said.length ? [`- **${judgement.ref}** ${said.join("; ")}. ${judgement.reason}`] : [];
15022
+ });
15023
+ const unmatched = read.reading.unmatched.map((passage) => `- Could not tell which finding this answers${passage.candidates.length ? ` (${passage.candidates.join(" or ")})` : ""}, so none was changed: “${passage.text}”${passage.why ? ` ${passage.why}` : ""}`);
15024
+ const dropped = read.reading.discarded.unquoted + read.reading.discarded.unknownRef;
15025
+ const droppedLine = dropped ? [`*${dropped === 1 ? "One judgement was" : `${dropped} judgements were`} left out: ${dropped === 1 ? "it" : "each"} named no finding in this review, or quoted words that are not in the turns.*`] : [];
15026
+ const top = headline(outcome);
15027
+ const unnamedLine = identity?.kind === "unnamed" ? [`*This reply does not name review ${identity.tag}, so any I or Q reference in it was trusted only where what it says matches that finding.*`] : [];
15028
+ if (!lines.length && !unmatched.length) return [
15029
+ "**Reconciled with findings.** Nothing in these turns bears on the review’s issues or questions, so nothing is suggested.",
15030
+ ...unnamedLine,
15031
+ ...droppedLine
15032
+ ].join("\n\n");
15033
+ return [
15034
+ `**Reconciled with findings.**${top ? ` ${top}.` : ""}`,
15035
+ "These are suggestions. Nothing changes until you accept one on its card.",
15036
+ [...lines, ...unmatched].join("\n"),
15037
+ ...unnamedLine,
15038
+ ...droppedLine
15039
+ ].join("\n\n");
15040
+ }
15041
+ /** The turns as the prompt labels them. */
15042
+ function evidenceTurns(turns) {
15043
+ return labelTurns(turns);
15044
+ }
14147
15045
  //#endregion
14148
15046
  //#region ../../packages/review-harness/src/workspace/diagram.ts
14149
15047
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
@@ -15081,6 +15979,7 @@ async function handle(req, res, options) {
15081
15979
  if (url.pathname === "/runs/remove") return void handleRunRemoval(req, res, options.indexToken);
15082
15980
  if (url.pathname === "/runs/sections") return void handleRunsSections(req, res, options.indexToken);
15083
15981
  if (url.pathname === "/help") return void handleHelp(req, res, url.searchParams, options.indexToken);
15982
+ if (url.pathname === "/tour") return void handleTour(req, res, url.searchParams, options.indexToken);
15084
15983
  if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
15085
15984
  if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
15086
15985
  if (url.pathname === "/notifications/live") return handleLiveNotifications(req, res, url.searchParams, options.indexToken);
@@ -15341,10 +16240,30 @@ function handleHelp(req, res, params, indexToken) {
15341
16240
  unreadNotifications: unreadCount$1(),
15342
16241
  driving: isDriving(),
15343
16242
  theme: readPreferences().theme,
15344
- sidebar: readPreferences().sidebar
16243
+ sidebar: readPreferences().sidebar,
16244
+ tour: tourUnavailable(recentWorkspaces(Number.MAX_SAFE_INTEGER), busyReviewIds()) ?? "available",
16245
+ tourFailed: params.get("tour") === "unavailable"
15345
16246
  }), indexCookieHeader(indexToken));
15346
16247
  }
15347
16248
  /**
16249
+ * Reviews something is analysing or updating right now. The guided tour is not shown on these: what
16250
+ * it would point at is about to be replaced. An assistant answering, or a diagram being drawn, is
16251
+ * not one of these: the review under it stays as it is.
16252
+ */
16253
+ function busyReviewIds() {
16254
+ return new Set(runningJobs().map((running) => running.job).filter((job) => purposeOf(job) !== "ask").map((job) => job.workspaceId));
16255
+ }
16256
+ /**
16257
+ * Replay guided tour, from Help. Chosen when it is pressed rather than when Help was drawn, so a
16258
+ * review that started updating in between is not opened to a tour that cannot start. With nothing to
16259
+ * open, back to Help's Welcome, which says why.
16260
+ */
16261
+ function handleTour(req, res, params, indexToken) {
16262
+ if (!indexAuthorised(req, params, indexToken)) return refuseIndex(req, res);
16263
+ const review = chooseTourReview(recentWorkspaces(Number.MAX_SAFE_INTEGER), busyReviewIds());
16264
+ redirect(res, review ? `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}${TOUR_REPLAY_HASH}` : "/help?tour=unavailable#welcome");
16265
+ }
16266
+ /**
15348
16267
  * Whether the caller is a browser navigating, rather than something reading the API.
15349
16268
  *
15350
16269
  * `Accept` is the only signal available and browsers are consistent about it: a navigation asks
@@ -16314,6 +17233,8 @@ async function sendWorkspacePage(res, workspace) {
16314
17233
  dismissedNotices: readPreferences().dismissedNotices,
16315
17234
  promptsCollapsed: readPreferences().promptsCollapsed === true,
16316
17235
  reviewLayout: readPreferences().reviewLayout ?? {},
17236
+ guidedTour: readPreferences().guidedTour,
17237
+ tourBusy: busyReviewIds().has(workspace.id),
16317
17238
  changeMapRegister: readPreferences().changeMapRegister ?? "simple",
16318
17239
  architecture: await architecturePane(workspace),
16319
17240
  transcript: workspace.transcript,
@@ -16725,6 +17646,22 @@ async function handleAsk(req, res, workspace) {
16725
17646
  ...reassessed,
16726
17647
  from: currentGradeIn(workspace.session, reassessed.kind, reassessed.id) ?? null
16727
17648
  } : null;
17649
+ let reconcileTurns = null;
17650
+ let reconcileIdentity = null;
17651
+ if (body?.reconciles !== void 0 && !explains && !reassessed) {
17652
+ if (!workspace.session.result) return send(res, 409, { error: "This review has no findings to reconcile with yet." });
17653
+ const chosen = reconcileTurnsOf(workspace.transcript, body.reconciles);
17654
+ if (!chosen.ok) return send(res, 400, {
17655
+ error: TURNS_REFUSED[chosen.refusal],
17656
+ refusal: chosen.refusal
17657
+ });
17658
+ reconcileIdentity = reviewIdentityOf(chosen.turns, workspace.session.id);
17659
+ if (reconcileIdentity.kind === "other") return send(res, 409, {
17660
+ error: otherReviewRefusal(reconcileIdentity),
17661
+ refusal: "other-review"
17662
+ });
17663
+ reconcileTurns = evidenceTurns(chosen.turns);
17664
+ }
16728
17665
  if (liveUpdateFor(workspace.id)) return send(res, 409, {
16729
17666
  error: "This review is being updated. Ask again when the new review is ready.",
16730
17667
  updating: true
@@ -16765,6 +17702,13 @@ async function handleAsk(req, res, workspace) {
16765
17702
  ...explains ? { explaining: explains } : {},
16766
17703
  ...continuing ? { continuing } : {},
16767
17704
  ...reassessed ? { reassessing: reassessed } : {},
17705
+ ...reconcileTurns && reconcileIdentity ? { reconciling: {
17706
+ turns: reconcileTurns,
17707
+ review: {
17708
+ tag: reconcileIdentity.tag,
17709
+ named: reconcileIdentity.kind === "named"
17710
+ }
17711
+ } } : {},
16768
17712
  conversation: prepared
16769
17713
  });
16770
17714
  } catch (error) {
@@ -16787,21 +17731,39 @@ async function handleAsk(req, res, workspace) {
16787
17731
  commitSha: workspace.session.metadata.commitSha
16788
17732
  }) : null;
16789
17733
  let reassessOutcome = null;
17734
+ const reconcileRead = reconcileTurns && workspace.session.result ? await readReconcileTurn(answer, workspace.session.result, reconcileTurns, {
17735
+ repoPath: workspace.repoPath,
17736
+ commitSha: workspace.session.metadata.commitSha
17737
+ }) : null;
17738
+ let reconcileOutcome = null;
17739
+ let keptAt = 0;
16790
17740
  let explanation;
16791
17741
  let shown = answer;
16792
17742
  const saved = mutateWorkspace(workspace.id, (fresh) => {
16793
17743
  const askedAt = Date.now();
16794
17744
  const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
17745
+ keptAt = askedAt;
16795
17746
  if (turn && reassessing) {
16796
17747
  reassessOutcome = keepReassessTurn(fresh.session, reassessing, turn, askedAt);
16797
17748
  shown = reassessTurnText(reassessing, turn, reassessOutcome);
16798
17749
  }
17750
+ let reconciles;
17751
+ if (reconcileRead && reconcileTurns) {
17752
+ const exchanges = reconcileTurns.map((evidence) => evidence.askedAt);
17753
+ reconcileOutcome = keepReconcileTurn(fresh.session, reconcileRead, exchanges, askedAt);
17754
+ shown = reconcileTurnText(reconcileRead, reconcileOutcome, reconcileIdentity ?? void 0);
17755
+ reconciles = {
17756
+ exchanges,
17757
+ proposals: proposedIds(reconcileOutcome)
17758
+ };
17759
+ }
16799
17760
  fresh.transcript.push({
16800
17761
  question,
16801
17762
  answer: shown,
16802
17763
  sectionId,
16803
17764
  askedAt,
16804
- ...kept ? { explains: kept } : {}
17765
+ ...kept ? { explains: kept } : {},
17766
+ ...reconciles ? { reconciles } : {}
16805
17767
  });
16806
17768
  if (kept) {
16807
17769
  explanation = {
@@ -16817,13 +17779,23 @@ async function handleAsk(req, res, workspace) {
16817
17779
  outcome: reassessOutcome,
16818
17780
  ...card ? { card } : {}
16819
17781
  } } : {};
16820
- sendIfOpen(res, 200, explanation ? {
16821
- answer: shown,
16822
- explanation,
16823
- ...reassessment
16824
- } : {
17782
+ const reconciliation = reconcileOutcome && saved ? { reconciliation: {
17783
+ proposals: proposedIds(reconcileOutcome),
17784
+ cards: reconcileOutcome.judgements.flatMap(({ judgement }) => {
17785
+ const parts = gradeFragments(saved, judgement.kind, judgement.findingId);
17786
+ return parts ? [parts] : [];
17787
+ })
17788
+ } } : {};
17789
+ const turnRef = saved ? {
17790
+ askedAt: keptAt,
17791
+ ...reconcileOutcome ? { reconcileTurn: true } : {}
17792
+ } : {};
17793
+ sendIfOpen(res, 200, {
16825
17794
  answer: shown,
16826
- ...reassessment
17795
+ ...explanation ? { explanation } : {},
17796
+ ...reassessment,
17797
+ ...reconciliation,
17798
+ ...turnRef
16827
17799
  });
16828
17800
  } finally {
16829
17801
  endRun(run);
@@ -17119,6 +18091,7 @@ async function handlePrefs(req, res) {
17119
18091
  const layout = readReviewLayoutPatch(body?.reviewLayout);
17120
18092
  if (layout) writeReviewLayout(layout);
17121
18093
  if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
18094
+ if (isGuidedTourState(body?.guidedTour)) writeGuidedTour(body.guidedTour);
17122
18095
  if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
17123
18096
  send(res, 200, { ok: true });
17124
18097
  }
@@ -17233,7 +18206,8 @@ var ASK_SOURCES = [
17233
18206
  "free_text",
17234
18207
  "explain_simply",
17235
18208
  "selection",
17236
- "finding"
18209
+ "finding",
18210
+ "reconcile"
17237
18211
  ];
17238
18212
  function askSource(value) {
17239
18213
  return ASK_SOURCES.includes(value) ? value : "free_text";