prreviewbuddy 0.85.0 → 0.86.6

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-CwKZQlMg.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";
@@ -497,13 +497,14 @@ function statusLabel(status) {
497
497
  return status === "in-progress" ? "Not reviewed" : "New";
498
498
  }
499
499
  /**
500
- * The bulk bar's way to tick everything the filters show, across pages: "Select all 7".
500
+ * The bulk bar's way to tick everything the filters show, across pages.
501
501
  *
502
- * Counts the cards in view, because that is what it ticks. With PR set to Merged this is how a
503
- * reviewer clears the merged reviews in one go, by way of the existing Mark reviewed.
502
+ * Counted ("Select all 7") only while the headline is not already saying "of 7", so the number is
503
+ * on the bar once. With PR set to Merged this is how a reviewer clears the merged reviews in one go,
504
+ * by way of the existing Mark reviewed.
504
505
  */
505
- function selectAllLabel(inView) {
506
- return inView === 0 ? "Select all" : `Select all ${inView}`;
506
+ function selectAllLabel(inView, scopeShown = false) {
507
+ return inView === 0 || scopeShown ? "Select all" : `Select all ${inView}`;
507
508
  }
508
509
  /** The PR filter's options, in order, as the select shows them. */
509
510
  var PR_FILTER_OPTIONS = [
@@ -553,6 +554,8 @@ var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
553
554
  var MESSAGE_SOURCE_ATTR = "data-copy-source";
554
555
  /** A finding's text for a feedback draft, on its feedback checkbox. Copy's text less the reviewer's own working. */
555
556
  var FEEDBACK_SOURCE_ATTR = "data-feedback-source";
557
+ /** The finding's readable reference (`I3`), beside its feedback text, where the host draws references. */
558
+ var FEEDBACK_REF_ATTR = "data-feedback-ref";
556
559
  var STATES$1 = {
557
560
  idle: {
558
561
  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 +708,8 @@ function renderFeedbackSelectButton() {
705
708
  * to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
706
709
  * counts are the way back to each pane.
707
710
  */
708
- function renderFeedbackTray() {
709
- return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
711
+ function renderFeedbackTray(reviewTag) {
712
+ 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
713
  <p class="prb-selection-count" aria-live="polite">
711
714
  <span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
712
715
  </p>
@@ -805,6 +808,12 @@ var PATHS = {
805
808
  warning: "<path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/><path d=\"M12 9v4M12 17h.01\"/>",
806
809
  forward: "<path d=\"M5 12h14\"/><path d=\"m12 5 7 7-7 7\"/>",
807
810
  plus: "<path d=\"M12 5v14M5 12h14\"/>",
811
+ "queue-add": "<path d=\"M3 6h12M3 12h9M3 18h9\"/><path d=\"M18 12v6M15 15h6\"/>",
812
+ folder: "<path d=\"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"/>",
813
+ "check-double": "<path d=\"m2 12 5 5L18 6\"/><path d=\"m13 15 2 2 7-11\"/>",
814
+ more: "<circle cx=\"5\" cy=\"12\" r=\"1\"/><circle cx=\"12\" cy=\"12\" r=\"1\"/><circle cx=\"19\" cy=\"12\" r=\"1\"/>",
815
+ trash: "<path d=\"M3 6h18\"/><path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\"/><path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"/><path d=\"M10 11v6M14 11v6\"/>",
816
+ close: "<path d=\"M18 6 6 18M6 6l12 12\"/>",
808
817
  "sidebar-collapse": "<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M9 4v16\"/><path d=\"m16 9-3 3 3 3\"/>",
809
818
  "sidebar-expand": "<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M9 4v16\"/><path d=\"m13 9 3 3-3 3\"/>"
810
819
  };
@@ -1300,6 +1309,63 @@ function renderFindingExplanation(explanation, options = {}) {
1300
1309
  </details>`;
1301
1310
  }
1302
1311
  //#endregion
1312
+ //#region ../../packages/review-harness/src/domain/ai_review/finding_ref.ts
1313
+ var PREFIX = {
1314
+ issue: "I",
1315
+ question: "Q"
1316
+ };
1317
+ function findingRef(kind, index) {
1318
+ return `${PREFIX[kind]}${index + 1}`;
1319
+ }
1320
+ /** Every finding's reference by its id, for a renderer that meets the findings out of order. */
1321
+ function findingRefs(result) {
1322
+ const refs = /* @__PURE__ */ new Map();
1323
+ result.issues.forEach((issue, index) => refs.set(`issue:${issue.id}`, findingRef("issue", index)));
1324
+ result.questions.forEach((question, index) => refs.set(`question:${question.id}`, findingRef("question", index)));
1325
+ return refs;
1326
+ }
1327
+ /** The reference of one finding, or undefined when the analysis does not hold it. */
1328
+ function refOf(refs, kind, id) {
1329
+ return refs.get(`${kind}:${id}`);
1330
+ }
1331
+ /**
1332
+ * The finding a reference names, or null. Only the exact form (`I3`, `q2` is accepted as `Q2`):
1333
+ * a reference out of range names nothing rather than the nearest finding.
1334
+ */
1335
+ function findingAtRef(result, ref) {
1336
+ const match = /^([IQ])([1-9]\d*)$/i.exec(ref.trim());
1337
+ if (!match) return null;
1338
+ const index = Number(match[2]) - 1;
1339
+ if (match[1].toUpperCase() === "I") {
1340
+ const finding = result.issues[index];
1341
+ return finding === void 0 ? null : {
1342
+ kind: "issue",
1343
+ index,
1344
+ finding
1345
+ };
1346
+ }
1347
+ const finding = result.questions[index];
1348
+ return finding === void 0 ? null : {
1349
+ kind: "question",
1350
+ index,
1351
+ finding
1352
+ };
1353
+ }
1354
+ /**
1355
+ * The review a set of references belongs to: `PRB-` and the first eight characters of the analysis's
1356
+ * session id. An update keeps the session, and so its references; a fresh analysis or a reanalysis
1357
+ * starts a new session and numbers its findings again, so its tag differs. A feedback draft carries
1358
+ * it, and Reconcile reads it back, so a reply to an older draft is never read against findings that
1359
+ * a later analysis happens to number the same way.
1360
+ */
1361
+ function reviewTag(sessionId) {
1362
+ return `PRB-${sessionId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase()}`;
1363
+ }
1364
+ /** Every review tag in a text, as `reviewTag` writes them. */
1365
+ function reviewTagsIn(text) {
1366
+ return [...new Set([...text.matchAll(/\bPRB-([a-z0-9]{8})\b/gi)].map((match) => `PRB-${match[1].toLowerCase()}`))];
1367
+ }
1368
+ //#endregion
1303
1369
  //#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
1304
1370
  /**
1305
1371
  * What every pill in the sidebar actually means, in one place.
@@ -2050,7 +2116,8 @@ function renderReassessmentLine(kind, assessment, now) {
2050
2116
  /** Who is suggesting it, by the proposal's source. */
2051
2117
  function proposer(source) {
2052
2118
  switch (source.kind) {
2053
- case "buddy": return "Buddy AI";
2119
+ case "buddy":
2120
+ case "buddy-conversation": return "Buddy AI";
2054
2121
  case "investigation": return "The repository investigation";
2055
2122
  case "update": return "The update";
2056
2123
  case "reviewer": return "The review";
@@ -2089,9 +2156,92 @@ function renderWaitingSuggestions(count) {
2089
2156
  if (count === 0) return "";
2090
2157
  return `<span class="pr-helper-closed-findings-waiting" data-closed-findings-waiting> ${count === 1 ? "1 suggestion waiting" : `${count} suggestions waiting`}</span>`;
2091
2158
  }
2159
+ /** A verdict as a card names it. */
2160
+ function verdictLabel$1(verdict) {
2161
+ switch (verdict) {
2162
+ case "resolved": return "Resolved";
2163
+ case "needs-investigation": return "Needs investigation";
2164
+ case "open": return "Open again";
2165
+ case "answered": return "Answered";
2166
+ case "partially-answered": return "Partly answered";
2167
+ case "unanswered": return "Unanswered again";
2168
+ }
2169
+ }
2170
+ /**
2171
+ * The words the evidence rests on, as the reviewer pasted them. Quoted, never paraphrased: they are
2172
+ * someone else's claim, and the card shows the claim rather than vouching for it.
2173
+ */
2174
+ function renderQuotes(quotes) {
2175
+ if (!quotes?.length) return "";
2176
+ return quotes.map((quote) => `<blockquote class="pr-helper-reassessment-quote">${renderInlineText(quote.text)}</blockquote>`).join("");
2177
+ }
2178
+ /** What a resolution rests on, said before the places that show it. Nothing on any other verdict. */
2179
+ function renderBasis(basis) {
2180
+ if (!basis) return "";
2181
+ 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>`;
2182
+ }
2183
+ function renderAnswer(answer) {
2184
+ return answer ? `<p class="pr-helper-reassessment-answer">${renderInlineText(answer)}</p>` : "";
2185
+ }
2186
+ function renderStatusStep(step, now) {
2187
+ return `<li class="pr-helper-reassessment-step">
2188
+ <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>
2189
+ <span class="pr-helper-reassessment-step-source">${escapeHtml$1(capitalise(sourcePhrase(step.source)))} · ${escapeHtml$1(formatRelativeTime(step.at, now))}</span>
2190
+ ${step.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(step.reason)}</p>` : ""}
2191
+ ${renderAnswer(step.answer)}
2192
+ ${renderQuotes(step.quotes)}
2193
+ ${renderBasis(step.basis)}
2194
+ ${renderEvidence$1(step.evidence)}
2195
+ </li>`;
2196
+ }
2197
+ /**
2198
+ * The status the reviewer accepted from later evidence, and History behind it. Says whether the
2199
+ * finding is still open, because an accepted status is not a closed one: a partial answer and an
2200
+ * issue that needs investigating stay in the work.
2201
+ */
2202
+ function renderStatusLine(status, now) {
2203
+ const current = status.current;
2204
+ if (!current) return "";
2205
+ const last = status.history[status.history.length - 1];
2206
+ const open = closesFinding(current) ? "" : " Still open.";
2207
+ return `<div class="pr-helper-reassessment pr-helper-finding-verdict" data-finding-verdict="${escapeHtml$1(current)}">
2208
+ <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>
2209
+ ${renderAnswer(last.answer)}
2210
+ <details class="pr-helper-reassessment-history">
2211
+ <summary class="pr-helper-reassessment-history-summary">Status history</summary>
2212
+ <ol class="pr-helper-reassessment-steps">${status.history.map((step) => renderStatusStep(step, now)).join("")}</ol>
2213
+ </details>
2214
+ </div>`;
2215
+ }
2216
+ /**
2217
+ * A status waiting on the reviewer. The same answers as a grade's, under the same attribute, so a
2218
+ * host's Accept and Keep need no second wiring: Accept applies exactly this verdict, Keep declines it.
2219
+ * A proposal that would take back an accepted closure says so first.
2220
+ */
2221
+ function renderStatusProposal(status, options) {
2222
+ const proposal = status.pending;
2223
+ if (!proposal) return "";
2224
+ const actions = options.editable ? `<div class="pr-helper-reassessment-actions">
2225
+ <button type="button" class="pr-helper-reassessment-accept" data-reassessment-decide="accepted">Accept</button>
2226
+ <button type="button" class="pr-helper-reassessment-keep" data-reassessment-decide="declined">Keep as it is</button>
2227
+ ${options.askMore ? "<button type=\"button\" class=\"pr-helper-reassessment-more\" data-reassessment-ask-more>Ask more →</button>" : ""}
2228
+ </div>` : "";
2229
+ 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>` : "";
2230
+ return `<div class="pr-helper-reassessment-proposal" data-reassessment-proposal="${escapeHtml$1(proposal.id)}" data-status-proposal="${escapeHtml$1(proposal.to)}">
2231
+ <p class="pr-helper-reassessment-proposal-title">${escapeHtml$1(proposer(proposal.source))} suggests: ${escapeHtml$1(verdictLabel$1(proposal.to))}</p>
2232
+ ${contradiction}
2233
+ ${renderAnswer(proposal.answer)}
2234
+ ${proposal.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(proposal.reason)}</p>` : ""}
2235
+ ${renderQuotes(proposal.quotes)}
2236
+ ${renderBasis(proposal.basis)}
2237
+ ${renderEvidence$1(proposal.evidence)}
2238
+ ${actions}
2239
+ </div>`;
2240
+ }
2092
2241
  /** 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>`;
2242
+ function renderAssessmentRegion(kind, assessment, options = {}, status) {
2243
+ const now = options.now ?? Date.now();
2244
+ 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
2245
  }
2096
2246
  //#endregion
2097
2247
  //#region ../../packages/review-harness/src/domain/ui/components/review_tabs.ts
@@ -2534,7 +2684,7 @@ function possibleReason(summary, hasDescription) {
2534
2684
  */
2535
2685
  function renderUnclearMotive(motivation, session, questions) {
2536
2686
  const intent = questions.find((q) => q.category === "intent");
2537
- const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? [])) : false;
2687
+ const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question) : false;
2538
2688
  const hide = (yes) => yes ? " hidden" : "";
2539
2689
  return `
2540
2690
  <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 +2728,7 @@ function renderOverviewTab(session, iconUrl = "", options) {
2578
2728
  <div class="pr-helper-overview-stats" data-tour="stats">
2579
2729
  ${renderOverviewStat(metrics.sections, "Themes", "guide")}
2580
2730
  ${renderIssuesStat(issuesOutstanding(result.issues, reviewerDispositions(session)))}
2581
- ${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
2731
+ ${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question), "Questions", "questions", "answered")}
2582
2732
  ${renderCoverageStat(session, metrics.files)}
2583
2733
  </div>
2584
2734
  ${renderSuggestedPath(result.sections, "Suggested review path")}
@@ -2621,13 +2771,13 @@ function discussionDetails(session) {
2621
2771
  if (reviewThreads > 0) {
2622
2772
  const outdated = stats.outdatedThreads ?? 0;
2623
2773
  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})`);
2774
+ parts.push(`${reviewThreads} ${plural$3(reviewThreads, "review thread")} (${state})`);
2625
2775
  }
2626
- if (conversation > 0) parts.push(`${conversation} ${plural$2(conversation, "conversation comment")}`);
2627
- if (decisions > 0) parts.push(`${decisions} ${plural$2(decisions, "review decision")}`);
2776
+ if (conversation > 0) parts.push(`${conversation} ${plural$3(conversation, "conversation comment")}`);
2777
+ if (decisions > 0) parts.push(`${decisions} ${plural$3(decisions, "review decision")}`);
2628
2778
  return parts;
2629
2779
  }
2630
- function plural$2(count, word) {
2780
+ function plural$3(count, word) {
2631
2781
  return count === 1 ? word : `${word}s`;
2632
2782
  }
2633
2783
  function renderExistingDiscussion(ref) {
@@ -3068,8 +3218,11 @@ function renderIssuesTab(session, investigatingId, options) {
3068
3218
  const dismissals = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
3069
3219
  const dispositions = reviewerDispositions(session);
3070
3220
  const assess = assessmentLookup(session);
3221
+ const statusOf = statusLookup(session);
3071
3222
  const grading = gradeOptions(options, now);
3223
+ const refs = options?.findingRefs ? findingRefs(result) : null;
3072
3224
  const card = (issue) => {
3225
+ const ref = refs ? refOf(refs, "issue", issue.id) : void 0;
3073
3226
  const isDismissed = dispositions.isDismissed(issue);
3074
3227
  const stale = dispositions.isStale(issue);
3075
3228
  const assessment = assess("issue", issue);
@@ -3077,7 +3230,8 @@ function renderIssuesTab(session, investigatingId, options) {
3077
3230
  <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
3231
  <div class="pr-helper-issue-header">
3079
3232
  <div class="pr-helper-issue-title-row">
3080
- ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue)) : ""}
3233
+ ${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue), ref) : ""}
3234
+ ${renderFindingRef(ref)}
3081
3235
  <p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
3082
3236
  ${renderFindingCopySource(issueMarkdown(issue, assessment), "issue")}
3083
3237
  ${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
@@ -3092,7 +3246,7 @@ function renderIssuesTab(session, investigatingId, options) {
3092
3246
  </div>
3093
3247
  </div>
3094
3248
  ${renderStaleDismissal(stale)}
3095
- ${renderAssessmentRegion("issue", assessment, grading)}
3249
+ ${renderAssessmentRegion("issue", assessment, grading, statusOf("issue", issue.id))}
3096
3250
  <p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(explanationOf(session, "issue", issue.id), options)}
3097
3251
  ${renderReconciliationNote(issue.reconciliationNote)}
3098
3252
  ${renderStatusHistory(issue.statusHistory, now)}
@@ -3103,7 +3257,7 @@ function renderIssuesTab(session, investigatingId, options) {
3103
3257
  ${renderInvestigationProvenance("issue", issue.id, records, now)}
3104
3258
  <div class="pr-helper-issue-actions">
3105
3259
  ${renderInvestigationActions("issue", issue.id, records, investigatingId)}
3106
- ${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id))}
3260
+ ${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id), dispositions.isClosedByVerdict(issue))}
3107
3261
  </div>
3108
3262
  </div>
3109
3263
  `;
@@ -3113,7 +3267,7 @@ function renderIssuesTab(session, investigatingId, options) {
3113
3267
  const open = result.issues.filter((issue) => isIssueOutstanding(issue, dispositions)).map(card).join("");
3114
3268
  const closedIssues = result.issues.filter((issue) => !isIssueOutstanding(issue, dispositions));
3115
3269
  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)}`;
3270
+ 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
3271
  }
3118
3272
  /**
3119
3273
  * What to call the closed pile, by what is actually in it.
@@ -3130,7 +3284,9 @@ function closedFindingsSummary(counts) {
3130
3284
  if (byConversation === counts.done) return `Resolved by the conversation (${counts.done})`;
3131
3285
  return `Resolved (${counts.done})`;
3132
3286
  }
3133
- if (byCommits === 0) return `Dismissed by you (${counts.done})`;
3287
+ const byVerdict = counts.byVerdict ?? 0;
3288
+ if (byCommits === 0 && byVerdict === 0) return `Dismissed by you (${counts.done})`;
3289
+ if (byCommits === 0 && byVerdict === counts.done) return `Accepted as resolved (${counts.done})`;
3134
3290
  return `Dealt with (${counts.done})`;
3135
3291
  }
3136
3292
  /**
@@ -3169,11 +3325,11 @@ function renderStaleDismissal(stale) {
3169
3325
  * toggle is. A reviewer agreeing with a fix changes no count, and a control that vanishes on some
3170
3326
  * cards is harder to explain than one that occasionally changes nothing.
3171
3327
  */
3172
- function renderDismissToggle(issue, isDismissed, stale, dismissal) {
3328
+ function renderDismissToggle(issue, isDismissed, stale, dismissal, closedByVerdict = false) {
3173
3329
  const label = stale ? "Dismiss again" : isDismissed ? "Reopen" : "Dismiss";
3174
3330
  const settled = issue.reconciliationStatus === "resolved";
3175
3331
  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>`;
3332
+ 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
3333
  }
3178
3334
  /**
3179
3335
  * The group an Intent question lives in, keyed like a theme's so the workspace client finds its
@@ -3185,19 +3341,21 @@ var INTENT_QUESTION_GROUP = "prb-intent";
3185
3341
  function questionHome(q) {
3186
3342
  return q.category === "intent" ? INTENT_QUESTION_GROUP : q.sectionId;
3187
3343
  }
3188
- function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options, explanation, assessment = ANALYSIS_ONLY("question", q)) {
3344
+ 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
3345
  const grading = gradeOptions(options, now);
3190
3346
  const importanceBadge = renderGradeControl("question", assessment, grading);
3191
3347
  const jumpLink = renderQuestionJump(q.file, options);
3192
3348
  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>`;
3349
+ const verdictClosed = closesFinding(status.current);
3350
+ 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
3351
  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
3352
  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
3353
  return `
3197
3354
  <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
3355
  <div class="pr-helper-issue-header">
3199
3356
  <div class="pr-helper-issue-title-row">
3200
- ${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q)) : ""}
3357
+ ${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q), ref) : ""}
3358
+ ${renderFindingRef(ref)}
3201
3359
  <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
3202
3360
  ${renderFindingCopySource(questionMarkdown(q, assessment), "question")}
3203
3361
  ${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
@@ -3214,7 +3372,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
3214
3372
  ${importanceBadge}
3215
3373
  </div>
3216
3374
  </div>
3217
- ${renderAssessmentRegion("question", assessment, grading)}
3375
+ ${renderAssessmentRegion("question", assessment, grading, status)}
3218
3376
  ${q.whyThisMatters ? `<h5>Why this matters</h5><p class="pr-helper-issue-desc">${renderInlineText(q.whyThisMatters)}</p>` : ""}${renderCardExplanation(explanation, options)}
3219
3377
  ${reconciliationAnswer}
3220
3378
  ${renderReconciliationNote(q.reconciliationNote)}
@@ -3245,8 +3403,8 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
3245
3403
  function questionsProgressBody(total, outstanding) {
3246
3404
  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
3405
  }
3248
- function renderQuestionsProgress(questions, answered) {
3249
- const counts = questionsOutstanding(questions, answered);
3406
+ function renderQuestionsProgress(questions, answered, closedByVerdict) {
3407
+ const counts = questionsOutstanding(questions, answered, closedByVerdict);
3250
3408
  if (counts.total < 2) return "";
3251
3409
  return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
3252
3410
  }
@@ -3261,8 +3419,13 @@ function renderQuestionsProgress(questions, answered) {
3261
3419
  * It carries the text the card gives a feedback draft, which is narrower than its Copy: see
3262
3420
  * finding_copy.ts.
3263
3421
  */
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()}`)}">`;
3422
+ function renderFeedbackPick(kind, title, feedback, ref) {
3423
+ const refAttr = ref ? ` ${FEEDBACK_REF_ATTR}="${ref}"` : "";
3424
+ 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()}`)}">`;
3425
+ }
3426
+ /** The card's readable reference, ahead of its title. Drawn only where the host asked for references. */
3427
+ function renderFindingRef(ref) {
3428
+ return ref ? `<span class="pr-helper-finding-ref" data-finding-ref="${ref}">${ref}</span>` : "";
3266
3429
  }
3267
3430
  function renderQuestionJump(file, options) {
3268
3431
  if (!file || options?.suppressFileJump) return "";
@@ -3279,14 +3442,17 @@ function renderQuestionsTab(session, investigatingId, options) {
3279
3442
  if (result.questions.length === 0) return emptyState("No open questions.");
3280
3443
  const sections = buildSectionLookup(result);
3281
3444
  const answered = new Set(session.answeredQuestionIds ?? []);
3282
- const progress = renderQuestionsProgress(result.questions, answered);
3445
+ const progress = renderQuestionsProgress(result.questions, answered, verdictClosure(session).question);
3283
3446
  const records = session.investigations;
3284
3447
  const now = Date.now();
3285
3448
  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);
3449
+ const statusOf = statusLookup(session);
3450
+ const closedByVerdict = verdictClosure(session).question;
3451
+ const refs = options?.findingRefs ? findingRefs(result) : null;
3452
+ 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));
3453
+ const open = result.questions.filter((q) => isQuestionOutstanding(q, answered, closedByVerdict));
3454
+ const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered, closedByVerdict));
3455
+ 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
3456
  const intent = open.filter((q) => q.category === "intent");
3291
3457
  const intentHtml = result.questions.some((q) => q.category === "intent") ? `
3292
3458
  <div class="pr-helper-question-group" data-question-group="${INTENT_QUESTION_GROUP}"${intent.length ? "" : " hidden"}>
@@ -3474,6 +3640,41 @@ function renderThemePicker(sections, selectedId) {
3474
3640
  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
3641
  }
3476
3642
  //#endregion
3643
+ //#region ../../packages/review-harness/src/domain/ui/components/turn_actions.ts
3644
+ /**
3645
+ * What a finished Buddy AI turn offers under its answer, drawn the same by the page and by the client
3646
+ * on a live answer, so a reload never changes it.
3647
+ *
3648
+ * Every completed turn offers Reconcile with findings. Always, rather than when a turn looks like an
3649
+ * author's reply: whether a turn bears on the findings is the judgement the reconcile itself makes,
3650
+ * and a control that appears only sometimes is one a reviewer cannot count on. A reconcile turn
3651
+ * offers Accept all instead, while any suggestion it made is still waiting.
3652
+ */
3653
+ /**
3654
+ * The supporting sentence, said beside the action. It names the scope, because the action reads only
3655
+ * the message of the turn it sits under: a reply split across several messages is reconciled one
3656
+ * message at a time (the route takes several turns; choosing them on the page is not built yet).
3657
+ */
3658
+ var RECONCILE_HELP = "Reassesses existing issues and questions using only your message in this turn.";
3659
+ function renderTurnActions(turn, waiting = []) {
3660
+ if (turn.reconciles) return renderAcceptAll(waiting);
3661
+ return `<div class="prb-turn-actions" data-turn-actions="${turn.askedAt}">
3662
+ <button type="button" class="prb-turn-reconcile" data-reconcile-turn="${turn.askedAt}">Reconcile with findings</button>
3663
+ <span class="prb-turn-help">${escapeHtml$1(RECONCILE_HELP)}</span>
3664
+ </div>`;
3665
+ }
3666
+ /**
3667
+ * Accept every suggestion a reconcile made that is still waiting, each on its own card exactly as its
3668
+ * own Accept would. Nothing when none waits.
3669
+ */
3670
+ function renderAcceptAll(waiting) {
3671
+ if (!waiting.length) return "";
3672
+ const label = waiting.length === 1 ? "Accept the suggestion" : `Accept all ${waiting.length} suggestions`;
3673
+ return `<div class="prb-turn-actions">
3674
+ <button type="button" class="prb-turn-accept-all" data-reconcile-accept-all="${escapeHtml$1(waiting.join(" "))}">${escapeHtml$1(label)}</button>
3675
+ </div>`;
3676
+ }
3677
+ //#endregion
3477
3678
  //#region ../../packages/review-harness/src/workspace/diagram_view.ts
3478
3679
  /**
3479
3680
  * The diagrams a review holds, drawn as cards in one scrolling pane.
@@ -5582,7 +5783,7 @@ function updateFacts(outcome) {
5582
5783
  counts.affected ? `${counts.affected} to look at again` : "",
5583
5784
  counts.questionsAnswered ? `${plural(counts.questionsAnswered, "question", "questions")} answered` : ""
5584
5785
  ].filter(Boolean);
5585
- const grades = counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [];
5786
+ 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
5787
  const basis = outcome.reassessed;
5587
5788
  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
5789
  }
@@ -6271,7 +6472,7 @@ function renderReviewBody(view, sections) {
6271
6472
  ${renderRail(view, sections, updating(view))}
6272
6473
  <main class="prb-doc">
6273
6474
  ${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
6274
- ${offersFeedback(view) ? renderFeedbackTray() : ""}
6475
+ ${offersFeedback(view) ? renderFeedbackTray(reviewTag(view.session.id)) : ""}
6275
6476
  </main>
6276
6477
  ${result ? renderAsk(view, sections, updating(view)) : ""}
6277
6478
  ${result ? renderAskTab() : ""}
@@ -6446,7 +6647,8 @@ function renderStandardViews(view, sections) {
6446
6647
  ${renderIssuesTab(session, null, {
6447
6648
  askBuddy: true,
6448
6649
  feedbackSelect: true,
6449
- reassess: true
6650
+ reassess: true,
6651
+ findingRefs: true
6450
6652
  })}
6451
6653
  </section>
6452
6654
  <section class="prb-view" data-view="questions" hidden>
@@ -6455,7 +6657,8 @@ function renderStandardViews(view, sections) {
6455
6657
  suppressFileJump: true,
6456
6658
  askBuddy: true,
6457
6659
  feedbackSelect: true,
6458
- reassess: true
6660
+ reassess: true,
6661
+ findingRefs: true
6459
6662
  })}
6460
6663
  </section>
6461
6664
  <section class="prb-view" data-view="prcontext" hidden>
@@ -6840,7 +7043,7 @@ function renderRail(view, sections, stale = false) {
6840
7043
  </div>
6841
7044
  ${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
6842
7045
  ${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)}
7046
+ ${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered, verdictClosure(view.session).question), "question", "answered"), void 0, void 0, true)}
6844
7047
  ${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
6845
7048
  ${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
7049
  }) : "";
@@ -7002,7 +7205,7 @@ function totalBadge(count, noun) {
7002
7205
  * with all of them", and a tick would claim the second on a review that earned the first.
7003
7206
  */
7004
7207
  /** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
7005
- function plural$1(n, noun) {
7208
+ function plural$2(n, noun) {
7006
7209
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
7007
7210
  }
7008
7211
  function remainingBadge(counts, noun, verb) {
@@ -7012,16 +7215,16 @@ function remainingBadge(counts, noun, verb) {
7012
7215
  };
7013
7216
  if (counts.open === 0) return {
7014
7217
  text: "✓",
7015
- label: `All ${plural$1(counts.total, noun)} ${verb}`,
7218
+ label: `All ${plural$2(counts.total, noun)} ${verb}`,
7016
7219
  done: true
7017
7220
  };
7018
7221
  if (counts.done === 0) return {
7019
7222
  text: String(counts.open),
7020
- label: plural$1(counts.total, noun)
7223
+ label: plural$2(counts.total, noun)
7021
7224
  };
7022
7225
  return {
7023
7226
  text: String(counts.open),
7024
- label: `${counts.open} of ${plural$1(counts.total, noun)} left, ${counts.done} ${verb}`
7227
+ label: `${counts.open} of ${plural$2(counts.total, noun)} left, ${counts.done} ${verb}`
7025
7228
  };
7026
7229
  }
7027
7230
  /**
@@ -7045,9 +7248,9 @@ function findingsBadge(counts) {
7045
7248
  */
7046
7249
  function themeFindingsNote(counts) {
7047
7250
  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`;
7251
+ if (counts.open === 0) return `all ${plural$2(counts.total, "issue")} ${doneVerb(counts)}`;
7252
+ if (counts.done === 0) return plural$2(counts.open, "issue");
7253
+ return `${counts.open} of ${plural$2(counts.total, "issue")} left`;
7051
7254
  }
7052
7255
  /**
7053
7256
  * `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
@@ -7298,6 +7501,7 @@ function renderPromptDialog() {
7298
7501
  <textarea data-prompt-text rows="5" maxlength="2000" placeholder="Check this change for N+1 database queries."></textarea>
7299
7502
  </label>
7300
7503
  <p class="prb-dialog-reassure">Saved prompts are yours and stay on this machine. They are offered in every review, and on any text you select.</p>
7504
+ <p class="prb-prompt-error" data-prompt-error role="alert" hidden></p>
7301
7505
  <div class="prb-dialog-actions">
7302
7506
  <button type="button" class="prb-dialog-danger" data-prompt-delete hidden>Delete</button>
7303
7507
  <span class="prb-dialog-spacer"></span>
@@ -7339,7 +7543,8 @@ function renderAsk(view, sections, paused = false) {
7339
7543
  ${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
7340
7544
  <div class="prb-ask-log" data-ask-log>
7341
7545
  ${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" : ""}>
7546
+ <div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>
7547
+ ${paused ? "" : renderTurnActions(exchange, waitingProposals(view.session, exchange.reconciles?.proposals))}`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
7343
7548
  <p>Ask about this change. Answers come only from the review, the diff and the repository on disk.</p>
7344
7549
  ${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
7345
7550
  </div>`}
@@ -7361,6 +7566,12 @@ function renderAsk(view, sections, paused = false) {
7361
7566
  * The list is always present, even when empty: an empty bank with an Add button explains what
7362
7567
  * the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
7363
7568
  */
7569
+ /** Of the suggestions a reconcile made, the ones still waiting on the reviewer on this analysis. */
7570
+ function waitingProposals(session, ids) {
7571
+ if (!ids?.length) return [];
7572
+ const wanted = new Set(ids);
7573
+ return (session.findingReassessments ?? []).filter((entry) => wanted.has(entry.id) && isOffered(session, entry)).map((entry) => entry.id);
7574
+ }
7364
7575
  function renderPromptBank(prompts, collapsed = false) {
7365
7576
  return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
7366
7577
  <div class="prb-bank-head">
@@ -8258,7 +8469,7 @@ function readPrStates() {
8258
8469
  if (parsed.version !== VERSION$2) return {};
8259
8470
  const entries = parsed.entries;
8260
8471
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8261
- return normalise$1(entries);
8472
+ return normalise$2(entries);
8262
8473
  } catch {
8263
8474
  return {};
8264
8475
  }
@@ -8283,7 +8494,7 @@ function prunePrStates(states, referenced) {
8283
8494
  * state with no time or a time with no state says nothing, and an unknown state must not be read
8284
8495
  * as one this build knows.
8285
8496
  */
8286
- function normalise$1(entries) {
8497
+ function normalise$2(entries) {
8287
8498
  const states = {};
8288
8499
  for (const [key, value] of Object.entries(entries)) {
8289
8500
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -8398,7 +8609,7 @@ function readWatchDocument() {
8398
8609
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return null;
8399
8610
  return {
8400
8611
  version,
8401
- entries: normalise(entries)
8612
+ entries: normalise$1(entries)
8402
8613
  };
8403
8614
  } catch {
8404
8615
  return null;
@@ -8411,7 +8622,7 @@ function readWatch() {
8411
8622
  if (parsed.version !== VERSION) return {};
8412
8623
  const entries = parsed.entries;
8413
8624
  if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
8414
- return normalise(entries);
8625
+ return normalise$1(entries);
8415
8626
  } catch {
8416
8627
  return {};
8417
8628
  }
@@ -8452,7 +8663,7 @@ function pruneWatch(watched) {
8452
8663
  * `notifiedSha` that survived as a number would compare unequal to every real sha and announce on
8453
8664
  * every cycle for ever.
8454
8665
  */
8455
- function normalise(entries) {
8666
+ function normalise$1(entries) {
8456
8667
  const state = {};
8457
8668
  for (const [id, value] of Object.entries(entries)) {
8458
8669
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
@@ -10186,34 +10397,52 @@ function renderOperation(operation, review, positions, prStates, now, collection
10186
10397
  * The bulk bar, with its counts left out.
10187
10398
  *
10188
10399
  * Same division as the pager: every word is written here, where the house style tests read it, and
10189
- * the two labels that carry a number are filled in by the browser because only the browser knows
10190
- * what is ticked. It ships hidden and complete rather than being built on demand, so the sentence
10400
+ * the labels that carry a number are filled in by the browser because only the browser knows what
10401
+ * is ticked. It ships hidden and complete rather than being built on demand, so the sentence
10191
10402
  * somebody is asked to agree to before deleting a dozen reviews is not assembled in a client bundle.
10192
10403
  *
10193
- * Marking comes before deleting, and the two are separated: this bar is mostly a workflow control
10194
- * and occasionally a destructive one, and the destructive button should never be the one a hand
10195
- * lands on by default.
10404
+ * Two rows. The head says what is selected and holds the ways to change that: Select all, and the
10405
+ * way out (the X, which the command layer gives its Esc). The actions sit under it, and only once
10406
+ * something is ticked (`data-empty`, written by the client), so an empty bar is a sentence and two
10407
+ * controls rather than a row of disabled buttons.
10408
+ *
10409
+ * In the actions, one filled button for the move most selections are made for, the two other
10410
+ * common ones beside it, and the rest under More, which is the shared menu (`menu.ts`). Mark done
10411
+ * is one of the two and Mark reviewed is under More: reviewed is mostly marked on the review
10412
+ * itself, and finishing several at once is what this list is for. Delete is
10413
+ * last and set apart: this bar is mostly a workflow control and occasionally a destructive one, and
10414
+ * the destructive button should never be the one a hand lands on by default.
10196
10415
  *
10197
10416
  * The confirmation is a second state of the same bar, which is how the card menus already ask.
10198
10417
  */
10199
10418
  function renderBulkBar() {
10200
- return `<div class="prb-bulk" data-bulk hidden role="region" aria-label="Selected reviews">
10201
- <p class="prb-bulk-count" data-bulk-count aria-live="polite"></p>
10202
- <div class="prb-bulk-actions" data-bulk-actions>
10419
+ const DONE = LIFECYCLES.find((lifecycle) => lifecycle.id === "done");
10420
+ const icon = (name) => `<span class="prb-bulk-icon" aria-hidden="true">${iconSvg(name)}</span>`;
10421
+ return `<div class="prb-bulk" data-bulk data-empty hidden role="region" aria-label="Selected reviews">
10422
+ <div class="prb-bulk-head">
10423
+ <p class="prb-bulk-status" aria-live="polite"><span class="prb-bulk-count" data-bulk-count></span> <span class="prb-bulk-detail" data-bulk-detail></span></p>
10203
10424
  <button type="button" class="prb-bulk-button" data-bulk-select-all>${escapeHtml$1(selectAllLabel(0))}</button>
10425
+ <button type="button" class="prb-bulk-exit" data-bulk-exit aria-label="Exit selection">${icon("close")}</button>
10426
+ </div>
10427
+ <div class="prb-bulk-actions" data-bulk-actions>
10428
+ <button type="button" class="prb-bulk-button prb-bulk-primary" data-bulk-queue disabled>${icon("queue-add")}Add to queue</button>
10429
+ <button type="button" class="prb-bulk-button" data-bulk-collections disabled>${icon("folder")}Collections</button>
10430
+ <button type="button" class="prb-bulk-button" data-bulk-lifecycle="${DONE.id}">${icon("check-double")}${escapeHtml$1(DONE.action)}</button>
10204
10431
  <button type="button" class="prb-bulk-button" data-bulk-fetch hidden>${escapeHtml$1(FETCH_SELECTED_LABEL)}</button>
10205
- <button type="button" class="prb-bulk-button" data-bulk-queue disabled>Add to review queue</button>
10206
- <button type="button" class="prb-bulk-button" data-bulk-collections disabled>Manage collections</button>
10207
- <button type="button" class="prb-bulk-button" data-bulk-mark="complete">${markItemLabel("complete")}</button>
10208
- <button type="button" class="prb-bulk-button" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
10209
- ${LIFECYCLES.map((lifecycle) => `<button type="button" class="prb-bulk-button" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
10210
- <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete></button>
10211
- <button type="button" class="prb-bulk-button prb-bulk-cancel" data-bulk-cancel>Cancel</button>
10432
+ <div class="prb-menu prb-bulk-more" data-menu data-bulk-more>
10433
+ <button type="button" class="prb-bulk-button" data-menu-trigger aria-expanded="false" aria-haspopup="true">${icon("more")}More</button>
10434
+ <div class="prb-menu-panel" data-menu-panel hidden>
10435
+ <button type="button" class="prb-menu-item" data-bulk-mark="complete">${markItemLabel("complete")}</button>
10436
+ <button type="button" class="prb-menu-item" data-bulk-mark="in-progress">${markItemLabel("in-progress")}</button>
10437
+ ${LIFECYCLES.filter((lifecycle) => lifecycle !== DONE).map((lifecycle) => `<button type="button" class="prb-menu-item" data-bulk-lifecycle="${lifecycle.id}">${escapeHtml$1(lifecycle.action)}</button>`).join("\n ")}
10438
+ </div>
10439
+ </div>
10440
+ <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-delete>${icon("trash")}<span data-bulk-delete-label></span></button>
10212
10441
  </div>
10213
10442
  <div class="prb-bulk-confirm" data-bulk-confirm hidden>
10214
10443
  <p class="prb-bulk-question" data-bulk-question></p>
10215
10444
  <p class="prb-bulk-note">This cannot be undone. Your branches and their commits are untouched.</p>
10216
- <div class="prb-bulk-actions">
10445
+ <div class="prb-bulk-confirm-actions">
10217
10446
  <button type="button" class="prb-bulk-button prb-bulk-button-danger" data-bulk-confirm-delete>Delete</button>
10218
10447
  <button type="button" class="prb-bulk-button" data-bulk-cancel-delete>Cancel</button>
10219
10448
  </div>
@@ -11201,7 +11430,7 @@ function renderPrivacySection(settings) {
11201
11430
  * A record with no facts -- every one written before 0.74.0 -- gets no line, and its row looks as
11202
11431
  * it always did.
11203
11432
  */
11204
- var plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
11433
+ var plural$1 = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
11205
11434
  function shortSha(sha) {
11206
11435
  return sha.slice(0, 7);
11207
11436
  }
@@ -11213,15 +11442,15 @@ function headMoveLine(notification, facts) {
11213
11442
  const where = notification.source === "branch" ? "locally" : "on the pull request";
11214
11443
  const parts = [`${step(facts.fromSha, notification.headSha)} ${where}`];
11215
11444
  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`);
11445
+ else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural$1(facts.behind, "commit")} behind`);
11217
11446
  else parts.push("review behind");
11218
11447
  return parts.join(" · ");
11219
11448
  }
11220
11449
  /** The card's own words for what is left (`whatIsLeft`), so a row and a card never disagree in vocabulary. */
11221
11450
  function reviewLine(facts) {
11222
11451
  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"));
11452
+ if (facts.openIssues > 0) parts.push(plural$1(facts.openIssues, "issue"));
11453
+ if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
11225
11454
  if (parts.length === 0) parts.push("Nothing flagged");
11226
11455
  if (facts.risk) parts.push(`${facts.risk} risk`);
11227
11456
  return parts.join(" · ");
@@ -11244,8 +11473,8 @@ function updateLine(facts) {
11244
11473
  const delta = updateDelta(facts);
11245
11474
  parts.push(...delta.length > 0 ? delta : ["findings unchanged"]);
11246
11475
  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"));
11476
+ if (facts.questionsAnswered > 0) parts.push(`${plural$1(facts.questionsAnswered, "question")} answered`);
11477
+ if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
11249
11478
  if (facts.fetchFailed) parts.push("remote not reached");
11250
11479
  else if (facts.upToDate) parts.push("up to date");
11251
11480
  return parts.join(" · ");
@@ -11281,7 +11510,7 @@ function toastFact(notification) {
11281
11510
  return delta.length > 0 ? delta.slice(0, 2).join(", ") : "findings unchanged";
11282
11511
  }
11283
11512
  const facts = notification.facts;
11284
- return facts.openIssues > 0 ? plural(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
11513
+ return facts.openIssues > 0 ? plural$1(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
11285
11514
  }
11286
11515
  /**
11287
11516
  * A toast's whole line: what happened, to what, and at most one fact.
@@ -11652,6 +11881,26 @@ feature: E</pre>
11652
11881
  <li><b>Delete</b> removes a review and the checkouts it was made in. See
11653
11882
  <a href="#reviews-and-ids">Reviews and review IDs</a>.</li>
11654
11883
  </ul>`
11884
+ },
11885
+ {
11886
+ id: "author-replies",
11887
+ title: "Asking the author",
11888
+ body: `<p>Every issue and question has a reference, such as <b>I3</b> or <b>Q2</b>, shown on its
11889
+ card. <b>Prepare feedback</b> on Issues or Questions turns the ones you choose into a draft to send
11890
+ the author, each led by its reference, so the author can answer “Q2: …”.</p>
11891
+ <p>When the reply comes back, paste it into Buddy AI as one message and press <b>Reconcile with
11892
+ findings</b> under it. Only that message is read. Buddy AI reads it against the review and
11893
+ suggests what it settles: a question answered or partly answered, an issue resolved, or one that
11894
+ needs investigating. Each suggestion appears on its card with the words it rests on, and nothing
11895
+ changes until you accept it.</p>
11896
+ <p>The draft ends with the review it came from. A reply naming a different review, such as one
11897
+ analysed afresh since, is not reconciled, because its references number different findings.</p>
11898
+ <p>Buddy AI is asked to read the code before suggesting an issue is resolved, and to cite the lines
11899
+ that show it. The review checks that the cited files exist, not that the code fixes anything, so
11900
+ read the cited lines before you accept. A fix the author has not pushed, or has only promised, is
11901
+ never suggested as resolved. A partly answered question and an issue that needs investigating stay
11902
+ open. If a later reply or new commits contradict an answer you accepted, you are asked again
11903
+ rather than the answer being undone.</p>`
11655
11904
  },
11656
11905
  {
11657
11906
  id: "reviews-and-ids",
@@ -11670,8 +11919,9 @@ feature: E</pre>
11670
11919
  id: "collections",
11671
11920
  title: "Collections",
11672
11921
  body: `<p>Collections help you organise related reviews, such as a refactor, release or feature.</p>
11673
- <p>Create a collection from the Reviews page, then add reviews individually or select several and
11674
- use <b>Manage collections</b>. A review can belong to multiple collections.</p>
11922
+ <p>Create a collection from the Reviews page, then add reviews individually with <b>Manage
11923
+ collections</b> in a review's menu, or select several and use <b>Collections</b> in the selection
11924
+ bar. A review can belong to multiple collections.</p>
11675
11925
  <p>Collections show reviews that still need attention. When you mark a review <b>Done</b>, it moves
11676
11926
  under <b>Show completed</b> within its collection, without losing its membership.</p>
11677
11927
  <p>You can collapse collections to stay focused, and expand them when you're ready to continue.
@@ -13769,6 +14019,7 @@ function gradeFragments$1(session, kind, id) {
13769
14019
  const finding = findingIn(session, kind, id);
13770
14020
  if (!finding) return null;
13771
14021
  const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
14022
+ const status = findingStatus(session.findingReassessments, kind, id, analysisRefOf(session));
13772
14023
  const options = {
13773
14024
  editable: true,
13774
14025
  askMore: true
@@ -13779,8 +14030,9 @@ function gradeFragments$1(session, kind, id) {
13779
14030
  current: assessment.current,
13780
14031
  cardClass: cardGradeClass(kind, assessment.current),
13781
14032
  grade: renderGradeControl(kind, assessment, options),
13782
- region: renderAssessmentRegion(kind, assessment, options),
13783
- copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
14033
+ region: renderAssessmentRegion(kind, assessment, options, status),
14034
+ copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment),
14035
+ verdictClosed: closesFinding(status.current)
13784
14036
  };
13785
14037
  }
13786
14038
  /** The finding a request is about: named outright, or through the proposal it decides. */
@@ -13862,7 +14114,7 @@ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
13862
14114
  //#endregion
13863
14115
  //#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
13864
14116
  /** More than any real answer cites; a list longer than this is padding, not evidence. */
13865
- var MAX_EVIDENCE = 10;
14117
+ var MAX_EVIDENCE$1 = 10;
13866
14118
  function scaleText(kind) {
13867
14119
  return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
13868
14120
  }
@@ -13914,7 +14166,7 @@ function buildReassessInstructions(input) {
13914
14166
  ];
13915
14167
  }
13916
14168
  var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
13917
- function readEvidence(value) {
14169
+ function readEvidence$1(value) {
13918
14170
  if (!Array.isArray(value)) return [];
13919
14171
  const out = [];
13920
14172
  for (const item of value) {
@@ -13926,7 +14178,7 @@ function readEvidence(value) {
13926
14178
  file,
13927
14179
  line
13928
14180
  });
13929
- if (out.length === MAX_EVIDENCE) break;
14181
+ if (out.length === MAX_EVIDENCE$1) break;
13930
14182
  }
13931
14183
  return out;
13932
14184
  }
@@ -13947,7 +14199,7 @@ function parseReassessAnswer(raw, kind, current) {
13947
14199
  const obj = parsed;
13948
14200
  const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
13949
14201
  if (!reason) return null;
13950
- const evidence = readEvidence(obj.evidence);
14202
+ const evidence = readEvidence$1(obj.evidence);
13951
14203
  if (obj.verdict === "keep") return {
13952
14204
  verdict: "keep",
13953
14205
  reason,
@@ -14030,6 +14282,267 @@ function reassessTurnText(target, turn, outcome) {
14030
14282
  const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
14031
14283
  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
14284
  }
14285
+ /** More than any finding a real reply rests on; past this it is padding. */
14286
+ var MAX_QUOTES = 4;
14287
+ var MAX_EVIDENCE = 10;
14288
+ /** The turns, labelled in the order the reviewer wrote them. */
14289
+ function labelTurns(turns) {
14290
+ return [...turns].sort((a, b) => a.askedAt - b.askedAt).map((turn, index) => ({
14291
+ label: `A${index + 1}`,
14292
+ askedAt: turn.askedAt,
14293
+ text: turn.question
14294
+ }));
14295
+ }
14296
+ function issueLine(issue, index, assess, status) {
14297
+ const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
14298
+ const severity = ` [severity: ${promptGrade(assess("issue", issue))}]`;
14299
+ 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 ")}`;
14300
+ }
14301
+ function questionLine(question, index, assess, status, ticked) {
14302
+ const importance = assess("question", question).current ? ` [importance: ${promptGrade(assess("question", question))}]` : "";
14303
+ const where = question.file ? ` [${question.file}]` : "";
14304
+ const why = question.whyThisMatters?.trim() ? `\n ${question.whyThisMatters.trim()}` : "";
14305
+ const prior = question.reconciliationStatus === "answered" ? "answered by the code" : ticked ? "marked answered by the reviewer" : void 0;
14306
+ return `${findingRef("question", index)}.${importance}${where}${priorStatus(prior)}${acceptedLine(status("question", question.id))} ${question.question.trim()}${why}`;
14307
+ }
14308
+ function priorStatus(text) {
14309
+ return text ? ` [${text}]` : "";
14310
+ }
14311
+ /**
14312
+ * The verdict the reviewer accepted earlier, said as their decision and not as a fact, with what it
14313
+ * rested on, so evidence that contradicts it is recognised as a contradiction.
14314
+ */
14315
+ function acceptedLine(status) {
14316
+ if (!status.current) return "";
14317
+ const last = status.history[status.history.length - 1];
14318
+ return ` [the reviewer accepted "${status.current}" ${sourcePhrase(last.source)}${last.reason ? `: ${last.reason}` : ""}]`;
14319
+ }
14320
+ var SHAPE = `{
14321
+ "findings": [
14322
+ {
14323
+ "ref": string,
14324
+ "status": "answered" | "partially-answered" | "still-open" | "resolved" | "needs-investigation",
14325
+ "reason": string,
14326
+ "answer"?: string,
14327
+ "basis"?: "premise" | "code",
14328
+ "evidence"?: [{ "file": string, "line"?: number }],
14329
+ "quotes": [{ "turn": string, "text": string }],
14330
+ "severity"?: ${gradeScale("issue").map((grade) => `"${grade}"`).join(" | ")},
14331
+ "importance"?: ${gradeScale("question").map((grade) => `"${grade}"`).join(" | ")},
14332
+ "gradeReason"?: string
14333
+ }
14334
+ ],
14335
+ "unmatched": [{ "turn": string, "text": string, "candidates": string[], "why": string }]
14336
+ }`;
14337
+ /** The block a reconcile turn adds to Buddy AI's prompt: the turns, the findings, the rules and the shape. */
14338
+ function buildReplyReconciliationInstructions(input) {
14339
+ const { result, assess, status } = input;
14340
+ const issues = result.issues.length ? result.issues.map((issue, index) => issueLine(issue, index, assess, status)).join("\n") : "(The review raised no issues.)";
14341
+ 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.)";
14342
+ return [
14343
+ "---",
14344
+ "RECONCILE WITH FINDINGS",
14345
+ "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.",
14346
+ "",
14347
+ "THE TURNS (the reviewer's own messages, labelled; Buddy AI's answers are not evidence):",
14348
+ ...input.turns.map((turn) => `[${turn.label}]\n${turn.text.trim()}`),
14349
+ "",
14350
+ 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.`,
14351
+ "",
14352
+ "THE FINDINGS, by the reference the review's cards and its feedback drafts use:",
14353
+ "Issues:",
14354
+ issues,
14355
+ "Questions:",
14356
+ questions,
14357
+ "",
14358
+ "RULES",
14359
+ "- 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.",
14360
+ "- 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.",
14361
+ "- 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.",
14362
+ "- What an author says about an issue is one of three things, and they are judged differently:",
14363
+ " 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.",
14364
+ " 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.",
14365
+ " 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\".",
14366
+ "- \"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\".",
14367
+ "- Treat what the author asserts as context, not proof. Where you can check a claim in the repository, check it before relying on it.",
14368
+ "- 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.",
14369
+ "- 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.",
14370
+ `- "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".`,
14371
+ "- 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.",
14372
+ "",
14373
+ "Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:",
14374
+ SHAPE,
14375
+ "\"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.",
14376
+ "Empty arrays are valid and far better than judgements the turns do not support."
14377
+ ];
14378
+ }
14379
+ var QUESTION_STATUSES = [
14380
+ "answered",
14381
+ "partially-answered",
14382
+ "still-open"
14383
+ ];
14384
+ var ISSUE_STATUSES = [
14385
+ "resolved",
14386
+ "needs-investigation",
14387
+ "still-open"
14388
+ ];
14389
+ var ANSWER = objectWithAnyOf("findings", "unmatched");
14390
+ /** Whitespace and quote marks as a model may retype them: two texts are the same words if these agree. */
14391
+ function normalise(text) {
14392
+ return text.replace(/[‘’]/g, "'").replace(/[“”]/g, "\"").replace(/\s+/g, " ").trim();
14393
+ }
14394
+ /** The quotes found, word for word, in the turns they name. Nothing else is kept. */
14395
+ function verifiedQuotes(value, turns) {
14396
+ if (!Array.isArray(value)) return [];
14397
+ const byLabel = new Map(turns.map((turn) => [turn.label.toUpperCase(), turn]));
14398
+ const kept = [];
14399
+ for (const item of value) {
14400
+ const quote = item;
14401
+ if (typeof quote?.turn !== "string" || typeof quote.text !== "string") continue;
14402
+ const turn = byLabel.get(quote.turn.trim().replace(/^\[|\]$/g, "").toUpperCase());
14403
+ const text = normalise(quote.text);
14404
+ if (!turn || !text || text.length > 600) continue;
14405
+ if (!normalise(turn.text).includes(text)) continue;
14406
+ if (kept.some((existing) => existing.askedAt === turn.askedAt && existing.text === text)) continue;
14407
+ kept.push({
14408
+ askedAt: turn.askedAt,
14409
+ text
14410
+ });
14411
+ if (kept.length === MAX_QUOTES) break;
14412
+ }
14413
+ return kept;
14414
+ }
14415
+ function readEvidence(value) {
14416
+ if (!Array.isArray(value)) return [];
14417
+ return value.flatMap((item) => {
14418
+ const place = item;
14419
+ if (typeof place?.file !== "string" || !place.file.trim()) return [];
14420
+ const line = typeof place.line === "number" && Number.isInteger(place.line) && place.line > 0 ? place.line : void 0;
14421
+ return [{
14422
+ file: place.file.trim(),
14423
+ ...line ? { line } : {}
14424
+ }];
14425
+ }).slice(0, MAX_EVIDENCE);
14426
+ }
14427
+ function text(value) {
14428
+ return typeof value === "string" ? value.trim() : "";
14429
+ }
14430
+ /**
14431
+ * The answer, read strictly. Null when it is not an answer at all; otherwise every judgement that
14432
+ * names a finding the review holds, with a status on that finding's scale, a reason, and at least one
14433
+ * quote found in the turns. A judgement missing any of those is counted and dropped, never repaired.
14434
+ */
14435
+ function parseReplyReconciliation(raw, result, turns) {
14436
+ let parsed;
14437
+ try {
14438
+ parsed = JSON.parse(extractJsonText(raw, ANSWER));
14439
+ } catch {
14440
+ return null;
14441
+ }
14442
+ if (!ANSWER(parsed)) return null;
14443
+ const obj = parsed;
14444
+ const discarded = {
14445
+ unknownRef: 0,
14446
+ unquoted: 0
14447
+ };
14448
+ const judgements = [];
14449
+ const seen = /* @__PURE__ */ new Set();
14450
+ for (const item of Array.isArray(obj.findings) ? obj.findings : []) {
14451
+ const entry = item;
14452
+ const at = typeof entry?.ref === "string" ? findingAtRef(result, entry.ref) : null;
14453
+ if (!at) {
14454
+ discarded.unknownRef += 1;
14455
+ continue;
14456
+ }
14457
+ const allowed = at.kind === "issue" ? ISSUE_STATUSES : QUESTION_STATUSES;
14458
+ const status = entry.status;
14459
+ const reason = text(entry.reason);
14460
+ if (typeof status !== "string" || !allowed.includes(status) || !reason) {
14461
+ discarded.unknownRef += 1;
14462
+ continue;
14463
+ }
14464
+ const quotes = verifiedQuotes(entry.quotes, turns);
14465
+ if (!quotes.length) {
14466
+ discarded.unquoted += 1;
14467
+ continue;
14468
+ }
14469
+ const key = `${at.kind}:${at.index}`;
14470
+ if (seen.has(key)) continue;
14471
+ seen.add(key);
14472
+ const answer = text(entry.answer);
14473
+ const basis = entry.basis === "premise" || entry.basis === "code" ? entry.basis : void 0;
14474
+ const gradeTo = at.kind === "issue" ? entry.severity : entry.importance;
14475
+ const gradeReason = text(entry.gradeReason);
14476
+ judgements.push({
14477
+ kind: at.kind,
14478
+ index: at.index,
14479
+ findingId: at.finding.id,
14480
+ ref: findingRef(at.kind, at.index),
14481
+ status,
14482
+ reason,
14483
+ ...answer ? { answer } : {},
14484
+ ...basis ? { basis } : {},
14485
+ evidence: readEvidence(entry.evidence),
14486
+ quotes,
14487
+ ...isGradeFor(at.kind, gradeTo) && gradeReason ? { grade: {
14488
+ to: gradeTo,
14489
+ reason: gradeReason
14490
+ } } : {}
14491
+ });
14492
+ }
14493
+ return {
14494
+ judgements,
14495
+ unmatched: (Array.isArray(obj.unmatched) ? obj.unmatched : []).flatMap((item) => {
14496
+ const passage = item;
14497
+ const words = text(passage?.text);
14498
+ if (!words) return [];
14499
+ const candidates = (Array.isArray(passage.candidates) ? passage.candidates : []).filter((ref) => typeof ref === "string" && findingAtRef(result, ref) !== null).map((ref) => ref.trim().toUpperCase());
14500
+ return [{
14501
+ text: words.slice(0, 600),
14502
+ candidates,
14503
+ why: text(passage.why)
14504
+ }];
14505
+ }),
14506
+ discarded
14507
+ };
14508
+ }
14509
+ /** Said beside a resolution the harness could not let stand as one. */
14510
+ 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.";
14511
+ /**
14512
+ * A resolution, checked against what it says it rests on, as far as the harness can check it.
14513
+ *
14514
+ * `premise` stands as the model judged it. `code` stands only with at least one cited place, with a
14515
+ * line, in a file that exists at the reviewed commit (`existing` is the set of paths found there).
14516
+ * Anything else is downgraded to "needs investigation", with the reason saying so.
14517
+ *
14518
+ * This is citation validation, not verification: a file that exists does not show that the code in it
14519
+ * fixes anything. Whether it does is the model's reading, which the prompt requires it to make by
14520
+ * opening the code, and the reviewer's to confirm before accepting. The card says as much.
14521
+ */
14522
+ function groundResolution(judgement, existing) {
14523
+ if (judgement.kind !== "issue" || judgement.status !== "resolved") return judgement;
14524
+ const evidence = judgement.evidence.filter((place) => existing.has(place.file));
14525
+ if (judgement.basis === "premise") return {
14526
+ ...judgement,
14527
+ evidence
14528
+ };
14529
+ if (judgement.basis === "code" && evidence.some((place) => place.line !== void 0)) return {
14530
+ ...judgement,
14531
+ evidence
14532
+ };
14533
+ return {
14534
+ ...judgement,
14535
+ status: "needs-investigation",
14536
+ evidence,
14537
+ reason: `${judgement.reason} ${UNSUPPORTED_RESOLUTION}`
14538
+ };
14539
+ }
14540
+ /** A proposal a judgement becomes on the status axis, or null when it is not one. */
14541
+ function verdictFor(judgement, current) {
14542
+ if (judgement.status !== "still-open") return judgement.status;
14543
+ if (!closesFinding(current)) return null;
14544
+ return judgement.kind === "issue" ? "open" : "unanswered";
14545
+ }
14033
14546
  //#endregion
14034
14547
  //#region ../../packages/review-harness/src/workspace/ask.ts
14035
14548
  /** Long enough for a real look at the repository; short enough that a hang is not forever. */
@@ -14144,6 +14657,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14144
14657
  "REVIEW GUIDE (JSON):",
14145
14658
  JSON.stringify(workspace.session.result),
14146
14659
  ...reassessedGrades(workspace),
14660
+ ...acceptedStatuses(workspace),
14147
14661
  "---",
14148
14662
  "DIFF:",
14149
14663
  workspace.diff,
@@ -14161,6 +14675,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
14161
14675
  ...continuingFrom(workspace, context.continuing),
14162
14676
  ...explainingFinding(workspace, context.explaining),
14163
14677
  ...reassessingFinding(workspace, context.reassessing),
14678
+ ...reconcilingTurns(workspace, context.reconciling),
14164
14679
  "---",
14165
14680
  `QUESTION: ${question}`
14166
14681
  ].join("\n");
@@ -14224,6 +14739,33 @@ function reassessedGrades(workspace) {
14224
14739
  ];
14225
14740
  }
14226
14741
  /**
14742
+ * The statuses the reviewer has accepted from later evidence, such as an author's reply. Listed only
14743
+ * when there are some, as the reviewer's decisions rather than facts, so a turn neither re-asks a
14744
+ * question they have accepted an answer to nor treats that answer as beyond question.
14745
+ */
14746
+ function acceptedStatuses(workspace) {
14747
+ const result = workspace.session.result;
14748
+ if (!result) return [];
14749
+ const status = statusLookup(workspace.session);
14750
+ const lines = [...result.issues.map((issue) => ({
14751
+ name: `ISSUE "${issue.title.trim()}"`,
14752
+ status: status("issue", issue.id)
14753
+ })), ...result.questions.map((q) => ({
14754
+ name: `QUESTION "${q.question.trim()}"`,
14755
+ status: status("question", q.id)
14756
+ }))].flatMap(({ name, status: s }) => {
14757
+ if (!s.current) return [];
14758
+ const last = s.history[s.history.length - 1];
14759
+ return [`- ${name}: accepted as ${s.current}${last.reason ? ` (${last.reason})` : ""}${last.answer ? `. Answer: ${last.answer}` : ""}`];
14760
+ });
14761
+ if (!lines.length) return [];
14762
+ return [
14763
+ "---",
14764
+ "STATUSES THE REVIEWER HAS ACCEPTED FROM LATER EVIDENCE (their decisions, which later evidence may still contradict):",
14765
+ ...lines
14766
+ ];
14767
+ }
14768
+ /**
14227
14769
  * Reassess on a finding: everything the judgement needs, and the exact shape of the answer. The
14228
14770
  * thread the analysis tied the finding to goes in when it resolves in the conversation this review
14229
14771
  * holds; when it does not, the turn goes ahead without it.
@@ -14243,12 +14785,290 @@ function reassessingFinding(workspace, reassessing) {
14243
14785
  ...thread ? { thread } : {}
14244
14786
  });
14245
14787
  }
14788
+ /**
14789
+ * Reconcile with findings: the chosen turns, every finding by its reference with the grade and any
14790
+ * verdict the review holds now, the evidence rules and the exact shape of the answer.
14791
+ */
14792
+ function reconcilingTurns(workspace, reconciling) {
14793
+ const result = workspace.session.result;
14794
+ if (!reconciling || !result) return [];
14795
+ return buildReplyReconciliationInstructions({
14796
+ result,
14797
+ assess: assessmentLookup(workspace.session),
14798
+ status: statusLookup(workspace.session),
14799
+ answeredQuestionIds: new Set(workspace.session.answeredQuestionIds ?? []),
14800
+ turns: reconciling.turns,
14801
+ review: reconciling.review
14802
+ });
14803
+ }
14246
14804
  /** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
14247
14805
  function findingName(workspace, finding) {
14248
14806
  const result = workspace.session.result;
14249
14807
  const title = finding.kind === "issue" ? result?.issues.find((issue) => issue.id === finding.id)?.title : result?.questions.find((question) => question.id === finding.id)?.question;
14250
14808
  return `THE ${finding.kind.toUpperCase()}${title ? ` "${title.trim()}"` : ""}`;
14251
14809
  }
14810
+ var TURNS_REFUSED = {
14811
+ none: "Choose at least one turn of this conversation to reconcile with the findings.",
14812
+ unknown: "This conversation has no such turn to reconcile.",
14813
+ "too-many": `Reconcile reads at most 10 turns at once. Choose fewer.`,
14814
+ "too-long": "Those turns are too long to read in one reconcile. Choose fewer, or paste the reply in parts."
14815
+ };
14816
+ /**
14817
+ * The turns a request names, from the transcript as stored. Only the reviewer's own turns with words
14818
+ * in them: a reconcile turn is not evidence, and neither is anything Buddy AI wrote.
14819
+ */
14820
+ function reconcileTurnsOf(transcript, value) {
14821
+ const asked = value?.exchanges;
14822
+ if (!Array.isArray(asked) || asked.length === 0) return {
14823
+ ok: false,
14824
+ refusal: "none"
14825
+ };
14826
+ const wanted = [...new Set(asked)];
14827
+ if (wanted.length > 10) return {
14828
+ ok: false,
14829
+ refusal: "too-many"
14830
+ };
14831
+ const turns = [];
14832
+ for (const askedAt of wanted) {
14833
+ const turn = typeof askedAt === "number" ? transcript.find((exchange) => exchange.askedAt === askedAt) : void 0;
14834
+ if (!turn || turn.reconciles || !turn.question.trim()) return {
14835
+ ok: false,
14836
+ refusal: "unknown"
14837
+ };
14838
+ turns.push(turn);
14839
+ }
14840
+ if (turns.reduce((total, turn) => total + turn.question.length, 0) > 4e4) return {
14841
+ ok: false,
14842
+ refusal: "too-long"
14843
+ };
14844
+ return {
14845
+ ok: true,
14846
+ turns
14847
+ };
14848
+ }
14849
+ function reviewIdentityOf(turns, sessionId) {
14850
+ const tag = reviewTag(sessionId);
14851
+ const named = [...new Set(turns.flatMap((turn) => reviewTagsIn(turn.question)))];
14852
+ if (!named.length) return {
14853
+ kind: "unnamed",
14854
+ tag
14855
+ };
14856
+ if (named.some((other) => other !== tag)) return {
14857
+ kind: "other",
14858
+ tag,
14859
+ named
14860
+ };
14861
+ return {
14862
+ kind: "named",
14863
+ tag
14864
+ };
14865
+ }
14866
+ /** Why a reply naming another review is refused, in the words the page shows. */
14867
+ function otherReviewRefusal(identity) {
14868
+ 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.`;
14869
+ }
14870
+ /**
14871
+ * The answer, with every resolution checked against what it rests on (`groundResolution`). A check
14872
+ * that fails keeps no code evidence rather than unchecked evidence, so a code-based resolution then
14873
+ * falls to "needs investigation".
14874
+ */
14875
+ async function readReconcileTurn(raw, result, turns, where, check = existingPaths) {
14876
+ const reading = parseReplyReconciliation(raw, result, turns);
14877
+ if (!reading) return {
14878
+ reading: null,
14879
+ raw
14880
+ };
14881
+ const cited = [...new Set(reading.judgements.flatMap((judgement) => judgement.status === "resolved" ? judgement.evidence.map((place) => place.file) : []))];
14882
+ let existing = /* @__PURE__ */ new Set();
14883
+ if (cited.length) try {
14884
+ existing = await check(where.repoPath, cited, where.commitSha);
14885
+ } catch {
14886
+ existing = /* @__PURE__ */ new Set();
14887
+ }
14888
+ return {
14889
+ reading: {
14890
+ ...reading,
14891
+ judgements: reading.judgements.map((judgement) => groundResolution(judgement, existing))
14892
+ },
14893
+ raw
14894
+ };
14895
+ }
14896
+ function sameSuggestion(entry, axis, to) {
14897
+ return entry.axis === "status" === (axis === "status") && entry.to === to;
14898
+ }
14899
+ /** The exchanges a buddy-conversation entry read, or none for any other source. */
14900
+ function exchangesOf(source) {
14901
+ return source.kind === "buddy-conversation" ? source.exchanges : [];
14902
+ }
14903
+ /**
14904
+ * Whether this suggestion is already on the ledger for this finding, in a way that means it should
14905
+ * not be written again: offered on the card now (`isOffered`, the projection's own answer), or
14906
+ * declined by the reviewer on evidence that covered every turn this one read. A proposal still marked
14907
+ * proposed but outdated or overtaken is offered by no card, so it holds nothing back. A decline on
14908
+ * less evidence does not either: later turns are new evidence.
14909
+ */
14910
+ function heldBack(session, kind, findingId, axis, to, exchanges) {
14911
+ for (const entry of session.findingReassessments ?? []) {
14912
+ if (entry.kind !== kind || entry.findingId !== findingId || !sameSuggestion(entry, axis, to)) continue;
14913
+ if (isOffered(session, entry)) return {
14914
+ kind: "waiting",
14915
+ entry
14916
+ };
14917
+ if (entry.state === "declined") {
14918
+ const read = new Set(exchangesOf(entry.source));
14919
+ if (exchanges.every((askedAt) => read.has(askedAt))) return { kind: "declined-before" };
14920
+ }
14921
+ }
14922
+ return null;
14923
+ }
14924
+ /**
14925
+ * Writes each judgement's proposals onto the session, checked against it as it is now. Run inside
14926
+ * the store write on the fresh copy, so a finding the reviewer decided while Buddy AI was reading is
14927
+ * written against what they decided, or not at all.
14928
+ */
14929
+ function keepReconcileTurn(session, read, exchanges, askedAt) {
14930
+ const outcome = { judgements: [] };
14931
+ if (!read.reading) return outcome;
14932
+ const source = {
14933
+ kind: "buddy-conversation",
14934
+ exchanges: [...exchanges].sort((a, b) => a - b)
14935
+ };
14936
+ const analysis = analysisRefOf(session);
14937
+ for (const judgement of read.reading.judgements) {
14938
+ const finding = findingIn(session, judgement.kind, judgement.findingId);
14939
+ if (!finding) {
14940
+ outcome.judgements.push({
14941
+ judgement,
14942
+ status: { kind: "stale" },
14943
+ grade: null
14944
+ });
14945
+ continue;
14946
+ }
14947
+ const current = findingStatus(session.findingReassessments, judgement.kind, judgement.findingId, analysis).current;
14948
+ const to = verdictFor(judgement, current);
14949
+ let status;
14950
+ if (!to) status = { kind: "still-open" };
14951
+ else {
14952
+ const held = heldBack(session, judgement.kind, judgement.findingId, "status", to, source.exchanges);
14953
+ if (held) status = held;
14954
+ else {
14955
+ const write = reassessIn(session, {
14956
+ op: "propose-status",
14957
+ kind: judgement.kind,
14958
+ findingId: judgement.findingId,
14959
+ from: current,
14960
+ to,
14961
+ reason: judgement.reason,
14962
+ ...judgement.answer ? { answer: judgement.answer } : {},
14963
+ ...judgement.basis && to === "resolved" ? { basis: judgement.basis } : {},
14964
+ ...judgement.evidence.length ? { evidence: judgement.evidence } : {},
14965
+ quotes: judgement.quotes,
14966
+ source
14967
+ }, askedAt);
14968
+ status = write.ok ? {
14969
+ kind: "proposed",
14970
+ entry: write.entry
14971
+ } : { kind: "stale" };
14972
+ }
14973
+ }
14974
+ let grade = null;
14975
+ if (judgement.grade) {
14976
+ const from = findingAssessment(session.findingReassessments, judgement.kind, finding, analysis).current;
14977
+ grade = heldBack(session, judgement.kind, judgement.findingId, "grade", judgement.grade.to, source.exchanges);
14978
+ if (!grade) {
14979
+ const write = reassessIn(session, {
14980
+ op: "propose",
14981
+ kind: judgement.kind,
14982
+ findingId: judgement.findingId,
14983
+ from,
14984
+ to: judgement.grade.to,
14985
+ reason: judgement.grade.reason,
14986
+ source
14987
+ }, askedAt);
14988
+ grade = write.ok ? {
14989
+ kind: "proposed",
14990
+ entry: write.entry
14991
+ } : write.refusal === "unchanged" ? null : { kind: "stale" };
14992
+ }
14993
+ }
14994
+ outcome.judgements.push({
14995
+ judgement,
14996
+ status,
14997
+ grade
14998
+ });
14999
+ }
15000
+ return outcome;
15001
+ }
15002
+ /** The ids of every proposal the turn wrote, for the transcript and for Accept all. */
15003
+ function proposedIds(outcome) {
15004
+ return outcome.judgements.flatMap(({ status, grade }) => [status, grade].flatMap((result) => result && result.kind === "proposed" ? [result.entry.id] : []));
15005
+ }
15006
+ /** One judgement's line, as the conversation says it: always a suggestion, never a change made. */
15007
+ function statusPhrase(judgement, status) {
15008
+ const to = status.kind === "proposed" || status.kind === "waiting" ? status.entry.to : null;
15009
+ switch (status.kind) {
15010
+ case "still-open": return "still open after this reply";
15011
+ case "declined-before": return "not suggested again: you kept it as it was on the same evidence";
15012
+ case "stale": return "not suggested: it changed while this was being read";
15013
+ case "waiting":
15014
+ case "proposed": {
15015
+ 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;
15016
+ if (!phrase) return null;
15017
+ return status.kind === "waiting" ? `${phrase} (already waiting on the card)` : phrase;
15018
+ }
15019
+ }
15020
+ }
15021
+ function plural(n, noun) {
15022
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
15023
+ }
15024
+ /** "3 questions may be answered · 1 issue may be resolved · 1 needs further investigation". */
15025
+ function headline(outcome) {
15026
+ const count = (kind, to) => outcome.judgements.filter(({ judgement, status }) => judgement.kind === kind && status.kind === "proposed" && status.entry.to === to).length;
15027
+ return [
15028
+ [count("question", "answered"), (n) => `${plural(n, "question")} may be answered`],
15029
+ [count("question", "partially-answered"), (n) => `${plural(n, "question")} may be partly answered`],
15030
+ [count("issue", "resolved"), (n) => `${plural(n, "issue")} may be resolved`],
15031
+ [count("issue", "needs-investigation"), (n) => `${n} ${n === 1 ? "needs" : "need"} further investigation`],
15032
+ [outcome.judgements.filter(({ status }) => status.kind === "proposed" && (status.entry.to === "open" || status.entry.to === "unanswered")).length, (n) => `${n} earlier ${n === 1 ? "decision" : "decisions"} contradicted`],
15033
+ [outcome.judgements.filter(({ grade }) => grade?.kind === "proposed").length, (n) => `${plural(n, "grade change")} suggested`]
15034
+ ].flatMap(([n, say]) => n > 0 ? [say(n)] : []).join(" · ");
15035
+ }
15036
+ /**
15037
+ * What the conversation shows for the turn: the suggestions, finding by finding, and nothing that
15038
+ * reads as a change made. Never the JSON. Findings the reply did not address are not mentioned,
15039
+ * because they were not judged.
15040
+ */
15041
+ function reconcileTurnText(read, outcome, identity) {
15042
+ 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.*`;
15043
+ const lines = outcome.judgements.flatMap(({ judgement, status, grade }) => {
15044
+ const said = [];
15045
+ const phrase = statusPhrase(judgement, status);
15046
+ if (phrase) said.push(phrase);
15047
+ if (grade?.kind === "proposed" || grade?.kind === "waiting") said.push(`its ${judgement.kind === "issue" ? "severity" : "importance"} may change to ${grade.entry.to}`);
15048
+ return said.length ? [`- **${judgement.ref}** ${said.join("; ")}. ${judgement.reason}`] : [];
15049
+ });
15050
+ 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}` : ""}`);
15051
+ const dropped = read.reading.discarded.unquoted + read.reading.discarded.unknownRef;
15052
+ 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.*`] : [];
15053
+ const top = headline(outcome);
15054
+ 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.*`] : [];
15055
+ if (!lines.length && !unmatched.length) return [
15056
+ "**Reconciled with findings.** Nothing in these turns bears on the review’s issues or questions, so nothing is suggested.",
15057
+ ...unnamedLine,
15058
+ ...droppedLine
15059
+ ].join("\n\n");
15060
+ return [
15061
+ `**Reconciled with findings.**${top ? ` ${top}.` : ""}`,
15062
+ "These are suggestions. Nothing changes until you accept one on its card.",
15063
+ [...lines, ...unmatched].join("\n"),
15064
+ ...unnamedLine,
15065
+ ...droppedLine
15066
+ ].join("\n\n");
15067
+ }
15068
+ /** The turns as the prompt labels them. */
15069
+ function evidenceTurns(turns) {
15070
+ return labelTurns(turns);
15071
+ }
14252
15072
  //#endregion
14253
15073
  //#region ../../packages/review-harness/src/workspace/diagram.ts
14254
15074
  /** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
@@ -16729,10 +17549,10 @@ async function handleUpdate(req, res, workspace) {
16729
17549
  /**
16730
17550
  * Resume a job that stopped, rather than start one over.
16731
17551
  *
16732
- * Fires and returns at once, exactly as starting a job does: `runJob` can take minutes, and this
16733
- * route answers a button click, not a session that is waiting for the review. `runJob` records
16734
- * every outcome, success or failure, on the job itself before it resolves or rejects, so there is
16735
- * nothing left worth doing with a rejection here except not letting it become an unhandled one.
17552
+ * Answers as soon as the new attempt is recorded, not when it ends: `runJob` can take minutes, and
17553
+ * this route answers a button click, not a session that is waiting for the review. `runJob` records
17554
+ * every later outcome, success or failure, on the job itself, so a rejection after the answer has
17555
+ * nothing left worth doing except not becoming an unhandled one.
16736
17556
  *
16737
17557
  * Refused up front when the job is not terminal, rather than left to `runJob`'s own guard: a job
16738
17558
  * that is still running has nothing here worth retrying, and saying so is cheaper and clearer than
@@ -16749,8 +17569,19 @@ async function handleRetry(res, workspace) {
16749
17569
  if (!workspace.jobId) return send(res, 404, { error: "No job for this review." });
16750
17570
  const job = loadJob(workspace.jobId);
16751
17571
  if (!job || !isTerminal(job) || isClaimed(job.id)) return send(res, 409, { error: "This review is still being made. Wait for it to finish before retrying." });
16752
- track(runJob(workspace.id, {}, "standard", { source: "workspace" })).catch(() => {});
16753
- send(res, 200, { ok: true });
17572
+ await new Promise((resolve) => {
17573
+ const run = runJob(workspace.id, {}, "standard", {
17574
+ source: "workspace",
17575
+ onStarted: () => {
17576
+ send(res, 200, { ok: true });
17577
+ resolve();
17578
+ }
17579
+ });
17580
+ track(run).catch((error) => {
17581
+ if (!res.headersSent) send(res, 409, { error: error instanceof Error ? error.message : String(error) });
17582
+ resolve();
17583
+ });
17584
+ });
16754
17585
  }
16755
17586
  /**
16756
17587
  * Analyse this review again, at the commit it describes, with the agent that made it.
@@ -16853,6 +17684,22 @@ async function handleAsk(req, res, workspace) {
16853
17684
  ...reassessed,
16854
17685
  from: currentGradeIn(workspace.session, reassessed.kind, reassessed.id) ?? null
16855
17686
  } : null;
17687
+ let reconcileTurns = null;
17688
+ let reconcileIdentity = null;
17689
+ if (body?.reconciles !== void 0 && !explains && !reassessed) {
17690
+ if (!workspace.session.result) return send(res, 409, { error: "This review has no findings to reconcile with yet." });
17691
+ const chosen = reconcileTurnsOf(workspace.transcript, body.reconciles);
17692
+ if (!chosen.ok) return send(res, 400, {
17693
+ error: TURNS_REFUSED[chosen.refusal],
17694
+ refusal: chosen.refusal
17695
+ });
17696
+ reconcileIdentity = reviewIdentityOf(chosen.turns, workspace.session.id);
17697
+ if (reconcileIdentity.kind === "other") return send(res, 409, {
17698
+ error: otherReviewRefusal(reconcileIdentity),
17699
+ refusal: "other-review"
17700
+ });
17701
+ reconcileTurns = evidenceTurns(chosen.turns);
17702
+ }
16856
17703
  if (liveUpdateFor(workspace.id)) return send(res, 409, {
16857
17704
  error: "This review is being updated. Ask again when the new review is ready.",
16858
17705
  updating: true
@@ -16893,6 +17740,13 @@ async function handleAsk(req, res, workspace) {
16893
17740
  ...explains ? { explaining: explains } : {},
16894
17741
  ...continuing ? { continuing } : {},
16895
17742
  ...reassessed ? { reassessing: reassessed } : {},
17743
+ ...reconcileTurns && reconcileIdentity ? { reconciling: {
17744
+ turns: reconcileTurns,
17745
+ review: {
17746
+ tag: reconcileIdentity.tag,
17747
+ named: reconcileIdentity.kind === "named"
17748
+ }
17749
+ } } : {},
16896
17750
  conversation: prepared
16897
17751
  });
16898
17752
  } catch (error) {
@@ -16915,21 +17769,39 @@ async function handleAsk(req, res, workspace) {
16915
17769
  commitSha: workspace.session.metadata.commitSha
16916
17770
  }) : null;
16917
17771
  let reassessOutcome = null;
17772
+ const reconcileRead = reconcileTurns && workspace.session.result ? await readReconcileTurn(answer, workspace.session.result, reconcileTurns, {
17773
+ repoPath: workspace.repoPath,
17774
+ commitSha: workspace.session.metadata.commitSha
17775
+ }) : null;
17776
+ let reconcileOutcome = null;
17777
+ let keptAt = 0;
16918
17778
  let explanation;
16919
17779
  let shown = answer;
16920
17780
  const saved = mutateWorkspace(workspace.id, (fresh) => {
16921
17781
  const askedAt = Date.now();
16922
17782
  const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
17783
+ keptAt = askedAt;
16923
17784
  if (turn && reassessing) {
16924
17785
  reassessOutcome = keepReassessTurn(fresh.session, reassessing, turn, askedAt);
16925
17786
  shown = reassessTurnText(reassessing, turn, reassessOutcome);
16926
17787
  }
17788
+ let reconciles;
17789
+ if (reconcileRead && reconcileTurns) {
17790
+ const exchanges = reconcileTurns.map((evidence) => evidence.askedAt);
17791
+ reconcileOutcome = keepReconcileTurn(fresh.session, reconcileRead, exchanges, askedAt);
17792
+ shown = reconcileTurnText(reconcileRead, reconcileOutcome, reconcileIdentity ?? void 0);
17793
+ reconciles = {
17794
+ exchanges,
17795
+ proposals: proposedIds(reconcileOutcome)
17796
+ };
17797
+ }
16927
17798
  fresh.transcript.push({
16928
17799
  question,
16929
17800
  answer: shown,
16930
17801
  sectionId,
16931
17802
  askedAt,
16932
- ...kept ? { explains: kept } : {}
17803
+ ...kept ? { explains: kept } : {},
17804
+ ...reconciles ? { reconciles } : {}
16933
17805
  });
16934
17806
  if (kept) {
16935
17807
  explanation = {
@@ -16945,13 +17817,23 @@ async function handleAsk(req, res, workspace) {
16945
17817
  outcome: reassessOutcome,
16946
17818
  ...card ? { card } : {}
16947
17819
  } } : {};
16948
- sendIfOpen(res, 200, explanation ? {
16949
- answer: shown,
16950
- explanation,
16951
- ...reassessment
16952
- } : {
17820
+ const reconciliation = reconcileOutcome && saved ? { reconciliation: {
17821
+ proposals: proposedIds(reconcileOutcome),
17822
+ cards: reconcileOutcome.judgements.flatMap(({ judgement }) => {
17823
+ const parts = gradeFragments(saved, judgement.kind, judgement.findingId);
17824
+ return parts ? [parts] : [];
17825
+ })
17826
+ } } : {};
17827
+ const turnRef = saved ? {
17828
+ askedAt: keptAt,
17829
+ ...reconcileOutcome ? { reconcileTurn: true } : {}
17830
+ } : {};
17831
+ sendIfOpen(res, 200, {
16953
17832
  answer: shown,
16954
- ...reassessment
17833
+ ...explanation ? { explanation } : {},
17834
+ ...reassessment,
17835
+ ...reconciliation,
17836
+ ...turnRef
16955
17837
  });
16956
17838
  } finally {
16957
17839
  endRun(run);
@@ -17264,7 +18146,9 @@ async function handleState(req, res, workspace) {
17264
18146
  ok: true,
17265
18147
  completedAt: saved.completedAt ?? null,
17266
18148
  pinned: saved.pinned === true,
17267
- queued: readQueue().includes(saved.id)
18149
+ queued: readQueue().includes(saved.id),
18150
+ answeredQuestionIds: saved.session.answeredQuestionIds ?? [],
18151
+ dismissedIssues: saved.session.dismissedIssues ?? []
17268
18152
  });
17269
18153
  }
17270
18154
  var KISS_DECISION_VALUES = [
@@ -17362,7 +18246,8 @@ var ASK_SOURCES = [
17362
18246
  "free_text",
17363
18247
  "explain_simply",
17364
18248
  "selection",
17365
- "finding"
18249
+ "finding",
18250
+ "reconcile"
17366
18251
  ];
17367
18252
  function askSource(value) {
17368
18253
  return ASK_SOURCES.includes(value) ? value : "free_text";