prreviewbuddy 0.85.0 → 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, _ as runningJobs, _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)}"` : ""}>
@@ -2578,7 +2721,7 @@ function renderOverviewTab(session, iconUrl = "", options) {
2578
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) {
@@ -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.
@@ -5582,7 +5776,7 @@ function updateFacts(outcome) {
5582
5776
  counts.affected ? `${counts.affected} to look at again` : "",
5583
5777
  counts.questionsAnswered ? `${plural(counts.questionsAnswered, "question", "questions")} answered` : ""
5584
5778
  ].filter(Boolean);
5585
- 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`] : []];
5586
5780
  const basis = outcome.reassessed;
5587
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)];
5588
5782
  }
@@ -6271,7 +6465,7 @@ function renderReviewBody(view, sections) {
6271
6465
  ${renderRail(view, sections, updating(view))}
6272
6466
  <main class="prb-doc">
6273
6467
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
6274
- ${offersFeedback(view) ? renderFeedbackTray() : ""}
6468
+ ${offersFeedback(view) ? renderFeedbackTray(reviewTag(view.session.id)) : ""}
6275
6469
  </main>
6276
6470
  ${result ? renderAsk(view, sections, updating(view)) : ""}
6277
6471
  ${result ? renderAskTab() : ""}
@@ -6446,7 +6640,8 @@ function renderStandardViews(view, sections) {
6446
6640
  ${renderIssuesTab(session, null, {
6447
6641
  askBuddy: true,
6448
6642
  feedbackSelect: true,
6449
- reassess: true
6643
+ reassess: true,
6644
+ findingRefs: true
6450
6645
  })}
6451
6646
  </section>
6452
6647
  <section class="prb-view" data-view="questions" hidden>
@@ -6455,7 +6650,8 @@ function renderStandardViews(view, sections) {
6455
6650
  suppressFileJump: true,
6456
6651
  askBuddy: true,
6457
6652
  feedbackSelect: true,
6458
- reassess: true
6653
+ reassess: true,
6654
+ findingRefs: true
6459
6655
  })}
6460
6656
  </section>
6461
6657
  <section class="prb-view" data-view="prcontext" hidden>
@@ -6840,7 +7036,7 @@ function renderRail(view, sections, stale = false) {
6840
7036
  </div>
6841
7037
  ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
6842
7038
  ${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
6843
- ${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)}
6844
7040
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
6845
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")}`
6846
7042
  }) : "";
@@ -7002,7 +7198,7 @@ function totalBadge(count, noun) {
7002
7198
  * with all of them", and a tick would claim the second on a review that earned the first.
7003
7199
  */
7004
7200
  /** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
7005
- function plural$1(n, noun) {
7201
+ function plural$2(n, noun) {
7006
7202
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
7007
7203
  }
7008
7204
  function remainingBadge(counts, noun, verb) {
@@ -7012,16 +7208,16 @@ function remainingBadge(counts, noun, verb) {
7012
7208
  };
7013
7209
  if (counts.open === 0) return {
7014
7210
  text: "✓",
7015
- label: `All ${plural$1(counts.total, noun)} ${verb}`,
7211
+ label: `All ${plural$2(counts.total, noun)} ${verb}`,
7016
7212
  done: true
7017
7213
  };
7018
7214
  if (counts.done === 0) return {
7019
7215
  text: String(counts.open),
7020
- label: plural$1(counts.total, noun)
7216
+ label: plural$2(counts.total, noun)
7021
7217
  };
7022
7218
  return {
7023
7219
  text: String(counts.open),
7024
- 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}`
7025
7221
  };
7026
7222
  }
7027
7223
  /**
@@ -7045,9 +7241,9 @@ function findingsBadge(counts) {
7045
7241
  */
7046
7242
  function themeFindingsNote(counts) {
7047
7243
  if (counts.total === 0) return null;
7048
- if (counts.open === 0) return `all ${plural$1(counts.total, "issue")} ${doneVerb(counts)}`;
7049
- if (counts.done === 0) return plural$1(counts.open, "issue");
7050
- 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`;
7051
7247
  }
7052
7248
  /**
7053
7249
  * `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
@@ -7339,7 +7535,8 @@ function renderAsk(view, sections, paused = false) {
7339
7535
  ${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
7340
7536
  <div class="prb-ask-log" data-ask-log>
7341
7537
  ${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
7342
- <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" : ""}>
7343
7540
  <p>Ask about this change. Answers come only from the review, the diff and the repository on disk.</p>
7344
7541
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
7345
7542
  </div>`}
@@ -7361,6 +7558,12 @@ function renderAsk(view, sections, paused = false) {
7361
7558
  * The list is always present, even when empty: an empty bank with an Add button explains what
7362
7559
  * the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
7363
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
+ }
7364
7567
  function renderPromptBank(prompts, collapsed = false) {
7365
7568
  return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
7366
7569
  <div class="prb-bank-head">
@@ -8258,7 +8461,7 @@ function readPrStates() {
8258
8461
  if (parsed.version !== VERSION$2) return {};
8259
8462
  const entries = parsed.entries;
8260
8463
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8261
- return normalise$1(entries);
8464
+ return normalise$2(entries);
8262
8465
  } catch {
8263
8466
  return {};
8264
8467
  }
@@ -8283,7 +8486,7 @@ function prunePrStates(states, referenced) {
8283
8486
  * state with no time or a time with no state says nothing, and an unknown state must not be read
8284
8487
  * as one this build knows.
8285
8488
  */
8286
- function normalise$1(entries) {
8489
+ function normalise$2(entries) {
8287
8490
  const states = {};
8288
8491
  for (const [key, value] of Object.entries(entries)) {
8289
8492
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -8398,7 +8601,7 @@ function readWatchDocument() {
8398
8601
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return null;
8399
8602
  return {
8400
8603
  version,
8401
- entries: normalise(entries)
8604
+ entries: normalise$1(entries)
8402
8605
  };
8403
8606
  } catch {
8404
8607
  return null;
@@ -8411,7 +8614,7 @@ function readWatch() {
8411
8614
  if (parsed.version !== VERSION) return {};
8412
8615
  const entries = parsed.entries;
8413
8616
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8414
- return normalise(entries);
8617
+ return normalise$1(entries);
8415
8618
  } catch {
8416
8619
  return {};
8417
8620
  }
@@ -8452,7 +8655,7 @@ function pruneWatch(watched) {
8452
8655
  * `notifiedSha` that survived as a number would compare unequal to every real sha and announce on
8453
8656
  * every cycle for ever.
8454
8657
  */
8455
- function normalise(entries) {
8658
+ function normalise$1(entries) {
8456
8659
  const state = {};
8457
8660
  for (const [id, value] of Object.entries(entries)) {
8458
8661
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -11201,7 +11404,7 @@ function renderPrivacySection(settings) {
11201
11404
  * A record with no facts -- every one written before 0.74.0 -- gets no line, and its row looks as
11202
11405
  * it always did.
11203
11406
  */
11204
- 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}`;
11205
11408
  function shortSha(sha) {
11206
11409
  return sha.slice(0, 7);
11207
11410
  }
@@ -11213,15 +11416,15 @@ function headMoveLine(notification, facts) {
11213
11416
  const where = notification.source === "branch" ? "locally" : "on the pull request";
11214
11417
  const parts = [`${step(facts.fromSha, notification.headSha)} ${where}`];
11215
11418
  if (facts.rewritten) parts.push("branch rewritten since the review");
11216
- 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`);
11217
11420
  else parts.push("review behind");
11218
11421
  return parts.join(" · ");
11219
11422
  }
11220
11423
  /** The card's own words for what is left (`whatIsLeft`), so a row and a card never disagree in vocabulary. */
11221
11424
  function reviewLine(facts) {
11222
11425
  const parts = [];
11223
- if (facts.openIssues > 0) parts.push(plural(facts.openIssues, "issue"));
11224
- 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"));
11225
11428
  if (parts.length === 0) parts.push("Nothing flagged");
11226
11429
  if (facts.risk) parts.push(`${facts.risk} risk`);
11227
11430
  return parts.join(" · ");
@@ -11244,8 +11447,8 @@ function updateLine(facts) {
11244
11447
  const delta = updateDelta(facts);
11245
11448
  parts.push(...delta.length > 0 ? delta : ["findings unchanged"]);
11246
11449
  if (facts.stillOpen > 0) parts.push(`${facts.stillOpen} still open`);
11247
- if (facts.questionsAnswered > 0) parts.push(`${plural(facts.questionsAnswered, "question")} answered`);
11248
- 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"));
11249
11452
  if (facts.fetchFailed) parts.push("remote not reached");
11250
11453
  else if (facts.upToDate) parts.push("up to date");
11251
11454
  return parts.join(" · ");
@@ -11281,7 +11484,7 @@ function toastFact(notification) {
11281
11484
  return delta.length > 0 ? delta.slice(0, 2).join(", ") : "findings unchanged";
11282
11485
  }
11283
11486
  const facts = notification.facts;
11284
- 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";
11285
11488
  }
11286
11489
  /**
11287
11490
  * A toast's whole line: what happened, to what, and at most one fact.
@@ -11652,6 +11855,26 @@ feature: E</pre>
11652
11855
  <li><b>Delete</b> removes a review and the checkouts it was made in. See
11653
11856
  <a href="#reviews-and-ids">Reviews and review IDs</a>.</li>
11654
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>`
11655
11878
  },
11656
11879
  {
11657
11880
  id: "reviews-and-ids",
@@ -13769,6 +13992,7 @@ function gradeFragments$1(session, kind, id) {
13769
13992
  const finding = findingIn(session, kind, id);
13770
13993
  if (!finding) return null;
13771
13994
  const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
13995
+ const status = findingStatus(session.findingReassessments, kind, id, analysisRefOf(session));
13772
13996
  const options = {
13773
13997
  editable: true,
13774
13998
  askMore: true
@@ -13779,8 +14003,9 @@ function gradeFragments$1(session, kind, id) {
13779
14003
  current: assessment.current,
13780
14004
  cardClass: cardGradeClass(kind, assessment.current),
13781
14005
  grade: renderGradeControl(kind, assessment, options),
13782
- region: renderAssessmentRegion(kind, assessment, options),
13783
- 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)
13784
14009
  };
13785
14010
  }
13786
14011
  /** The finding a request is about: named outright, or through the proposal it decides. */
@@ -13862,7 +14087,7 @@ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
13862
14087
  //#endregion
13863
14088
  //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
13864
14089
  /** More than any real answer cites; a list longer than this is padding, not evidence. */
13865
- var MAX_EVIDENCE = 10;
14090
+ var MAX_EVIDENCE$1 = 10;
13866
14091
  function scaleText(kind) {
13867
14092
  return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
13868
14093
  }
@@ -13914,7 +14139,7 @@ function buildReassessInstructions(input) {
13914
14139
  ];
13915
14140
  }
13916
14141
  var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
13917
- function readEvidence(value) {
14142
+ function readEvidence$1(value) {
13918
14143
  if (!Array.isArray(value)) return [];
13919
14144
  const out = [];
13920
14145
  for (const item of value) {
@@ -13926,7 +14151,7 @@ function readEvidence(value) {
13926
14151
  file,
13927
14152
  line
13928
14153
  });
13929
- if (out.length === MAX_EVIDENCE) break;
14154
+ if (out.length === MAX_EVIDENCE$1) break;
13930
14155
  }
13931
14156
  return out;
13932
14157
  }
@@ -13947,7 +14172,7 @@ function parseReassessAnswer(raw, kind, current) {
13947
14172
  const obj = parsed;
13948
14173
  const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
13949
14174
  if (!reason) return null;
13950
- const evidence = readEvidence(obj.evidence);
14175
+ const evidence = readEvidence$1(obj.evidence);
13951
14176
  if (obj.verdict === "keep") return {
13952
14177
  verdict: "keep",
13953
14178
  reason,
@@ -14030,6 +14255,267 @@ function reassessTurnText(target, turn, outcome) {
14030
14255
  const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
14031
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.*`;
14032
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
+ }
14033
14519
  //#endregion
14034
14520
  //#region ../../packages/review-harness/src/workspace/ask.ts
14035
14521
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
@@ -14144,6 +14630,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14144
14630
  "REVIEW GUIDE (JSON):",
14145
14631
  JSON.stringify(workspace.session.result),
14146
14632
  ...reassessedGrades(workspace),
14633
+ ...acceptedStatuses(workspace),
14147
14634
  "---",
14148
14635
  "DIFF:",
14149
14636
  workspace.diff,
@@ -14161,6 +14648,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14161
14648
  ...continuingFrom(workspace, context.continuing),
14162
14649
  ...explainingFinding(workspace, context.explaining),
14163
14650
  ...reassessingFinding(workspace, context.reassessing),
14651
+ ...reconcilingTurns(workspace, context.reconciling),
14164
14652
  "---",
14165
14653
  `QUESTION: ${question}`
14166
14654
  ].join("\n");
@@ -14224,6 +14712,33 @@ function reassessedGrades(workspace) {
14224
14712
  ];
14225
14713
  }
14226
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
+ /**
14227
14742
  * Reassess on a finding: everything the judgement needs, and the exact shape of the answer. The
14228
14743
  * thread the analysis tied the finding to goes in when it resolves in the conversation this review
14229
14744
  * holds; when it does not, the turn goes ahead without it.
@@ -14243,12 +14758,290 @@ function reassessingFinding(workspace, reassessing) {
14243
14758
  ...thread ? { thread } : {}
14244
14759
  });
14245
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
+ }
14246
14777
  /** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
14247
14778
  function findingName(workspace, finding) {
14248
14779
  const result = workspace.session.result;
14249
14780
  const title = finding.kind === "issue" ? result?.issues.find((issue) => issue.id === finding.id)?.title : result?.questions.find((question) => question.id === finding.id)?.question;
14250
14781
  return `THE ${finding.kind.toUpperCase()}${title ? ` "${title.trim()}"` : ""}`;
14251
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
+ }
14252
15045
  //#endregion
14253
15046
  //#region ../../packages/review-harness/src/workspace/diagram.ts
14254
15047
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
@@ -16853,6 +17646,22 @@ async function handleAsk(req, res, workspace) {
16853
17646
  ...reassessed,
16854
17647
  from: currentGradeIn(workspace.session, reassessed.kind, reassessed.id) ?? null
16855
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
+ }
16856
17665
  if (liveUpdateFor(workspace.id)) return send(res, 409, {
16857
17666
  error: "This review is being updated. Ask again when the new review is ready.",
16858
17667
  updating: true
@@ -16893,6 +17702,13 @@ async function handleAsk(req, res, workspace) {
16893
17702
  ...explains ? { explaining: explains } : {},
16894
17703
  ...continuing ? { continuing } : {},
16895
17704
  ...reassessed ? { reassessing: reassessed } : {},
17705
+ ...reconcileTurns && reconcileIdentity ? { reconciling: {
17706
+ turns: reconcileTurns,
17707
+ review: {
17708
+ tag: reconcileIdentity.tag,
17709
+ named: reconcileIdentity.kind === "named"
17710
+ }
17711
+ } } : {},
16896
17712
  conversation: prepared
16897
17713
  });
16898
17714
  } catch (error) {
@@ -16915,21 +17731,39 @@ async function handleAsk(req, res, workspace) {
16915
17731
  commitSha: workspace.session.metadata.commitSha
16916
17732
  }) : null;
16917
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;
16918
17740
  let explanation;
16919
17741
  let shown = answer;
16920
17742
  const saved = mutateWorkspace(workspace.id, (fresh) => {
16921
17743
  const askedAt = Date.now();
16922
17744
  const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
17745
+ keptAt = askedAt;
16923
17746
  if (turn && reassessing) {
16924
17747
  reassessOutcome = keepReassessTurn(fresh.session, reassessing, turn, askedAt);
16925
17748
  shown = reassessTurnText(reassessing, turn, reassessOutcome);
16926
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
+ }
16927
17760
  fresh.transcript.push({
16928
17761
  question,
16929
17762
  answer: shown,
16930
17763
  sectionId,
16931
17764
  askedAt,
16932
- ...kept ? { explains: kept } : {}
17765
+ ...kept ? { explains: kept } : {},
17766
+ ...reconciles ? { reconciles } : {}
16933
17767
  });
16934
17768
  if (kept) {
16935
17769
  explanation = {
@@ -16945,13 +17779,23 @@ async function handleAsk(req, res, workspace) {
16945
17779
  outcome: reassessOutcome,
16946
17780
  ...card ? { card } : {}
16947
17781
  } } : {};
16948
- sendIfOpen(res, 200, explanation ? {
16949
- answer: shown,
16950
- explanation,
16951
- ...reassessment
16952
- } : {
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, {
16953
17794
  answer: shown,
16954
- ...reassessment
17795
+ ...explanation ? { explanation } : {},
17796
+ ...reassessment,
17797
+ ...reconciliation,
17798
+ ...turnRef
16955
17799
  });
16956
17800
  } finally {
16957
17801
  endRun(run);
@@ -17362,7 +18206,8 @@ var ASK_SOURCES = [
17362
18206
  "free_text",
17363
18207
  "explain_simply",
17364
18208
  "selection",
17365
- "finding"
18209
+ "finding",
18210
+ "reconcile"
17366
18211
  ];
17367
18212
  function askSource(value) {
17368
18213
  return ASK_SOURCES.includes(value) ? value : "free_text";