prreviewbuddy 0.85.0 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/dist/{delete_review-BED4mPxB.js → delete_review-rwn_-SnJ.js} +303 -40
- package/dist/main.js +1 -1
- package/dist/server.js +916 -71
- package/package.json +1 -1
- package/static/shell.css +42 -0
- package/static/sidebar.css +15 -0
- package/static/workspace.js +66 -59
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as MIN_WORKSPACE_PORT, $i as
|
|
1
|
+
import { $ as MIN_WORKSPACE_PORT, $i as AmbiguousRefError, $n as RUN_RECORD_RETENTION_MS, $r as deleteCollection, $t as markRead, A as batchIdentity, Ai as findingAssessment, An as escapeHtml$1, At as loadArchitecture, Bi as wasReassessed, Bn as readMarker, Br as LIFECYCLES, Bt as ancestorsOf, C as RUN_KINDS, Ci as withExplanation, Cn as UNESTABLISHED_MOTIVATION, Cr as kissDecisionsOf, Ct as mapRuntimes, D as reportRun, Di as assessmentLookup, Dn as renderMarkdown, Dr as loadWorkspace, Dt as formatDuration, E as endRun, Ei as analysisRefOf, En as GITHUB_MARKDOWN, Er as lineagePosition, Et as describeRuntimes, F as activateQueued, Fi as isOffered, Fn as withReviewLock, Ft as storedScopes, G as serverEntryExists, Gi as describeAuthorship, Gn as findExistingReview, Gr as lifecycleItemLabel, Gt as subjectOf, Hi as asString, Hn as requestJobStop, Hr as activityLabel, Ht as writeJsonAtomically, I as compactQueue, Ii as promptGrade, In as MANAGED_ROOT, Ir as summarise, It as trackedFiles, Ji as resolveTarget, Jn as followedBranch, Jr as parseLifecycle, Jt as createPrState, K as spawnServer, Ki as WITHHELD_REASON, Kn as checkCodeFreshness, Kr as lifecyclePill, Kt as categoryOf, Li as reassessIn, Ln as createWorktree, Lr as touchWorkspace, Lt as ownersOf, M as parsePullRequestLink, Mi as findingStatus, Mr as previousKissResult, Mt as readArchitecture, N as unsupportedReason, Ni as gradeScale, Nn as ReviewBeingDeletedError, Nr as recentWorkspaces, Nt as repositoryKey, O as stopRun, Oi as closesFinding, On as CODE_BLOCK_CLASS, Ot as ARCHITECTURE_DIR, P as runBatch, Pi as isGradeFor, Pn as isBusy, Pr as reviewedCommit, Pt as storeArchitecture, Q as MAX_WORKSPACE_PORT, Qi as resolveBase, Qn as fillFileUrlTemplate, Qr as createWithMembers, Qt as markAllRead, R as settleQueue, Ri as sourcePhrase, Rn as holdWorktree, Rr as workspaceRevision, Rt as QUOTE_LIMITS, S as track, Si as isExplanationAction, Sn as chatConversationSection, Sr as kissDecisionRefusal, St as waitingHistory, T as completeRun, Ti as NO_STATUS, Tn as hasAuthorContent, Tr as lineageKey, Tt as RUNTIME_WORDS, U as readServerRecord, Ui as extractJsonText, Ur as isLifecycle, Ut as announceJob, V as clearServerRecordOwnedBy, Vi as followUpFindingId, Vn as removeWorktree, Vr as activityAttributes, Vt as isArchitectureId, Wi as objectWithAnyOf, Wr as lifecycleChip, Wt as branchOf, X as writeServerRecord, Xi as DetachedHeadError, Xn as fetchHead, Xr as collectionsStamp, Xt as eventName, Yi as originRemote, Yn as reviewedShaOf, Yr as sectionOf, Yt as createPrUpdate, Z as DEFAULT_WORKSPACE_PORT, Zi as isCommitReview, Zn as forgeResolver, Zr as createCollection, Zt as listNotifications, _ as runningJobs, _a as isAgentId, _i as verdictClosure, _n as unforeseenFailure, _r as decideKissRecommendation, _t as telemetryUploadConsent, a as wasBlocked, aa as resolveModel, ai as queueAdd, an as removeNotification, ar as loadJob, at as observeReview, b as startKissJob, ba as AgentCancelledError, bi as explanationActionOf, bn as agentEnvOf, br as indexKeptIds, c as RefreshUnavailableError, ca as readAgentPreference, ci as queueStamp, cn as unreadAround, ct as reanalyseReview, d as mergeUpdateInto, da as STORE_ROOT, di as isIssueOutstanding, dn as existingPaths, dr as phasesFor, ea as shortNameOf, ei as nameProblemText, en as markReadForReview, er as allJobs, et as parseWorkspacePort, fa as AGENT_IDS, fi as isQuestionOutstanding, fn as ASK_REREAD_CEILING_MS, fr as progressSteps, ft as readEvents, g as liveUpdateFor, ga as detectAgents, gi as reviewerDispositions, gn as storedConversation, gt as recordTelemetryUploadConsent, h as UpdateAlreadyRunningError, ha as agentIdentityOf, hi as resolvedByConversation, hn as saveRefresh, hr as isClaimed, i as liveJobsFor, ia as resolveAgent, ii as updateCollections, in as readStateOf, ir as isTerminal, it as failureOf$1, j as extractPullRequestLinks, ji as findingIn, jn as renderInlineText, jr as presentedKissRunOf, jt as partFiles, k as relativeTime$1, ki as currentGradeIn, kn as CODE_COPY_CLASS, kr as mutateWorkspace, kt as checkArchitecture, l as cancelUpdate, la as writeAgentPreference, li as readQueue, ln as unreadCount$1, lr as isLiveJob, lt as runJob, m as askCheckout, ma as agentFor$1, mi as questionsOutstanding, mn as prepareConversationWithin, n as deleteReview, na as git, ni as renameCollection, nn as pruneNotifications, nr as endedAt, nt as statedWorkspacePort, o as discardJob, oa as agentIdOf, oi as queueAddMany, on as retireNotification, or as pruneJobResidue, ot as prepareReview, p as updateReview, pa as agentById, pi as issuesOutstanding, pn as adoptLegacyBaseline, pr as purposeOf, pt as record, q as stopChild, qi as kissOutcome, qn as checkReview, qr as offersLifecycle, qt as createArchitectureNotification, r as lineageIds, ra as refExists, ri as setMembership, rn as readNotification, rr as fail$1, rt as writePortPreference, s as FETCH_TIMEOUT_MS, sa as clearAgentPreference, si as queueRemove, sn as toneOf, sr as saveJob, st as settleReview, t as deleteLineage, ta as shorthandOf, ti as readCollections, tn as moveIds, tr as attemptIdOf, tt as resolveWorkspacePort, u as cancellableHere, ui as doneVerb, un as withUsageRecorded, ur as isTerminalJobPhase, v as kissAgentId, va as parkedReason, vi as processDiscussion, vn as refreshPrContext, vr as followedRefName, w as beginRun, wi as ANALYSIS_ONLY, wn as authorPreview, wr as latestKissRun, wt as recordRuntimeBestEffort, x as isDriving, xa as AgentUnavailableError, xi as explanationOf, xn as EXPLAIN_SIMPLY_PROMPT, xr as indexReviewGroups, xt as feedbackUrl, ya as runnableAgents, yi as recommendationsOf, yn as isUnchanged, yr as groupByLineage, yt as BUILD_VERSION, z as readIndexToken, zi as statusLookup, zn as isWorktreeHeld, zr as workspaceStamps, zt as REPOSITORY_SCOPE } from "./delete_review-rwn_-SnJ.js";
|
|
2
2
|
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -553,6 +553,8 @@ var MESSAGE_ACTIONS_CLASS = "pr-helper-chat-actions";
|
|
|
553
553
|
var MESSAGE_SOURCE_ATTR = "data-copy-source";
|
|
554
554
|
/** A finding's text for a feedback draft, on its feedback checkbox. Copy's text less the reviewer's own working. */
|
|
555
555
|
var FEEDBACK_SOURCE_ATTR = "data-feedback-source";
|
|
556
|
+
/** The finding's readable reference (`I3`), beside its feedback text, where the host draws references. */
|
|
557
|
+
var FEEDBACK_REF_ATTR = "data-feedback-ref";
|
|
556
558
|
var STATES$1 = {
|
|
557
559
|
idle: {
|
|
558
560
|
icon: "<path d=\"M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z\"></path><path d=\"M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z\"></path>",
|
|
@@ -705,8 +707,8 @@ function renderFeedbackSelectButton() {
|
|
|
705
707
|
* to the review rather than to a pane, so leaving Issues does not take the tray with it, and its
|
|
706
708
|
* counts are the way back to each pane.
|
|
707
709
|
*/
|
|
708
|
-
function renderFeedbackTray() {
|
|
709
|
-
return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback" hidden>
|
|
710
|
+
function renderFeedbackTray(reviewTag) {
|
|
711
|
+
return `<div class="prb-selection-tray" data-feedback-tray role="region" aria-label="Chosen for feedback"${reviewTag ? ` data-review-tag="${escapeHtml$1(reviewTag)}"` : ""} hidden>
|
|
710
712
|
<p class="prb-selection-count" aria-live="polite">
|
|
711
713
|
<span data-feedback-total>${escapeHtml$1(feedbackSelectionLabel(0, 0))}</span>${renderGoto("issues")}${renderGoto("questions")}
|
|
712
714
|
</p>
|
|
@@ -1300,6 +1302,63 @@ function renderFindingExplanation(explanation, options = {}) {
|
|
|
1300
1302
|
</details>`;
|
|
1301
1303
|
}
|
|
1302
1304
|
//#endregion
|
|
1305
|
+
//#region ../../packages/review-harness/src/domain/ai_review/finding_ref.ts
|
|
1306
|
+
var PREFIX = {
|
|
1307
|
+
issue: "I",
|
|
1308
|
+
question: "Q"
|
|
1309
|
+
};
|
|
1310
|
+
function findingRef(kind, index) {
|
|
1311
|
+
return `${PREFIX[kind]}${index + 1}`;
|
|
1312
|
+
}
|
|
1313
|
+
/** Every finding's reference by its id, for a renderer that meets the findings out of order. */
|
|
1314
|
+
function findingRefs(result) {
|
|
1315
|
+
const refs = /* @__PURE__ */ new Map();
|
|
1316
|
+
result.issues.forEach((issue, index) => refs.set(`issue:${issue.id}`, findingRef("issue", index)));
|
|
1317
|
+
result.questions.forEach((question, index) => refs.set(`question:${question.id}`, findingRef("question", index)));
|
|
1318
|
+
return refs;
|
|
1319
|
+
}
|
|
1320
|
+
/** The reference of one finding, or undefined when the analysis does not hold it. */
|
|
1321
|
+
function refOf(refs, kind, id) {
|
|
1322
|
+
return refs.get(`${kind}:${id}`);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* The finding a reference names, or null. Only the exact form (`I3`, `q2` is accepted as `Q2`):
|
|
1326
|
+
* a reference out of range names nothing rather than the nearest finding.
|
|
1327
|
+
*/
|
|
1328
|
+
function findingAtRef(result, ref) {
|
|
1329
|
+
const match = /^([IQ])([1-9]\d*)$/i.exec(ref.trim());
|
|
1330
|
+
if (!match) return null;
|
|
1331
|
+
const index = Number(match[2]) - 1;
|
|
1332
|
+
if (match[1].toUpperCase() === "I") {
|
|
1333
|
+
const finding = result.issues[index];
|
|
1334
|
+
return finding === void 0 ? null : {
|
|
1335
|
+
kind: "issue",
|
|
1336
|
+
index,
|
|
1337
|
+
finding
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
const finding = result.questions[index];
|
|
1341
|
+
return finding === void 0 ? null : {
|
|
1342
|
+
kind: "question",
|
|
1343
|
+
index,
|
|
1344
|
+
finding
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* The review a set of references belongs to: `PRB-` and the first eight characters of the analysis's
|
|
1349
|
+
* session id. An update keeps the session, and so its references; a fresh analysis or a reanalysis
|
|
1350
|
+
* starts a new session and numbers its findings again, so its tag differs. A feedback draft carries
|
|
1351
|
+
* it, and Reconcile reads it back, so a reply to an older draft is never read against findings that
|
|
1352
|
+
* a later analysis happens to number the same way.
|
|
1353
|
+
*/
|
|
1354
|
+
function reviewTag(sessionId) {
|
|
1355
|
+
return `PRB-${sessionId.replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase()}`;
|
|
1356
|
+
}
|
|
1357
|
+
/** Every review tag in a text, as `reviewTag` writes them. */
|
|
1358
|
+
function reviewTagsIn(text) {
|
|
1359
|
+
return [...new Set([...text.matchAll(/\bPRB-([a-z0-9]{8})\b/gi)].map((match) => `PRB-${match[1].toLowerCase()}`))];
|
|
1360
|
+
}
|
|
1361
|
+
//#endregion
|
|
1303
1362
|
//#region ../../packages/review-harness/src/domain/ui/components/pill_tooltips.ts
|
|
1304
1363
|
/**
|
|
1305
1364
|
* What every pill in the sidebar actually means, in one place.
|
|
@@ -2050,7 +2109,8 @@ function renderReassessmentLine(kind, assessment, now) {
|
|
|
2050
2109
|
/** Who is suggesting it, by the proposal's source. */
|
|
2051
2110
|
function proposer(source) {
|
|
2052
2111
|
switch (source.kind) {
|
|
2053
|
-
case "buddy":
|
|
2112
|
+
case "buddy":
|
|
2113
|
+
case "buddy-conversation": return "Buddy AI";
|
|
2054
2114
|
case "investigation": return "The repository investigation";
|
|
2055
2115
|
case "update": return "The update";
|
|
2056
2116
|
case "reviewer": return "The review";
|
|
@@ -2089,9 +2149,92 @@ function renderWaitingSuggestions(count) {
|
|
|
2089
2149
|
if (count === 0) return "";
|
|
2090
2150
|
return `<span class="pr-helper-closed-findings-waiting" data-closed-findings-waiting> ${count === 1 ? "1 suggestion waiting" : `${count} suggestions waiting`}</span>`;
|
|
2091
2151
|
}
|
|
2152
|
+
/** A verdict as a card names it. */
|
|
2153
|
+
function verdictLabel$1(verdict) {
|
|
2154
|
+
switch (verdict) {
|
|
2155
|
+
case "resolved": return "Resolved";
|
|
2156
|
+
case "needs-investigation": return "Needs investigation";
|
|
2157
|
+
case "open": return "Open again";
|
|
2158
|
+
case "answered": return "Answered";
|
|
2159
|
+
case "partially-answered": return "Partly answered";
|
|
2160
|
+
case "unanswered": return "Unanswered again";
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* The words the evidence rests on, as the reviewer pasted them. Quoted, never paraphrased: they are
|
|
2165
|
+
* someone else's claim, and the card shows the claim rather than vouching for it.
|
|
2166
|
+
*/
|
|
2167
|
+
function renderQuotes(quotes) {
|
|
2168
|
+
if (!quotes?.length) return "";
|
|
2169
|
+
return quotes.map((quote) => `<blockquote class="pr-helper-reassessment-quote">${renderInlineText(quote.text)}</blockquote>`).join("");
|
|
2170
|
+
}
|
|
2171
|
+
/** What a resolution rests on, said before the places that show it. Nothing on any other verdict. */
|
|
2172
|
+
function renderBasis(basis) {
|
|
2173
|
+
if (!basis) return "";
|
|
2174
|
+
return `<p class="pr-helper-reassessment-basis" data-reassessment-basis="${basis}">${escapeHtml$1(basis === "code" ? "Buddy AI read this in the code at the reviewed commit. Check it before accepting:" : "Buddy AI read the reply as showing an assumption the issue made is false.")}</p>`;
|
|
2175
|
+
}
|
|
2176
|
+
function renderAnswer(answer) {
|
|
2177
|
+
return answer ? `<p class="pr-helper-reassessment-answer">${renderInlineText(answer)}</p>` : "";
|
|
2178
|
+
}
|
|
2179
|
+
function renderStatusStep(step, now) {
|
|
2180
|
+
return `<li class="pr-helper-reassessment-step">
|
|
2181
|
+
<span class="pr-helper-reassessment-step-move">${step.from ? `${escapeHtml$1(verdictLabel$1(step.from))} → ` : ""}<span class="pr-helper-reassessment-to">${escapeHtml$1(verdictLabel$1(step.to))}</span></span>
|
|
2182
|
+
<span class="pr-helper-reassessment-step-source">${escapeHtml$1(capitalise(sourcePhrase(step.source)))} · ${escapeHtml$1(formatRelativeTime(step.at, now))}</span>
|
|
2183
|
+
${step.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(step.reason)}</p>` : ""}
|
|
2184
|
+
${renderAnswer(step.answer)}
|
|
2185
|
+
${renderQuotes(step.quotes)}
|
|
2186
|
+
${renderBasis(step.basis)}
|
|
2187
|
+
${renderEvidence$1(step.evidence)}
|
|
2188
|
+
</li>`;
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* The status the reviewer accepted from later evidence, and History behind it. Says whether the
|
|
2192
|
+
* finding is still open, because an accepted status is not a closed one: a partial answer and an
|
|
2193
|
+
* issue that needs investigating stay in the work.
|
|
2194
|
+
*/
|
|
2195
|
+
function renderStatusLine(status, now) {
|
|
2196
|
+
const current = status.current;
|
|
2197
|
+
if (!current) return "";
|
|
2198
|
+
const last = status.history[status.history.length - 1];
|
|
2199
|
+
const open = closesFinding(current) ? "" : " Still open.";
|
|
2200
|
+
return `<div class="pr-helper-reassessment pr-helper-finding-verdict" data-finding-verdict="${escapeHtml$1(current)}">
|
|
2201
|
+
<p class="pr-helper-reassessment-line"><span class="pr-helper-reassessment-to">${escapeHtml$1(verdictLabel$1(current))}</span> <span class="pr-helper-reassessment-source">· Accepted ${escapeHtml$1(sourcePhrase(last.source))}.${open}</span></p>
|
|
2202
|
+
${renderAnswer(last.answer)}
|
|
2203
|
+
<details class="pr-helper-reassessment-history">
|
|
2204
|
+
<summary class="pr-helper-reassessment-history-summary">Status history</summary>
|
|
2205
|
+
<ol class="pr-helper-reassessment-steps">${status.history.map((step) => renderStatusStep(step, now)).join("")}</ol>
|
|
2206
|
+
</details>
|
|
2207
|
+
</div>`;
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* A status waiting on the reviewer. The same answers as a grade's, under the same attribute, so a
|
|
2211
|
+
* host's Accept and Keep need no second wiring: Accept applies exactly this verdict, Keep declines it.
|
|
2212
|
+
* A proposal that would take back an accepted closure says so first.
|
|
2213
|
+
*/
|
|
2214
|
+
function renderStatusProposal(status, options) {
|
|
2215
|
+
const proposal = status.pending;
|
|
2216
|
+
if (!proposal) return "";
|
|
2217
|
+
const actions = options.editable ? `<div class="pr-helper-reassessment-actions">
|
|
2218
|
+
<button type="button" class="pr-helper-reassessment-accept" data-reassessment-decide="accepted">Accept</button>
|
|
2219
|
+
<button type="button" class="pr-helper-reassessment-keep" data-reassessment-decide="declined">Keep as it is</button>
|
|
2220
|
+
${options.askMore ? "<button type=\"button\" class=\"pr-helper-reassessment-more\" data-reassessment-ask-more>Ask more →</button>" : ""}
|
|
2221
|
+
</div>` : "";
|
|
2222
|
+
const contradiction = proposal.contradicts && proposal.from ? `<p class="pr-helper-reassessment-contradicts" data-reassessment-contradicts>This would take back “${escapeHtml$1(verdictLabel$1(proposal.from))}”, which you accepted earlier.</p>` : "";
|
|
2223
|
+
return `<div class="pr-helper-reassessment-proposal" data-reassessment-proposal="${escapeHtml$1(proposal.id)}" data-status-proposal="${escapeHtml$1(proposal.to)}">
|
|
2224
|
+
<p class="pr-helper-reassessment-proposal-title">${escapeHtml$1(proposer(proposal.source))} suggests: ${escapeHtml$1(verdictLabel$1(proposal.to))}</p>
|
|
2225
|
+
${contradiction}
|
|
2226
|
+
${renderAnswer(proposal.answer)}
|
|
2227
|
+
${proposal.reason ? `<p class="pr-helper-reassessment-reason">${renderInlineText(proposal.reason)}</p>` : ""}
|
|
2228
|
+
${renderQuotes(proposal.quotes)}
|
|
2229
|
+
${renderBasis(proposal.basis)}
|
|
2230
|
+
${renderEvidence$1(proposal.evidence)}
|
|
2231
|
+
${actions}
|
|
2232
|
+
</div>`;
|
|
2233
|
+
}
|
|
2092
2234
|
/** The line, its History and any proposal, in the region a host replaces after a write. */
|
|
2093
|
-
function renderAssessmentRegion(kind, assessment, options = {}) {
|
|
2094
|
-
|
|
2235
|
+
function renderAssessmentRegion(kind, assessment, options = {}, status) {
|
|
2236
|
+
const now = options.now ?? Date.now();
|
|
2237
|
+
return `<div class="pr-helper-reassessment-region" data-assessment-region>${status ? `${renderStatusLine(status, now)}${renderStatusProposal(status, options)}` : ""}${renderReassessmentLine(kind, assessment, now)}${renderProposal(assessment, options)}</div>`;
|
|
2095
2238
|
}
|
|
2096
2239
|
//#endregion
|
|
2097
2240
|
//#region ../../packages/review-harness/src/domain/ui/components/review_tabs.ts
|
|
@@ -2534,7 +2677,7 @@ function possibleReason(summary, hasDescription) {
|
|
|
2534
2677
|
*/
|
|
2535
2678
|
function renderUnclearMotive(motivation, session, questions) {
|
|
2536
2679
|
const intent = questions.find((q) => q.category === "intent");
|
|
2537
|
-
const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? [])) : false;
|
|
2680
|
+
const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question) : false;
|
|
2538
2681
|
const hide = (yes) => yes ? " hidden" : "";
|
|
2539
2682
|
return `
|
|
2540
2683
|
<section class="pr-helper-why pr-helper-why-unclear" aria-labelledby="pr-helper-why-heading"${intent ? ` data-intent-question="${escapeHtml$1(intent.id)}"` : ""}>
|
|
@@ -2578,7 +2721,7 @@ function renderOverviewTab(session, iconUrl = "", options) {
|
|
|
2578
2721
|
<div class="pr-helper-overview-stats" data-tour="stats">
|
|
2579
2722
|
${renderOverviewStat(metrics.sections, "Themes", "guide")}
|
|
2580
2723
|
${renderIssuesStat(issuesOutstanding(result.issues, reviewerDispositions(session)))}
|
|
2581
|
-
${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
|
|
2724
|
+
${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question), "Questions", "questions", "answered")}
|
|
2582
2725
|
${renderCoverageStat(session, metrics.files)}
|
|
2583
2726
|
</div>
|
|
2584
2727
|
${renderSuggestedPath(result.sections, "Suggested review path")}
|
|
@@ -2621,13 +2764,13 @@ function discussionDetails(session) {
|
|
|
2621
2764
|
if (reviewThreads > 0) {
|
|
2622
2765
|
const outdated = stats.outdatedThreads ?? 0;
|
|
2623
2766
|
const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
|
|
2624
|
-
parts.push(`${reviewThreads} ${plural$
|
|
2767
|
+
parts.push(`${reviewThreads} ${plural$3(reviewThreads, "review thread")} (${state})`);
|
|
2625
2768
|
}
|
|
2626
|
-
if (conversation > 0) parts.push(`${conversation} ${plural$
|
|
2627
|
-
if (decisions > 0) parts.push(`${decisions} ${plural$
|
|
2769
|
+
if (conversation > 0) parts.push(`${conversation} ${plural$3(conversation, "conversation comment")}`);
|
|
2770
|
+
if (decisions > 0) parts.push(`${decisions} ${plural$3(decisions, "review decision")}`);
|
|
2628
2771
|
return parts;
|
|
2629
2772
|
}
|
|
2630
|
-
function plural$
|
|
2773
|
+
function plural$3(count, word) {
|
|
2631
2774
|
return count === 1 ? word : `${word}s`;
|
|
2632
2775
|
}
|
|
2633
2776
|
function renderExistingDiscussion(ref) {
|
|
@@ -3068,8 +3211,11 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3068
3211
|
const dismissals = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
|
|
3069
3212
|
const dispositions = reviewerDispositions(session);
|
|
3070
3213
|
const assess = assessmentLookup(session);
|
|
3214
|
+
const statusOf = statusLookup(session);
|
|
3071
3215
|
const grading = gradeOptions(options, now);
|
|
3216
|
+
const refs = options?.findingRefs ? findingRefs(result) : null;
|
|
3072
3217
|
const card = (issue) => {
|
|
3218
|
+
const ref = refs ? refOf(refs, "issue", issue.id) : void 0;
|
|
3073
3219
|
const isDismissed = dispositions.isDismissed(issue);
|
|
3074
3220
|
const stale = dispositions.isStale(issue);
|
|
3075
3221
|
const assessment = assess("issue", issue);
|
|
@@ -3077,7 +3223,8 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3077
3223
|
<div class="pr-helper-issue-card ${cardGradeClass("issue", assessment.current)}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
|
|
3078
3224
|
<div class="pr-helper-issue-header">
|
|
3079
3225
|
<div class="pr-helper-issue-title-row">
|
|
3080
|
-
${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue)) : ""}
|
|
3226
|
+
${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue), ref) : ""}
|
|
3227
|
+
${renderFindingRef(ref)}
|
|
3081
3228
|
<p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
|
|
3082
3229
|
${renderFindingCopySource(issueMarkdown(issue, assessment), "issue")}
|
|
3083
3230
|
${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
|
|
@@ -3092,7 +3239,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3092
3239
|
</div>
|
|
3093
3240
|
</div>
|
|
3094
3241
|
${renderStaleDismissal(stale)}
|
|
3095
|
-
${renderAssessmentRegion("issue", assessment, grading)}
|
|
3242
|
+
${renderAssessmentRegion("issue", assessment, grading, statusOf("issue", issue.id))}
|
|
3096
3243
|
<p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(explanationOf(session, "issue", issue.id), options)}
|
|
3097
3244
|
${renderReconciliationNote(issue.reconciliationNote)}
|
|
3098
3245
|
${renderStatusHistory(issue.statusHistory, now)}
|
|
@@ -3103,7 +3250,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3103
3250
|
${renderInvestigationProvenance("issue", issue.id, records, now)}
|
|
3104
3251
|
<div class="pr-helper-issue-actions">
|
|
3105
3252
|
${renderInvestigationActions("issue", issue.id, records, investigatingId)}
|
|
3106
|
-
${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id))}
|
|
3253
|
+
${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id), dispositions.isClosedByVerdict(issue))}
|
|
3107
3254
|
</div>
|
|
3108
3255
|
</div>
|
|
3109
3256
|
`;
|
|
@@ -3113,7 +3260,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3113
3260
|
const open = result.issues.filter((issue) => isIssueOutstanding(issue, dispositions)).map(card).join("");
|
|
3114
3261
|
const closedIssues = result.issues.filter((issue) => !isIssueOutstanding(issue, dispositions));
|
|
3115
3262
|
const closed = closedIssues.map(card).join("");
|
|
3116
|
-
return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending).length)}`;
|
|
3263
|
+
return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending || statusOf("issue", issue.id).pending).length)}`;
|
|
3117
3264
|
}
|
|
3118
3265
|
/**
|
|
3119
3266
|
* What to call the closed pile, by what is actually in it.
|
|
@@ -3130,7 +3277,9 @@ function closedFindingsSummary(counts) {
|
|
|
3130
3277
|
if (byConversation === counts.done) return `Resolved by the conversation (${counts.done})`;
|
|
3131
3278
|
return `Resolved (${counts.done})`;
|
|
3132
3279
|
}
|
|
3133
|
-
|
|
3280
|
+
const byVerdict = counts.byVerdict ?? 0;
|
|
3281
|
+
if (byCommits === 0 && byVerdict === 0) return `Dismissed by you (${counts.done})`;
|
|
3282
|
+
if (byCommits === 0 && byVerdict === counts.done) return `Accepted as resolved (${counts.done})`;
|
|
3134
3283
|
return `Dealt with (${counts.done})`;
|
|
3135
3284
|
}
|
|
3136
3285
|
/**
|
|
@@ -3169,11 +3318,11 @@ function renderStaleDismissal(stale) {
|
|
|
3169
3318
|
* toggle is. A reviewer agreeing with a fix changes no count, and a control that vanishes on some
|
|
3170
3319
|
* cards is harder to explain than one that occasionally changes nothing.
|
|
3171
3320
|
*/
|
|
3172
|
-
function renderDismissToggle(issue, isDismissed, stale, dismissal) {
|
|
3321
|
+
function renderDismissToggle(issue, isDismissed, stale, dismissal, closedByVerdict = false) {
|
|
3173
3322
|
const label = stale ? "Dismiss again" : isDismissed ? "Reopen" : "Dismiss";
|
|
3174
3323
|
const settled = issue.reconciliationStatus === "resolved";
|
|
3175
3324
|
const settledBy = settled ? ` data-settled-by="${resolvedByConversation(issue) ? "conversation" : "code"}"` : "";
|
|
3176
|
-
return `<button type="button" class="pr-helper-issue-dismiss-toggle" data-issue-dismissed="${escapeHtml$1(issue.id)}" data-dismissed="${isDismissed}"${stale ? " data-stale=\"true\"" : ""}${dismissal ? ` data-dismissed-in="${escapeHtml$1(dismissal.sessionId)}"` : ""}${settled ? ` data-settled="true"${settledBy}` : ""}>${label}</button>`;
|
|
3325
|
+
return `<button type="button" class="pr-helper-issue-dismiss-toggle" data-issue-dismissed="${escapeHtml$1(issue.id)}" data-dismissed="${isDismissed}"${stale ? " data-stale=\"true\"" : ""}${dismissal ? ` data-dismissed-in="${escapeHtml$1(dismissal.sessionId)}"` : ""}${settled ? ` data-settled="true"${settledBy}` : ""}${closedByVerdict ? " data-verdict-closed=\"true\"" : ""}>${label}</button>`;
|
|
3177
3326
|
}
|
|
3178
3327
|
/**
|
|
3179
3328
|
* The group an Intent question lives in, keyed like a theme's so the workspace client finds its
|
|
@@ -3185,19 +3334,21 @@ var INTENT_QUESTION_GROUP = "prb-intent";
|
|
|
3185
3334
|
function questionHome(q) {
|
|
3186
3335
|
return q.category === "intent" ? INTENT_QUESTION_GROUP : q.sectionId;
|
|
3187
3336
|
}
|
|
3188
|
-
function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options, explanation, assessment = ANALYSIS_ONLY("question", q)) {
|
|
3337
|
+
function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options, explanation, assessment = ANALYSIS_ONLY("question", q), ref, status = NO_STATUS("question", q.id)) {
|
|
3189
3338
|
const grading = gradeOptions(options, now);
|
|
3190
3339
|
const importanceBadge = renderGradeControl("question", assessment, grading);
|
|
3191
3340
|
const jumpLink = renderQuestionJump(q.file, options);
|
|
3192
3341
|
const settled = q.reconciliationStatus === "answered";
|
|
3193
|
-
const
|
|
3342
|
+
const verdictClosed = closesFinding(status.current);
|
|
3343
|
+
const answerToggle = `<button type="button" class="pr-helper-question-answer-toggle" data-question-answered="${escapeHtml$1(q.id)}" data-answered="${answered}"${settled ? " data-settled=\"true\"" : ""}${verdictClosed ? " data-verdict-closed=\"true\"" : ""}>${answered ? "Mark as unanswered" : "Mark answered"}</button>`;
|
|
3194
3344
|
const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
|
|
3195
3345
|
const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>${escapeHtml$1(reconciledAnswerHeading(q.reconciledAgainst))}</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
|
|
3196
3346
|
return `
|
|
3197
3347
|
<div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${questionHome(q) ? ` data-section-id="${escapeHtml$1(questionHome(q))}"` : ""}>
|
|
3198
3348
|
<div class="pr-helper-issue-header">
|
|
3199
3349
|
<div class="pr-helper-issue-title-row">
|
|
3200
|
-
${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q)) : ""}
|
|
3350
|
+
${options?.feedbackSelect ? renderFeedbackPick("question", q.question, questionFeedbackMarkdown(q), ref) : ""}
|
|
3351
|
+
${renderFindingRef(ref)}
|
|
3201
3352
|
<p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
|
|
3202
3353
|
${renderFindingCopySource(questionMarkdown(q, assessment), "question")}
|
|
3203
3354
|
${options?.askBuddy ? renderAskBuddyMenu("question") : ""}
|
|
@@ -3214,7 +3365,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
|
|
|
3214
3365
|
${importanceBadge}
|
|
3215
3366
|
</div>
|
|
3216
3367
|
</div>
|
|
3217
|
-
${renderAssessmentRegion("question", assessment, grading)}
|
|
3368
|
+
${renderAssessmentRegion("question", assessment, grading, status)}
|
|
3218
3369
|
${q.whyThisMatters ? `<h5>Why this matters</h5><p class="pr-helper-issue-desc">${renderInlineText(q.whyThisMatters)}</p>` : ""}${renderCardExplanation(explanation, options)}
|
|
3219
3370
|
${reconciliationAnswer}
|
|
3220
3371
|
${renderReconciliationNote(q.reconciliationNote)}
|
|
@@ -3245,8 +3396,8 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
|
|
|
3245
3396
|
function questionsProgressBody(total, outstanding) {
|
|
3246
3397
|
return outstanding === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">✓</span><span class="pr-helper-questions-progress-count">All ${total} questions answered</span>` : `<span class="pr-helper-questions-progress-count">${outstanding} of ${total} unanswered</span>`;
|
|
3247
3398
|
}
|
|
3248
|
-
function renderQuestionsProgress(questions, answered) {
|
|
3249
|
-
const counts = questionsOutstanding(questions, answered);
|
|
3399
|
+
function renderQuestionsProgress(questions, answered, closedByVerdict) {
|
|
3400
|
+
const counts = questionsOutstanding(questions, answered, closedByVerdict);
|
|
3250
3401
|
if (counts.total < 2) return "";
|
|
3251
3402
|
return `<div class="pr-helper-questions-progress" role="status">${questionsProgressBody(counts.total, counts.open)}</div>`;
|
|
3252
3403
|
}
|
|
@@ -3261,8 +3412,13 @@ function renderQuestionsProgress(questions, answered) {
|
|
|
3261
3412
|
* It carries the text the card gives a feedback draft, which is narrower than its Copy: see
|
|
3262
3413
|
* finding_copy.ts.
|
|
3263
3414
|
*/
|
|
3264
|
-
function renderFeedbackPick(kind, title, feedback) {
|
|
3265
|
-
|
|
3415
|
+
function renderFeedbackPick(kind, title, feedback, ref) {
|
|
3416
|
+
const refAttr = ref ? ` ${FEEDBACK_REF_ATTR}="${ref}"` : "";
|
|
3417
|
+
return `<input type="checkbox" class="pr-helper-feedback-pick" data-feedback-pick="${kind}" ${FEEDBACK_SOURCE_ATTR}="${escapeHtml$1(feedback)}"${refAttr} autocomplete="off" aria-label="${escapeHtml$1(`Include in feedback: ${title.trim()}`)}">`;
|
|
3418
|
+
}
|
|
3419
|
+
/** The card's readable reference, ahead of its title. Drawn only where the host asked for references. */
|
|
3420
|
+
function renderFindingRef(ref) {
|
|
3421
|
+
return ref ? `<span class="pr-helper-finding-ref" data-finding-ref="${ref}">${ref}</span>` : "";
|
|
3266
3422
|
}
|
|
3267
3423
|
function renderQuestionJump(file, options) {
|
|
3268
3424
|
if (!file || options?.suppressFileJump) return "";
|
|
@@ -3279,14 +3435,17 @@ function renderQuestionsTab(session, investigatingId, options) {
|
|
|
3279
3435
|
if (result.questions.length === 0) return emptyState("No open questions.");
|
|
3280
3436
|
const sections = buildSectionLookup(result);
|
|
3281
3437
|
const answered = new Set(session.answeredQuestionIds ?? []);
|
|
3282
|
-
const progress = renderQuestionsProgress(result.questions, answered);
|
|
3438
|
+
const progress = renderQuestionsProgress(result.questions, answered, verdictClosure(session).question);
|
|
3283
3439
|
const records = session.investigations;
|
|
3284
3440
|
const now = Date.now();
|
|
3285
3441
|
const assess = assessmentLookup(session);
|
|
3286
|
-
const
|
|
3287
|
-
const
|
|
3288
|
-
const
|
|
3289
|
-
const
|
|
3442
|
+
const statusOf = statusLookup(session);
|
|
3443
|
+
const closedByVerdict = verdictClosure(session).question;
|
|
3444
|
+
const refs = options?.findingRefs ? findingRefs(result) : null;
|
|
3445
|
+
const card = (q) => renderQuestionCard(q, sections, answered.has(q.id), records, now, investigatingId, options, explanationOf(session, "question", q.id), assess("question", q), refs ? refOf(refs, "question", q.id) : void 0, statusOf("question", q.id));
|
|
3446
|
+
const open = result.questions.filter((q) => isQuestionOutstanding(q, answered, closedByVerdict));
|
|
3447
|
+
const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered, closedByVerdict));
|
|
3448
|
+
const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0, settled.filter((q) => assess("question", q).pending || statusOf("question", q.id).pending).length);
|
|
3290
3449
|
const intent = open.filter((q) => q.category === "intent");
|
|
3291
3450
|
const intentHtml = result.questions.some((q) => q.category === "intent") ? `
|
|
3292
3451
|
<div class="pr-helper-question-group" data-question-group="${INTENT_QUESTION_GROUP}"${intent.length ? "" : " hidden"}>
|
|
@@ -3474,6 +3633,41 @@ function renderThemePicker(sections, selectedId) {
|
|
|
3474
3633
|
return `<div class="prb-guide-picker">${sections.map((section, index) => `<a class="prb-guide-pick" href="${escapeHtml$1(hashForSection(section.id))}" data-goto-section="${escapeHtml$1(section.id)}" data-theme-index="${index}" data-recommended-start="${section.recommendedStartingPoint}" aria-current="${section.id === selectedId}"><span class="prb-guide-pick-order">${index + 1}</span>${escapeHtml$1(section.title)}</a>`).join("")}</div>`;
|
|
3475
3634
|
}
|
|
3476
3635
|
//#endregion
|
|
3636
|
+
//#region ../../packages/review-harness/src/domain/ui/components/turn_actions.ts
|
|
3637
|
+
/**
|
|
3638
|
+
* What a finished Buddy AI turn offers under its answer, drawn the same by the page and by the client
|
|
3639
|
+
* on a live answer, so a reload never changes it.
|
|
3640
|
+
*
|
|
3641
|
+
* Every completed turn offers Reconcile with findings. Always, rather than when a turn looks like an
|
|
3642
|
+
* author's reply: whether a turn bears on the findings is the judgement the reconcile itself makes,
|
|
3643
|
+
* and a control that appears only sometimes is one a reviewer cannot count on. A reconcile turn
|
|
3644
|
+
* offers Accept all instead, while any suggestion it made is still waiting.
|
|
3645
|
+
*/
|
|
3646
|
+
/**
|
|
3647
|
+
* The supporting sentence, said beside the action. It names the scope, because the action reads only
|
|
3648
|
+
* the message of the turn it sits under: a reply split across several messages is reconciled one
|
|
3649
|
+
* message at a time (the route takes several turns; choosing them on the page is not built yet).
|
|
3650
|
+
*/
|
|
3651
|
+
var RECONCILE_HELP = "Reassesses existing issues and questions using only your message in this turn.";
|
|
3652
|
+
function renderTurnActions(turn, waiting = []) {
|
|
3653
|
+
if (turn.reconciles) return renderAcceptAll(waiting);
|
|
3654
|
+
return `<div class="prb-turn-actions" data-turn-actions="${turn.askedAt}">
|
|
3655
|
+
<button type="button" class="prb-turn-reconcile" data-reconcile-turn="${turn.askedAt}">Reconcile with findings</button>
|
|
3656
|
+
<span class="prb-turn-help">${escapeHtml$1(RECONCILE_HELP)}</span>
|
|
3657
|
+
</div>`;
|
|
3658
|
+
}
|
|
3659
|
+
/**
|
|
3660
|
+
* Accept every suggestion a reconcile made that is still waiting, each on its own card exactly as its
|
|
3661
|
+
* own Accept would. Nothing when none waits.
|
|
3662
|
+
*/
|
|
3663
|
+
function renderAcceptAll(waiting) {
|
|
3664
|
+
if (!waiting.length) return "";
|
|
3665
|
+
const label = waiting.length === 1 ? "Accept the suggestion" : `Accept all ${waiting.length} suggestions`;
|
|
3666
|
+
return `<div class="prb-turn-actions">
|
|
3667
|
+
<button type="button" class="prb-turn-accept-all" data-reconcile-accept-all="${escapeHtml$1(waiting.join(" "))}">${escapeHtml$1(label)}</button>
|
|
3668
|
+
</div>`;
|
|
3669
|
+
}
|
|
3670
|
+
//#endregion
|
|
3477
3671
|
//#region ../../packages/review-harness/src/workspace/diagram_view.ts
|
|
3478
3672
|
/**
|
|
3479
3673
|
* The diagrams a review holds, drawn as cards in one scrolling pane.
|
|
@@ -5582,7 +5776,7 @@ function updateFacts(outcome) {
|
|
|
5582
5776
|
counts.affected ? `${counts.affected} to look at again` : "",
|
|
5583
5777
|
counts.questionsAnswered ? `${plural(counts.questionsAnswered, "question", "questions")} answered` : ""
|
|
5584
5778
|
].filter(Boolean);
|
|
5585
|
-
const grades = counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [];
|
|
5779
|
+
const grades = [...counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [], ...counts.decisionsChallenged ? [`${plural(counts.decisionsChallenged, "accepted answer", "accepted answers")} put back to you`] : []];
|
|
5586
5780
|
const basis = outcome.reassessed;
|
|
5587
5781
|
return [...basis && !basis.code ? [escapeHtml$1("Reassessed against the conversation")] : [`<code>${escapeHtml$1(outcome.toSha.slice(0, 7))}</code>`, ...basis?.discussion ? [escapeHtml$1("with the conversation")] : []], ...[...changes.length ? changes : ["No change to the findings"], ...grades].map(escapeHtml$1)];
|
|
5588
5782
|
}
|
|
@@ -6271,7 +6465,7 @@ function renderReviewBody(view, sections) {
|
|
|
6271
6465
|
${renderRail(view, sections, updating(view))}
|
|
6272
6466
|
<main class="prb-doc">
|
|
6273
6467
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
6274
|
-
${offersFeedback(view) ? renderFeedbackTray() : ""}
|
|
6468
|
+
${offersFeedback(view) ? renderFeedbackTray(reviewTag(view.session.id)) : ""}
|
|
6275
6469
|
</main>
|
|
6276
6470
|
${result ? renderAsk(view, sections, updating(view)) : ""}
|
|
6277
6471
|
${result ? renderAskTab() : ""}
|
|
@@ -6446,7 +6640,8 @@ function renderStandardViews(view, sections) {
|
|
|
6446
6640
|
${renderIssuesTab(session, null, {
|
|
6447
6641
|
askBuddy: true,
|
|
6448
6642
|
feedbackSelect: true,
|
|
6449
|
-
reassess: true
|
|
6643
|
+
reassess: true,
|
|
6644
|
+
findingRefs: true
|
|
6450
6645
|
})}
|
|
6451
6646
|
</section>
|
|
6452
6647
|
<section class="prb-view" data-view="questions" hidden>
|
|
@@ -6455,7 +6650,8 @@ function renderStandardViews(view, sections) {
|
|
|
6455
6650
|
suppressFileJump: true,
|
|
6456
6651
|
askBuddy: true,
|
|
6457
6652
|
feedbackSelect: true,
|
|
6458
|
-
reassess: true
|
|
6653
|
+
reassess: true,
|
|
6654
|
+
findingRefs: true
|
|
6459
6655
|
})}
|
|
6460
6656
|
</section>
|
|
6461
6657
|
<section class="prb-view" data-view="prcontext" hidden>
|
|
@@ -6840,7 +7036,7 @@ function renderRail(view, sections, stale = false) {
|
|
|
6840
7036
|
</div>
|
|
6841
7037
|
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
|
|
6842
7038
|
${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
|
|
6843
|
-
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
|
|
7039
|
+
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered, verdictClosure(view.session).question), "question", "answered"), void 0, void 0, true)}
|
|
6844
7040
|
${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
|
|
6845
7041
|
${view.prContext ? railLink("prcontext", "prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "prcontext", "PR context", null, "No pull request was connected to this review")}`
|
|
6846
7042
|
}) : "";
|
|
@@ -7002,7 +7198,7 @@ function totalBadge(count, noun) {
|
|
|
7002
7198
|
* with all of them", and a tick would claim the second on a review that earned the first.
|
|
7003
7199
|
*/
|
|
7004
7200
|
/** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
|
|
7005
|
-
function plural$
|
|
7201
|
+
function plural$2(n, noun) {
|
|
7006
7202
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
7007
7203
|
}
|
|
7008
7204
|
function remainingBadge(counts, noun, verb) {
|
|
@@ -7012,16 +7208,16 @@ function remainingBadge(counts, noun, verb) {
|
|
|
7012
7208
|
};
|
|
7013
7209
|
if (counts.open === 0) return {
|
|
7014
7210
|
text: "✓",
|
|
7015
|
-
label: `All ${plural$
|
|
7211
|
+
label: `All ${plural$2(counts.total, noun)} ${verb}`,
|
|
7016
7212
|
done: true
|
|
7017
7213
|
};
|
|
7018
7214
|
if (counts.done === 0) return {
|
|
7019
7215
|
text: String(counts.open),
|
|
7020
|
-
label: plural$
|
|
7216
|
+
label: plural$2(counts.total, noun)
|
|
7021
7217
|
};
|
|
7022
7218
|
return {
|
|
7023
7219
|
text: String(counts.open),
|
|
7024
|
-
label: `${counts.open} of ${plural$
|
|
7220
|
+
label: `${counts.open} of ${plural$2(counts.total, noun)} left, ${counts.done} ${verb}`
|
|
7025
7221
|
};
|
|
7026
7222
|
}
|
|
7027
7223
|
/**
|
|
@@ -7045,9 +7241,9 @@ function findingsBadge(counts) {
|
|
|
7045
7241
|
*/
|
|
7046
7242
|
function themeFindingsNote(counts) {
|
|
7047
7243
|
if (counts.total === 0) return null;
|
|
7048
|
-
if (counts.open === 0) return `all ${plural$
|
|
7049
|
-
if (counts.done === 0) return plural$
|
|
7050
|
-
return `${counts.open} of ${plural$
|
|
7244
|
+
if (counts.open === 0) return `all ${plural$2(counts.total, "issue")} ${doneVerb(counts)}`;
|
|
7245
|
+
if (counts.done === 0) return plural$2(counts.open, "issue");
|
|
7246
|
+
return `${counts.open} of ${plural$2(counts.total, "issue")} left`;
|
|
7051
7247
|
}
|
|
7052
7248
|
/**
|
|
7053
7249
|
* `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
|
|
@@ -7339,7 +7535,8 @@ function renderAsk(view, sections, paused = false) {
|
|
|
7339
7535
|
${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
|
|
7340
7536
|
<div class="prb-ask-log" data-ask-log>
|
|
7341
7537
|
${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
7342
|
-
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div
|
|
7538
|
+
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>
|
|
7539
|
+
${paused ? "" : renderTurnActions(exchange, waitingProposals(view.session, exchange.reconciles?.proposals))}`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
|
|
7343
7540
|
<p>Ask about this change. Answers come only from the review, the diff and the repository on disk.</p>
|
|
7344
7541
|
${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
|
|
7345
7542
|
</div>`}
|
|
@@ -7361,6 +7558,12 @@ function renderAsk(view, sections, paused = false) {
|
|
|
7361
7558
|
* The list is always present, even when empty: an empty bank with an Add button explains what
|
|
7362
7559
|
* the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
|
|
7363
7560
|
*/
|
|
7561
|
+
/** Of the suggestions a reconcile made, the ones still waiting on the reviewer on this analysis. */
|
|
7562
|
+
function waitingProposals(session, ids) {
|
|
7563
|
+
if (!ids?.length) return [];
|
|
7564
|
+
const wanted = new Set(ids);
|
|
7565
|
+
return (session.findingReassessments ?? []).filter((entry) => wanted.has(entry.id) && isOffered(session, entry)).map((entry) => entry.id);
|
|
7566
|
+
}
|
|
7364
7567
|
function renderPromptBank(prompts, collapsed = false) {
|
|
7365
7568
|
return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
|
|
7366
7569
|
<div class="prb-bank-head">
|
|
@@ -8258,7 +8461,7 @@ function readPrStates() {
|
|
|
8258
8461
|
if (parsed.version !== VERSION$2) return {};
|
|
8259
8462
|
const entries = parsed.entries;
|
|
8260
8463
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
|
|
8261
|
-
return normalise$
|
|
8464
|
+
return normalise$2(entries);
|
|
8262
8465
|
} catch {
|
|
8263
8466
|
return {};
|
|
8264
8467
|
}
|
|
@@ -8283,7 +8486,7 @@ function prunePrStates(states, referenced) {
|
|
|
8283
8486
|
* state with no time or a time with no state says nothing, and an unknown state must not be read
|
|
8284
8487
|
* as one this build knows.
|
|
8285
8488
|
*/
|
|
8286
|
-
function normalise$
|
|
8489
|
+
function normalise$2(entries) {
|
|
8287
8490
|
const states = {};
|
|
8288
8491
|
for (const [key, value] of Object.entries(entries)) {
|
|
8289
8492
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
@@ -8398,7 +8601,7 @@ function readWatchDocument() {
|
|
|
8398
8601
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return null;
|
|
8399
8602
|
return {
|
|
8400
8603
|
version,
|
|
8401
|
-
entries: normalise(entries)
|
|
8604
|
+
entries: normalise$1(entries)
|
|
8402
8605
|
};
|
|
8403
8606
|
} catch {
|
|
8404
8607
|
return null;
|
|
@@ -8411,7 +8614,7 @@ function readWatch() {
|
|
|
8411
8614
|
if (parsed.version !== VERSION) return {};
|
|
8412
8615
|
const entries = parsed.entries;
|
|
8413
8616
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
|
|
8414
|
-
return normalise(entries);
|
|
8617
|
+
return normalise$1(entries);
|
|
8415
8618
|
} catch {
|
|
8416
8619
|
return {};
|
|
8417
8620
|
}
|
|
@@ -8452,7 +8655,7 @@ function pruneWatch(watched) {
|
|
|
8452
8655
|
* `notifiedSha` that survived as a number would compare unequal to every real sha and announce on
|
|
8453
8656
|
* every cycle for ever.
|
|
8454
8657
|
*/
|
|
8455
|
-
function normalise(entries) {
|
|
8658
|
+
function normalise$1(entries) {
|
|
8456
8659
|
const state = {};
|
|
8457
8660
|
for (const [id, value] of Object.entries(entries)) {
|
|
8458
8661
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
@@ -11201,7 +11404,7 @@ function renderPrivacySection(settings) {
|
|
|
11201
11404
|
* A record with no facts -- every one written before 0.74.0 -- gets no line, and its row looks as
|
|
11202
11405
|
* it always did.
|
|
11203
11406
|
*/
|
|
11204
|
-
var plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
11407
|
+
var plural$1 = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
11205
11408
|
function shortSha(sha) {
|
|
11206
11409
|
return sha.slice(0, 7);
|
|
11207
11410
|
}
|
|
@@ -11213,15 +11416,15 @@ function headMoveLine(notification, facts) {
|
|
|
11213
11416
|
const where = notification.source === "branch" ? "locally" : "on the pull request";
|
|
11214
11417
|
const parts = [`${step(facts.fromSha, notification.headSha)} ${where}`];
|
|
11215
11418
|
if (facts.rewritten) parts.push("branch rewritten since the review");
|
|
11216
|
-
else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural(facts.behind, "commit")} behind`);
|
|
11419
|
+
else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural$1(facts.behind, "commit")} behind`);
|
|
11217
11420
|
else parts.push("review behind");
|
|
11218
11421
|
return parts.join(" · ");
|
|
11219
11422
|
}
|
|
11220
11423
|
/** The card's own words for what is left (`whatIsLeft`), so a row and a card never disagree in vocabulary. */
|
|
11221
11424
|
function reviewLine(facts) {
|
|
11222
11425
|
const parts = [];
|
|
11223
|
-
if (facts.openIssues > 0) parts.push(plural(facts.openIssues, "issue"));
|
|
11224
|
-
if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
|
|
11426
|
+
if (facts.openIssues > 0) parts.push(plural$1(facts.openIssues, "issue"));
|
|
11427
|
+
if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
|
|
11225
11428
|
if (parts.length === 0) parts.push("Nothing flagged");
|
|
11226
11429
|
if (facts.risk) parts.push(`${facts.risk} risk`);
|
|
11227
11430
|
return parts.join(" · ");
|
|
@@ -11244,8 +11447,8 @@ function updateLine(facts) {
|
|
|
11244
11447
|
const delta = updateDelta(facts);
|
|
11245
11448
|
parts.push(...delta.length > 0 ? delta : ["findings unchanged"]);
|
|
11246
11449
|
if (facts.stillOpen > 0) parts.push(`${facts.stillOpen} still open`);
|
|
11247
|
-
if (facts.questionsAnswered > 0) parts.push(`${plural(facts.questionsAnswered, "question")} answered`);
|
|
11248
|
-
if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
|
|
11450
|
+
if (facts.questionsAnswered > 0) parts.push(`${plural$1(facts.questionsAnswered, "question")} answered`);
|
|
11451
|
+
if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
|
|
11249
11452
|
if (facts.fetchFailed) parts.push("remote not reached");
|
|
11250
11453
|
else if (facts.upToDate) parts.push("up to date");
|
|
11251
11454
|
return parts.join(" · ");
|
|
@@ -11281,7 +11484,7 @@ function toastFact(notification) {
|
|
|
11281
11484
|
return delta.length > 0 ? delta.slice(0, 2).join(", ") : "findings unchanged";
|
|
11282
11485
|
}
|
|
11283
11486
|
const facts = notification.facts;
|
|
11284
|
-
return facts.openIssues > 0 ? plural(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
|
|
11487
|
+
return facts.openIssues > 0 ? plural$1(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
|
|
11285
11488
|
}
|
|
11286
11489
|
/**
|
|
11287
11490
|
* A toast's whole line: what happened, to what, and at most one fact.
|
|
@@ -11652,6 +11855,26 @@ feature: E</pre>
|
|
|
11652
11855
|
<li><b>Delete</b> removes a review and the checkouts it was made in. See
|
|
11653
11856
|
<a href="#reviews-and-ids">Reviews and review IDs</a>.</li>
|
|
11654
11857
|
</ul>`
|
|
11858
|
+
},
|
|
11859
|
+
{
|
|
11860
|
+
id: "author-replies",
|
|
11861
|
+
title: "Asking the author",
|
|
11862
|
+
body: `<p>Every issue and question has a reference, such as <b>I3</b> or <b>Q2</b>, shown on its
|
|
11863
|
+
card. <b>Prepare feedback</b> on Issues or Questions turns the ones you choose into a draft to send
|
|
11864
|
+
the author, each led by its reference, so the author can answer “Q2: …”.</p>
|
|
11865
|
+
<p>When the reply comes back, paste it into Buddy AI as one message and press <b>Reconcile with
|
|
11866
|
+
findings</b> under it. Only that message is read. Buddy AI reads it against the review and
|
|
11867
|
+
suggests what it settles: a question answered or partly answered, an issue resolved, or one that
|
|
11868
|
+
needs investigating. Each suggestion appears on its card with the words it rests on, and nothing
|
|
11869
|
+
changes until you accept it.</p>
|
|
11870
|
+
<p>The draft ends with the review it came from. A reply naming a different review, such as one
|
|
11871
|
+
analysed afresh since, is not reconciled, because its references number different findings.</p>
|
|
11872
|
+
<p>Buddy AI is asked to read the code before suggesting an issue is resolved, and to cite the lines
|
|
11873
|
+
that show it. The review checks that the cited files exist, not that the code fixes anything, so
|
|
11874
|
+
read the cited lines before you accept. A fix the author has not pushed, or has only promised, is
|
|
11875
|
+
never suggested as resolved. A partly answered question and an issue that needs investigating stay
|
|
11876
|
+
open. If a later reply or new commits contradict an answer you accepted, you are asked again
|
|
11877
|
+
rather than the answer being undone.</p>`
|
|
11655
11878
|
},
|
|
11656
11879
|
{
|
|
11657
11880
|
id: "reviews-and-ids",
|
|
@@ -13769,6 +13992,7 @@ function gradeFragments$1(session, kind, id) {
|
|
|
13769
13992
|
const finding = findingIn(session, kind, id);
|
|
13770
13993
|
if (!finding) return null;
|
|
13771
13994
|
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
13995
|
+
const status = findingStatus(session.findingReassessments, kind, id, analysisRefOf(session));
|
|
13772
13996
|
const options = {
|
|
13773
13997
|
editable: true,
|
|
13774
13998
|
askMore: true
|
|
@@ -13779,8 +14003,9 @@ function gradeFragments$1(session, kind, id) {
|
|
|
13779
14003
|
current: assessment.current,
|
|
13780
14004
|
cardClass: cardGradeClass(kind, assessment.current),
|
|
13781
14005
|
grade: renderGradeControl(kind, assessment, options),
|
|
13782
|
-
region: renderAssessmentRegion(kind, assessment, options),
|
|
13783
|
-
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
14006
|
+
region: renderAssessmentRegion(kind, assessment, options, status),
|
|
14007
|
+
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment),
|
|
14008
|
+
verdictClosed: closesFinding(status.current)
|
|
13784
14009
|
};
|
|
13785
14010
|
}
|
|
13786
14011
|
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
@@ -13862,7 +14087,7 @@ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
|
|
|
13862
14087
|
//#endregion
|
|
13863
14088
|
//#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
|
|
13864
14089
|
/** More than any real answer cites; a list longer than this is padding, not evidence. */
|
|
13865
|
-
var MAX_EVIDENCE = 10;
|
|
14090
|
+
var MAX_EVIDENCE$1 = 10;
|
|
13866
14091
|
function scaleText(kind) {
|
|
13867
14092
|
return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
|
|
13868
14093
|
}
|
|
@@ -13914,7 +14139,7 @@ function buildReassessInstructions(input) {
|
|
|
13914
14139
|
];
|
|
13915
14140
|
}
|
|
13916
14141
|
var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
|
|
13917
|
-
function readEvidence(value) {
|
|
14142
|
+
function readEvidence$1(value) {
|
|
13918
14143
|
if (!Array.isArray(value)) return [];
|
|
13919
14144
|
const out = [];
|
|
13920
14145
|
for (const item of value) {
|
|
@@ -13926,7 +14151,7 @@ function readEvidence(value) {
|
|
|
13926
14151
|
file,
|
|
13927
14152
|
line
|
|
13928
14153
|
});
|
|
13929
|
-
if (out.length === MAX_EVIDENCE) break;
|
|
14154
|
+
if (out.length === MAX_EVIDENCE$1) break;
|
|
13930
14155
|
}
|
|
13931
14156
|
return out;
|
|
13932
14157
|
}
|
|
@@ -13947,7 +14172,7 @@ function parseReassessAnswer(raw, kind, current) {
|
|
|
13947
14172
|
const obj = parsed;
|
|
13948
14173
|
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
13949
14174
|
if (!reason) return null;
|
|
13950
|
-
const evidence = readEvidence(obj.evidence);
|
|
14175
|
+
const evidence = readEvidence$1(obj.evidence);
|
|
13951
14176
|
if (obj.verdict === "keep") return {
|
|
13952
14177
|
verdict: "keep",
|
|
13953
14178
|
reason,
|
|
@@ -14030,6 +14255,267 @@ function reassessTurnText(target, turn, outcome) {
|
|
|
14030
14255
|
const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
|
|
14031
14256
|
return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
|
|
14032
14257
|
}
|
|
14258
|
+
/** More than any finding a real reply rests on; past this it is padding. */
|
|
14259
|
+
var MAX_QUOTES = 4;
|
|
14260
|
+
var MAX_EVIDENCE = 10;
|
|
14261
|
+
/** The turns, labelled in the order the reviewer wrote them. */
|
|
14262
|
+
function labelTurns(turns) {
|
|
14263
|
+
return [...turns].sort((a, b) => a.askedAt - b.askedAt).map((turn, index) => ({
|
|
14264
|
+
label: `A${index + 1}`,
|
|
14265
|
+
askedAt: turn.askedAt,
|
|
14266
|
+
text: turn.question
|
|
14267
|
+
}));
|
|
14268
|
+
}
|
|
14269
|
+
function issueLine(issue, index, assess, status) {
|
|
14270
|
+
const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
|
|
14271
|
+
const severity = ` [severity: ${promptGrade(assess("issue", issue))}]`;
|
|
14272
|
+
return `${findingRef("issue", index)}.${severity}${where}${priorStatus(issue.reconciliationStatus === "resolved" ? "resolved by the code" : void 0)}${acceptedLine(status("issue", issue.id))} ${issue.title.trim()}\n ${issue.description.trim().replace(/\n/g, "\n ")}`;
|
|
14273
|
+
}
|
|
14274
|
+
function questionLine(question, index, assess, status, ticked) {
|
|
14275
|
+
const importance = assess("question", question).current ? ` [importance: ${promptGrade(assess("question", question))}]` : "";
|
|
14276
|
+
const where = question.file ? ` [${question.file}]` : "";
|
|
14277
|
+
const why = question.whyThisMatters?.trim() ? `\n ${question.whyThisMatters.trim()}` : "";
|
|
14278
|
+
const prior = question.reconciliationStatus === "answered" ? "answered by the code" : ticked ? "marked answered by the reviewer" : void 0;
|
|
14279
|
+
return `${findingRef("question", index)}.${importance}${where}${priorStatus(prior)}${acceptedLine(status("question", question.id))} ${question.question.trim()}${why}`;
|
|
14280
|
+
}
|
|
14281
|
+
function priorStatus(text) {
|
|
14282
|
+
return text ? ` [${text}]` : "";
|
|
14283
|
+
}
|
|
14284
|
+
/**
|
|
14285
|
+
* The verdict the reviewer accepted earlier, said as their decision and not as a fact, with what it
|
|
14286
|
+
* rested on, so evidence that contradicts it is recognised as a contradiction.
|
|
14287
|
+
*/
|
|
14288
|
+
function acceptedLine(status) {
|
|
14289
|
+
if (!status.current) return "";
|
|
14290
|
+
const last = status.history[status.history.length - 1];
|
|
14291
|
+
return ` [the reviewer accepted "${status.current}" ${sourcePhrase(last.source)}${last.reason ? `: ${last.reason}` : ""}]`;
|
|
14292
|
+
}
|
|
14293
|
+
var SHAPE = `{
|
|
14294
|
+
"findings": [
|
|
14295
|
+
{
|
|
14296
|
+
"ref": string,
|
|
14297
|
+
"status": "answered" | "partially-answered" | "still-open" | "resolved" | "needs-investigation",
|
|
14298
|
+
"reason": string,
|
|
14299
|
+
"answer"?: string,
|
|
14300
|
+
"basis"?: "premise" | "code",
|
|
14301
|
+
"evidence"?: [{ "file": string, "line"?: number }],
|
|
14302
|
+
"quotes": [{ "turn": string, "text": string }],
|
|
14303
|
+
"severity"?: ${gradeScale("issue").map((grade) => `"${grade}"`).join(" | ")},
|
|
14304
|
+
"importance"?: ${gradeScale("question").map((grade) => `"${grade}"`).join(" | ")},
|
|
14305
|
+
"gradeReason"?: string
|
|
14306
|
+
}
|
|
14307
|
+
],
|
|
14308
|
+
"unmatched": [{ "turn": string, "text": string, "candidates": string[], "why": string }]
|
|
14309
|
+
}`;
|
|
14310
|
+
/** The block a reconcile turn adds to Buddy AI's prompt: the turns, the findings, the rules and the shape. */
|
|
14311
|
+
function buildReplyReconciliationInstructions(input) {
|
|
14312
|
+
const { result, assess, status } = input;
|
|
14313
|
+
const issues = result.issues.length ? result.issues.map((issue, index) => issueLine(issue, index, assess, status)).join("\n") : "(The review raised no issues.)";
|
|
14314
|
+
const questions = result.questions.length ? result.questions.map((question, index) => questionLine(question, index, assess, status, input.answeredQuestionIds.has(question.id))).join("\n") : "(The review raised no questions.)";
|
|
14315
|
+
return [
|
|
14316
|
+
"---",
|
|
14317
|
+
"RECONCILE WITH FINDINGS",
|
|
14318
|
+
"The reviewer has asked you to read the turns below, which they wrote in this conversation, as evidence about the review's existing issues and questions. Most often they are an author's replies to feedback the reviewer sent, pasted in. Nothing you return changes a finding: each judgement becomes a suggestion the reviewer accepts or rejects on its own.",
|
|
14319
|
+
"",
|
|
14320
|
+
"THE TURNS (the reviewer's own messages, labelled; Buddy AI's answers are not evidence):",
|
|
14321
|
+
...input.turns.map((turn) => `[${turn.label}]\n${turn.text.trim()}`),
|
|
14322
|
+
"",
|
|
14323
|
+
input.review.named ? `The turns name this review (${input.review.tag}), so the references in them are this review's.` : `The turns do not name this review (${input.review.tag}). A reference in them may come from a draft of an earlier analysis whose findings were numbered differently: use a reference only where what the passage says matches that finding, and otherwise treat the passage as unmatched.`,
|
|
14324
|
+
"",
|
|
14325
|
+
"THE FINDINGS, by the reference the review's cards and its feedback drafts use:",
|
|
14326
|
+
"Issues:",
|
|
14327
|
+
issues,
|
|
14328
|
+
"Questions:",
|
|
14329
|
+
questions,
|
|
14330
|
+
"",
|
|
14331
|
+
"RULES",
|
|
14332
|
+
"- Match a passage to a finding by its reference where it names one (\"Q2:\", \"I3.\"), and otherwise only by what it unmistakably addresses. Never match a passage in order to produce a judgement. Where a passage could be about more than one finding, or you cannot tell which it answers, put it in \"unmatched\" with its candidate references and leave those findings out. The turns need not follow the order of the findings, one passage may address several findings, and one finding may be addressed across several turns.",
|
|
14333
|
+
"- Include only the findings the turns address. Leave every other finding out: an omitted finding is one the turns do not bear on, not one that is still open.",
|
|
14334
|
+
"- A question is \"answered\" only when the turns answer its substance. A reply that acknowledges it, defers it, restates it or answers a different question does not answer it. Where they answer part of it, use \"partially-answered\" and say in \"answer\" what they establish and what is still missing. Where they address it without answering it, use \"still-open\". For \"answered\" and \"partially-answered\", put the answer itself in \"answer\", in a sentence or two.",
|
|
14335
|
+
"- What an author says about an issue is one of three things, and they are judged differently:",
|
|
14336
|
+
" 1. The concern never held: an assumption the issue made is false (the code concerned is unreachable, is being removed, or a requirement it assumed does not exist). That may be \"resolved\" with \"basis\": \"premise\". Check the claim against the code wherever the code can settle it, and where the code contradicts it, it is not resolved.",
|
|
14337
|
+
" 2. It is already implemented: the code at the reviewed commit is said to handle it. That may be \"resolved\" with \"basis\": \"code\" only after you have opened that code and read it, and only when it addresses this concern, not merely when the file exists or touches the same area. List in \"evidence\" each place, with its line, where the code does so. A file name on its own is not evidence, and neither is code that exists but does not address the concern.",
|
|
14338
|
+
" 3. It is fixed somewhere you cannot read, or will be: fixed locally, in another branch, in a commit not yet pushed, or planned. That is a claim about code, not evidence, and never \"resolved\". Use \"needs-investigation\" where the claim deserves checking once the code exists, otherwise \"still-open\". Agreeing that it is a problem, with a plan to fix it later, is \"still-open\".",
|
|
14339
|
+
"- \"needs-investigation\": the turns raise something that bears on the issue and that only further reading or a further question can settle. Say what in \"reason\".",
|
|
14340
|
+
"- Treat what the author asserts as context, not proof. Where you can check a claim in the repository, check it before relying on it.",
|
|
14341
|
+
"- A verdict the reviewer accepted earlier is marked on its finding. It was their decision, not a fact. Where the turns contradict it, report the finding as \"still-open\", with the contradiction in \"reason\", so it is put to them.",
|
|
14342
|
+
"- Every judgement must quote, in \"quotes\", the exact words from the turns it rests on, copied character for character (a sentence or two each), with the turn's label. A judgement whose words cannot be found in the turn it names is discarded.",
|
|
14343
|
+
`- "severity" on an issue or "importance" on a question is optional: only where the turns change how much the finding matters, never the grade it already has, and always with "gradeReason".`,
|
|
14344
|
+
"- In \"reason\" and \"answer\", name the author by role (\"the author confirms the job is retired\"), and write no references or labels: the reviewer reads those sentences beside the finding.",
|
|
14345
|
+
"",
|
|
14346
|
+
"Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:",
|
|
14347
|
+
SHAPE,
|
|
14348
|
+
"\"status\" on a question (a Q reference) is \"answered\", \"partially-answered\" or \"still-open\"; on an issue (an I reference) it is \"resolved\", \"needs-investigation\" or \"still-open\". \"basis\" and \"evidence\" go only with \"resolved\". \"severity\" goes only on an issue and \"importance\" only on a question.",
|
|
14349
|
+
"Empty arrays are valid and far better than judgements the turns do not support."
|
|
14350
|
+
];
|
|
14351
|
+
}
|
|
14352
|
+
var QUESTION_STATUSES = [
|
|
14353
|
+
"answered",
|
|
14354
|
+
"partially-answered",
|
|
14355
|
+
"still-open"
|
|
14356
|
+
];
|
|
14357
|
+
var ISSUE_STATUSES = [
|
|
14358
|
+
"resolved",
|
|
14359
|
+
"needs-investigation",
|
|
14360
|
+
"still-open"
|
|
14361
|
+
];
|
|
14362
|
+
var ANSWER = objectWithAnyOf("findings", "unmatched");
|
|
14363
|
+
/** Whitespace and quote marks as a model may retype them: two texts are the same words if these agree. */
|
|
14364
|
+
function normalise(text) {
|
|
14365
|
+
return text.replace(/[‘’]/g, "'").replace(/[“”]/g, "\"").replace(/\s+/g, " ").trim();
|
|
14366
|
+
}
|
|
14367
|
+
/** The quotes found, word for word, in the turns they name. Nothing else is kept. */
|
|
14368
|
+
function verifiedQuotes(value, turns) {
|
|
14369
|
+
if (!Array.isArray(value)) return [];
|
|
14370
|
+
const byLabel = new Map(turns.map((turn) => [turn.label.toUpperCase(), turn]));
|
|
14371
|
+
const kept = [];
|
|
14372
|
+
for (const item of value) {
|
|
14373
|
+
const quote = item;
|
|
14374
|
+
if (typeof quote?.turn !== "string" || typeof quote.text !== "string") continue;
|
|
14375
|
+
const turn = byLabel.get(quote.turn.trim().replace(/^\[|\]$/g, "").toUpperCase());
|
|
14376
|
+
const text = normalise(quote.text);
|
|
14377
|
+
if (!turn || !text || text.length > 600) continue;
|
|
14378
|
+
if (!normalise(turn.text).includes(text)) continue;
|
|
14379
|
+
if (kept.some((existing) => existing.askedAt === turn.askedAt && existing.text === text)) continue;
|
|
14380
|
+
kept.push({
|
|
14381
|
+
askedAt: turn.askedAt,
|
|
14382
|
+
text
|
|
14383
|
+
});
|
|
14384
|
+
if (kept.length === MAX_QUOTES) break;
|
|
14385
|
+
}
|
|
14386
|
+
return kept;
|
|
14387
|
+
}
|
|
14388
|
+
function readEvidence(value) {
|
|
14389
|
+
if (!Array.isArray(value)) return [];
|
|
14390
|
+
return value.flatMap((item) => {
|
|
14391
|
+
const place = item;
|
|
14392
|
+
if (typeof place?.file !== "string" || !place.file.trim()) return [];
|
|
14393
|
+
const line = typeof place.line === "number" && Number.isInteger(place.line) && place.line > 0 ? place.line : void 0;
|
|
14394
|
+
return [{
|
|
14395
|
+
file: place.file.trim(),
|
|
14396
|
+
...line ? { line } : {}
|
|
14397
|
+
}];
|
|
14398
|
+
}).slice(0, MAX_EVIDENCE);
|
|
14399
|
+
}
|
|
14400
|
+
function text(value) {
|
|
14401
|
+
return typeof value === "string" ? value.trim() : "";
|
|
14402
|
+
}
|
|
14403
|
+
/**
|
|
14404
|
+
* The answer, read strictly. Null when it is not an answer at all; otherwise every judgement that
|
|
14405
|
+
* names a finding the review holds, with a status on that finding's scale, a reason, and at least one
|
|
14406
|
+
* quote found in the turns. A judgement missing any of those is counted and dropped, never repaired.
|
|
14407
|
+
*/
|
|
14408
|
+
function parseReplyReconciliation(raw, result, turns) {
|
|
14409
|
+
let parsed;
|
|
14410
|
+
try {
|
|
14411
|
+
parsed = JSON.parse(extractJsonText(raw, ANSWER));
|
|
14412
|
+
} catch {
|
|
14413
|
+
return null;
|
|
14414
|
+
}
|
|
14415
|
+
if (!ANSWER(parsed)) return null;
|
|
14416
|
+
const obj = parsed;
|
|
14417
|
+
const discarded = {
|
|
14418
|
+
unknownRef: 0,
|
|
14419
|
+
unquoted: 0
|
|
14420
|
+
};
|
|
14421
|
+
const judgements = [];
|
|
14422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14423
|
+
for (const item of Array.isArray(obj.findings) ? obj.findings : []) {
|
|
14424
|
+
const entry = item;
|
|
14425
|
+
const at = typeof entry?.ref === "string" ? findingAtRef(result, entry.ref) : null;
|
|
14426
|
+
if (!at) {
|
|
14427
|
+
discarded.unknownRef += 1;
|
|
14428
|
+
continue;
|
|
14429
|
+
}
|
|
14430
|
+
const allowed = at.kind === "issue" ? ISSUE_STATUSES : QUESTION_STATUSES;
|
|
14431
|
+
const status = entry.status;
|
|
14432
|
+
const reason = text(entry.reason);
|
|
14433
|
+
if (typeof status !== "string" || !allowed.includes(status) || !reason) {
|
|
14434
|
+
discarded.unknownRef += 1;
|
|
14435
|
+
continue;
|
|
14436
|
+
}
|
|
14437
|
+
const quotes = verifiedQuotes(entry.quotes, turns);
|
|
14438
|
+
if (!quotes.length) {
|
|
14439
|
+
discarded.unquoted += 1;
|
|
14440
|
+
continue;
|
|
14441
|
+
}
|
|
14442
|
+
const key = `${at.kind}:${at.index}`;
|
|
14443
|
+
if (seen.has(key)) continue;
|
|
14444
|
+
seen.add(key);
|
|
14445
|
+
const answer = text(entry.answer);
|
|
14446
|
+
const basis = entry.basis === "premise" || entry.basis === "code" ? entry.basis : void 0;
|
|
14447
|
+
const gradeTo = at.kind === "issue" ? entry.severity : entry.importance;
|
|
14448
|
+
const gradeReason = text(entry.gradeReason);
|
|
14449
|
+
judgements.push({
|
|
14450
|
+
kind: at.kind,
|
|
14451
|
+
index: at.index,
|
|
14452
|
+
findingId: at.finding.id,
|
|
14453
|
+
ref: findingRef(at.kind, at.index),
|
|
14454
|
+
status,
|
|
14455
|
+
reason,
|
|
14456
|
+
...answer ? { answer } : {},
|
|
14457
|
+
...basis ? { basis } : {},
|
|
14458
|
+
evidence: readEvidence(entry.evidence),
|
|
14459
|
+
quotes,
|
|
14460
|
+
...isGradeFor(at.kind, gradeTo) && gradeReason ? { grade: {
|
|
14461
|
+
to: gradeTo,
|
|
14462
|
+
reason: gradeReason
|
|
14463
|
+
} } : {}
|
|
14464
|
+
});
|
|
14465
|
+
}
|
|
14466
|
+
return {
|
|
14467
|
+
judgements,
|
|
14468
|
+
unmatched: (Array.isArray(obj.unmatched) ? obj.unmatched : []).flatMap((item) => {
|
|
14469
|
+
const passage = item;
|
|
14470
|
+
const words = text(passage?.text);
|
|
14471
|
+
if (!words) return [];
|
|
14472
|
+
const candidates = (Array.isArray(passage.candidates) ? passage.candidates : []).filter((ref) => typeof ref === "string" && findingAtRef(result, ref) !== null).map((ref) => ref.trim().toUpperCase());
|
|
14473
|
+
return [{
|
|
14474
|
+
text: words.slice(0, 600),
|
|
14475
|
+
candidates,
|
|
14476
|
+
why: text(passage.why)
|
|
14477
|
+
}];
|
|
14478
|
+
}),
|
|
14479
|
+
discarded
|
|
14480
|
+
};
|
|
14481
|
+
}
|
|
14482
|
+
/** Said beside a resolution the harness could not let stand as one. */
|
|
14483
|
+
var UNSUPPORTED_RESOLUTION = "Treated as needing investigation: the reply was read as resolving it, but cited no line of the reviewed code that shows it.";
|
|
14484
|
+
/**
|
|
14485
|
+
* A resolution, checked against what it says it rests on, as far as the harness can check it.
|
|
14486
|
+
*
|
|
14487
|
+
* `premise` stands as the model judged it. `code` stands only with at least one cited place, with a
|
|
14488
|
+
* line, in a file that exists at the reviewed commit (`existing` is the set of paths found there).
|
|
14489
|
+
* Anything else is downgraded to "needs investigation", with the reason saying so.
|
|
14490
|
+
*
|
|
14491
|
+
* This is citation validation, not verification: a file that exists does not show that the code in it
|
|
14492
|
+
* fixes anything. Whether it does is the model's reading, which the prompt requires it to make by
|
|
14493
|
+
* opening the code, and the reviewer's to confirm before accepting. The card says as much.
|
|
14494
|
+
*/
|
|
14495
|
+
function groundResolution(judgement, existing) {
|
|
14496
|
+
if (judgement.kind !== "issue" || judgement.status !== "resolved") return judgement;
|
|
14497
|
+
const evidence = judgement.evidence.filter((place) => existing.has(place.file));
|
|
14498
|
+
if (judgement.basis === "premise") return {
|
|
14499
|
+
...judgement,
|
|
14500
|
+
evidence
|
|
14501
|
+
};
|
|
14502
|
+
if (judgement.basis === "code" && evidence.some((place) => place.line !== void 0)) return {
|
|
14503
|
+
...judgement,
|
|
14504
|
+
evidence
|
|
14505
|
+
};
|
|
14506
|
+
return {
|
|
14507
|
+
...judgement,
|
|
14508
|
+
status: "needs-investigation",
|
|
14509
|
+
evidence,
|
|
14510
|
+
reason: `${judgement.reason} ${UNSUPPORTED_RESOLUTION}`
|
|
14511
|
+
};
|
|
14512
|
+
}
|
|
14513
|
+
/** A proposal a judgement becomes on the status axis, or null when it is not one. */
|
|
14514
|
+
function verdictFor(judgement, current) {
|
|
14515
|
+
if (judgement.status !== "still-open") return judgement.status;
|
|
14516
|
+
if (!closesFinding(current)) return null;
|
|
14517
|
+
return judgement.kind === "issue" ? "open" : "unanswered";
|
|
14518
|
+
}
|
|
14033
14519
|
//#endregion
|
|
14034
14520
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
14035
14521
|
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
@@ -14144,6 +14630,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
|
|
|
14144
14630
|
"REVIEW GUIDE (JSON):",
|
|
14145
14631
|
JSON.stringify(workspace.session.result),
|
|
14146
14632
|
...reassessedGrades(workspace),
|
|
14633
|
+
...acceptedStatuses(workspace),
|
|
14147
14634
|
"---",
|
|
14148
14635
|
"DIFF:",
|
|
14149
14636
|
workspace.diff,
|
|
@@ -14161,6 +14648,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
|
|
|
14161
14648
|
...continuingFrom(workspace, context.continuing),
|
|
14162
14649
|
...explainingFinding(workspace, context.explaining),
|
|
14163
14650
|
...reassessingFinding(workspace, context.reassessing),
|
|
14651
|
+
...reconcilingTurns(workspace, context.reconciling),
|
|
14164
14652
|
"---",
|
|
14165
14653
|
`QUESTION: ${question}`
|
|
14166
14654
|
].join("\n");
|
|
@@ -14224,6 +14712,33 @@ function reassessedGrades(workspace) {
|
|
|
14224
14712
|
];
|
|
14225
14713
|
}
|
|
14226
14714
|
/**
|
|
14715
|
+
* The statuses the reviewer has accepted from later evidence, such as an author's reply. Listed only
|
|
14716
|
+
* when there are some, as the reviewer's decisions rather than facts, so a turn neither re-asks a
|
|
14717
|
+
* question they have accepted an answer to nor treats that answer as beyond question.
|
|
14718
|
+
*/
|
|
14719
|
+
function acceptedStatuses(workspace) {
|
|
14720
|
+
const result = workspace.session.result;
|
|
14721
|
+
if (!result) return [];
|
|
14722
|
+
const status = statusLookup(workspace.session);
|
|
14723
|
+
const lines = [...result.issues.map((issue) => ({
|
|
14724
|
+
name: `ISSUE "${issue.title.trim()}"`,
|
|
14725
|
+
status: status("issue", issue.id)
|
|
14726
|
+
})), ...result.questions.map((q) => ({
|
|
14727
|
+
name: `QUESTION "${q.question.trim()}"`,
|
|
14728
|
+
status: status("question", q.id)
|
|
14729
|
+
}))].flatMap(({ name, status: s }) => {
|
|
14730
|
+
if (!s.current) return [];
|
|
14731
|
+
const last = s.history[s.history.length - 1];
|
|
14732
|
+
return [`- ${name}: accepted as ${s.current}${last.reason ? ` (${last.reason})` : ""}${last.answer ? `. Answer: ${last.answer}` : ""}`];
|
|
14733
|
+
});
|
|
14734
|
+
if (!lines.length) return [];
|
|
14735
|
+
return [
|
|
14736
|
+
"---",
|
|
14737
|
+
"STATUSES THE REVIEWER HAS ACCEPTED FROM LATER EVIDENCE (their decisions, which later evidence may still contradict):",
|
|
14738
|
+
...lines
|
|
14739
|
+
];
|
|
14740
|
+
}
|
|
14741
|
+
/**
|
|
14227
14742
|
* Reassess on a finding: everything the judgement needs, and the exact shape of the answer. The
|
|
14228
14743
|
* thread the analysis tied the finding to goes in when it resolves in the conversation this review
|
|
14229
14744
|
* holds; when it does not, the turn goes ahead without it.
|
|
@@ -14243,12 +14758,290 @@ function reassessingFinding(workspace, reassessing) {
|
|
|
14243
14758
|
...thread ? { thread } : {}
|
|
14244
14759
|
});
|
|
14245
14760
|
}
|
|
14761
|
+
/**
|
|
14762
|
+
* Reconcile with findings: the chosen turns, every finding by its reference with the grade and any
|
|
14763
|
+
* verdict the review holds now, the evidence rules and the exact shape of the answer.
|
|
14764
|
+
*/
|
|
14765
|
+
function reconcilingTurns(workspace, reconciling) {
|
|
14766
|
+
const result = workspace.session.result;
|
|
14767
|
+
if (!reconciling || !result) return [];
|
|
14768
|
+
return buildReplyReconciliationInstructions({
|
|
14769
|
+
result,
|
|
14770
|
+
assess: assessmentLookup(workspace.session),
|
|
14771
|
+
status: statusLookup(workspace.session),
|
|
14772
|
+
answeredQuestionIds: new Set(workspace.session.answeredQuestionIds ?? []),
|
|
14773
|
+
turns: reconciling.turns,
|
|
14774
|
+
review: reconciling.review
|
|
14775
|
+
});
|
|
14776
|
+
}
|
|
14246
14777
|
/** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
|
|
14247
14778
|
function findingName(workspace, finding) {
|
|
14248
14779
|
const result = workspace.session.result;
|
|
14249
14780
|
const title = finding.kind === "issue" ? result?.issues.find((issue) => issue.id === finding.id)?.title : result?.questions.find((question) => question.id === finding.id)?.question;
|
|
14250
14781
|
return `THE ${finding.kind.toUpperCase()}${title ? ` "${title.trim()}"` : ""}`;
|
|
14251
14782
|
}
|
|
14783
|
+
var TURNS_REFUSED = {
|
|
14784
|
+
none: "Choose at least one turn of this conversation to reconcile with the findings.",
|
|
14785
|
+
unknown: "This conversation has no such turn to reconcile.",
|
|
14786
|
+
"too-many": `Reconcile reads at most 10 turns at once. Choose fewer.`,
|
|
14787
|
+
"too-long": "Those turns are too long to read in one reconcile. Choose fewer, or paste the reply in parts."
|
|
14788
|
+
};
|
|
14789
|
+
/**
|
|
14790
|
+
* The turns a request names, from the transcript as stored. Only the reviewer's own turns with words
|
|
14791
|
+
* in them: a reconcile turn is not evidence, and neither is anything Buddy AI wrote.
|
|
14792
|
+
*/
|
|
14793
|
+
function reconcileTurnsOf(transcript, value) {
|
|
14794
|
+
const asked = value?.exchanges;
|
|
14795
|
+
if (!Array.isArray(asked) || asked.length === 0) return {
|
|
14796
|
+
ok: false,
|
|
14797
|
+
refusal: "none"
|
|
14798
|
+
};
|
|
14799
|
+
const wanted = [...new Set(asked)];
|
|
14800
|
+
if (wanted.length > 10) return {
|
|
14801
|
+
ok: false,
|
|
14802
|
+
refusal: "too-many"
|
|
14803
|
+
};
|
|
14804
|
+
const turns = [];
|
|
14805
|
+
for (const askedAt of wanted) {
|
|
14806
|
+
const turn = typeof askedAt === "number" ? transcript.find((exchange) => exchange.askedAt === askedAt) : void 0;
|
|
14807
|
+
if (!turn || turn.reconciles || !turn.question.trim()) return {
|
|
14808
|
+
ok: false,
|
|
14809
|
+
refusal: "unknown"
|
|
14810
|
+
};
|
|
14811
|
+
turns.push(turn);
|
|
14812
|
+
}
|
|
14813
|
+
if (turns.reduce((total, turn) => total + turn.question.length, 0) > 4e4) return {
|
|
14814
|
+
ok: false,
|
|
14815
|
+
refusal: "too-long"
|
|
14816
|
+
};
|
|
14817
|
+
return {
|
|
14818
|
+
ok: true,
|
|
14819
|
+
turns
|
|
14820
|
+
};
|
|
14821
|
+
}
|
|
14822
|
+
function reviewIdentityOf(turns, sessionId) {
|
|
14823
|
+
const tag = reviewTag(sessionId);
|
|
14824
|
+
const named = [...new Set(turns.flatMap((turn) => reviewTagsIn(turn.question)))];
|
|
14825
|
+
if (!named.length) return {
|
|
14826
|
+
kind: "unnamed",
|
|
14827
|
+
tag
|
|
14828
|
+
};
|
|
14829
|
+
if (named.some((other) => other !== tag)) return {
|
|
14830
|
+
kind: "other",
|
|
14831
|
+
tag,
|
|
14832
|
+
named
|
|
14833
|
+
};
|
|
14834
|
+
return {
|
|
14835
|
+
kind: "named",
|
|
14836
|
+
tag
|
|
14837
|
+
};
|
|
14838
|
+
}
|
|
14839
|
+
/** Why a reply naming another review is refused, in the words the page shows. */
|
|
14840
|
+
function otherReviewRefusal(identity) {
|
|
14841
|
+
return `This reply names review ${identity.named.filter((named) => named !== identity.tag).join(" and ")}, but these findings are numbered for review ${identity.tag}. A fresh analysis numbers its findings again, so its references cannot be read against these ones. Nothing was reconciled. Prepare feedback again from this review, or reconcile the reply on the review it names.`;
|
|
14842
|
+
}
|
|
14843
|
+
/**
|
|
14844
|
+
* The answer, with every resolution checked against what it rests on (`groundResolution`). A check
|
|
14845
|
+
* that fails keeps no code evidence rather than unchecked evidence, so a code-based resolution then
|
|
14846
|
+
* falls to "needs investigation".
|
|
14847
|
+
*/
|
|
14848
|
+
async function readReconcileTurn(raw, result, turns, where, check = existingPaths) {
|
|
14849
|
+
const reading = parseReplyReconciliation(raw, result, turns);
|
|
14850
|
+
if (!reading) return {
|
|
14851
|
+
reading: null,
|
|
14852
|
+
raw
|
|
14853
|
+
};
|
|
14854
|
+
const cited = [...new Set(reading.judgements.flatMap((judgement) => judgement.status === "resolved" ? judgement.evidence.map((place) => place.file) : []))];
|
|
14855
|
+
let existing = /* @__PURE__ */ new Set();
|
|
14856
|
+
if (cited.length) try {
|
|
14857
|
+
existing = await check(where.repoPath, cited, where.commitSha);
|
|
14858
|
+
} catch {
|
|
14859
|
+
existing = /* @__PURE__ */ new Set();
|
|
14860
|
+
}
|
|
14861
|
+
return {
|
|
14862
|
+
reading: {
|
|
14863
|
+
...reading,
|
|
14864
|
+
judgements: reading.judgements.map((judgement) => groundResolution(judgement, existing))
|
|
14865
|
+
},
|
|
14866
|
+
raw
|
|
14867
|
+
};
|
|
14868
|
+
}
|
|
14869
|
+
function sameSuggestion(entry, axis, to) {
|
|
14870
|
+
return entry.axis === "status" === (axis === "status") && entry.to === to;
|
|
14871
|
+
}
|
|
14872
|
+
/** The exchanges a buddy-conversation entry read, or none for any other source. */
|
|
14873
|
+
function exchangesOf(source) {
|
|
14874
|
+
return source.kind === "buddy-conversation" ? source.exchanges : [];
|
|
14875
|
+
}
|
|
14876
|
+
/**
|
|
14877
|
+
* Whether this suggestion is already on the ledger for this finding, in a way that means it should
|
|
14878
|
+
* not be written again: offered on the card now (`isOffered`, the projection's own answer), or
|
|
14879
|
+
* declined by the reviewer on evidence that covered every turn this one read. A proposal still marked
|
|
14880
|
+
* proposed but outdated or overtaken is offered by no card, so it holds nothing back. A decline on
|
|
14881
|
+
* less evidence does not either: later turns are new evidence.
|
|
14882
|
+
*/
|
|
14883
|
+
function heldBack(session, kind, findingId, axis, to, exchanges) {
|
|
14884
|
+
for (const entry of session.findingReassessments ?? []) {
|
|
14885
|
+
if (entry.kind !== kind || entry.findingId !== findingId || !sameSuggestion(entry, axis, to)) continue;
|
|
14886
|
+
if (isOffered(session, entry)) return {
|
|
14887
|
+
kind: "waiting",
|
|
14888
|
+
entry
|
|
14889
|
+
};
|
|
14890
|
+
if (entry.state === "declined") {
|
|
14891
|
+
const read = new Set(exchangesOf(entry.source));
|
|
14892
|
+
if (exchanges.every((askedAt) => read.has(askedAt))) return { kind: "declined-before" };
|
|
14893
|
+
}
|
|
14894
|
+
}
|
|
14895
|
+
return null;
|
|
14896
|
+
}
|
|
14897
|
+
/**
|
|
14898
|
+
* Writes each judgement's proposals onto the session, checked against it as it is now. Run inside
|
|
14899
|
+
* the store write on the fresh copy, so a finding the reviewer decided while Buddy AI was reading is
|
|
14900
|
+
* written against what they decided, or not at all.
|
|
14901
|
+
*/
|
|
14902
|
+
function keepReconcileTurn(session, read, exchanges, askedAt) {
|
|
14903
|
+
const outcome = { judgements: [] };
|
|
14904
|
+
if (!read.reading) return outcome;
|
|
14905
|
+
const source = {
|
|
14906
|
+
kind: "buddy-conversation",
|
|
14907
|
+
exchanges: [...exchanges].sort((a, b) => a - b)
|
|
14908
|
+
};
|
|
14909
|
+
const analysis = analysisRefOf(session);
|
|
14910
|
+
for (const judgement of read.reading.judgements) {
|
|
14911
|
+
const finding = findingIn(session, judgement.kind, judgement.findingId);
|
|
14912
|
+
if (!finding) {
|
|
14913
|
+
outcome.judgements.push({
|
|
14914
|
+
judgement,
|
|
14915
|
+
status: { kind: "stale" },
|
|
14916
|
+
grade: null
|
|
14917
|
+
});
|
|
14918
|
+
continue;
|
|
14919
|
+
}
|
|
14920
|
+
const current = findingStatus(session.findingReassessments, judgement.kind, judgement.findingId, analysis).current;
|
|
14921
|
+
const to = verdictFor(judgement, current);
|
|
14922
|
+
let status;
|
|
14923
|
+
if (!to) status = { kind: "still-open" };
|
|
14924
|
+
else {
|
|
14925
|
+
const held = heldBack(session, judgement.kind, judgement.findingId, "status", to, source.exchanges);
|
|
14926
|
+
if (held) status = held;
|
|
14927
|
+
else {
|
|
14928
|
+
const write = reassessIn(session, {
|
|
14929
|
+
op: "propose-status",
|
|
14930
|
+
kind: judgement.kind,
|
|
14931
|
+
findingId: judgement.findingId,
|
|
14932
|
+
from: current,
|
|
14933
|
+
to,
|
|
14934
|
+
reason: judgement.reason,
|
|
14935
|
+
...judgement.answer ? { answer: judgement.answer } : {},
|
|
14936
|
+
...judgement.basis && to === "resolved" ? { basis: judgement.basis } : {},
|
|
14937
|
+
...judgement.evidence.length ? { evidence: judgement.evidence } : {},
|
|
14938
|
+
quotes: judgement.quotes,
|
|
14939
|
+
source
|
|
14940
|
+
}, askedAt);
|
|
14941
|
+
status = write.ok ? {
|
|
14942
|
+
kind: "proposed",
|
|
14943
|
+
entry: write.entry
|
|
14944
|
+
} : { kind: "stale" };
|
|
14945
|
+
}
|
|
14946
|
+
}
|
|
14947
|
+
let grade = null;
|
|
14948
|
+
if (judgement.grade) {
|
|
14949
|
+
const from = findingAssessment(session.findingReassessments, judgement.kind, finding, analysis).current;
|
|
14950
|
+
grade = heldBack(session, judgement.kind, judgement.findingId, "grade", judgement.grade.to, source.exchanges);
|
|
14951
|
+
if (!grade) {
|
|
14952
|
+
const write = reassessIn(session, {
|
|
14953
|
+
op: "propose",
|
|
14954
|
+
kind: judgement.kind,
|
|
14955
|
+
findingId: judgement.findingId,
|
|
14956
|
+
from,
|
|
14957
|
+
to: judgement.grade.to,
|
|
14958
|
+
reason: judgement.grade.reason,
|
|
14959
|
+
source
|
|
14960
|
+
}, askedAt);
|
|
14961
|
+
grade = write.ok ? {
|
|
14962
|
+
kind: "proposed",
|
|
14963
|
+
entry: write.entry
|
|
14964
|
+
} : write.refusal === "unchanged" ? null : { kind: "stale" };
|
|
14965
|
+
}
|
|
14966
|
+
}
|
|
14967
|
+
outcome.judgements.push({
|
|
14968
|
+
judgement,
|
|
14969
|
+
status,
|
|
14970
|
+
grade
|
|
14971
|
+
});
|
|
14972
|
+
}
|
|
14973
|
+
return outcome;
|
|
14974
|
+
}
|
|
14975
|
+
/** The ids of every proposal the turn wrote, for the transcript and for Accept all. */
|
|
14976
|
+
function proposedIds(outcome) {
|
|
14977
|
+
return outcome.judgements.flatMap(({ status, grade }) => [status, grade].flatMap((result) => result && result.kind === "proposed" ? [result.entry.id] : []));
|
|
14978
|
+
}
|
|
14979
|
+
/** One judgement's line, as the conversation says it: always a suggestion, never a change made. */
|
|
14980
|
+
function statusPhrase(judgement, status) {
|
|
14981
|
+
const to = status.kind === "proposed" || status.kind === "waiting" ? status.entry.to : null;
|
|
14982
|
+
switch (status.kind) {
|
|
14983
|
+
case "still-open": return "still open after this reply";
|
|
14984
|
+
case "declined-before": return "not suggested again: you kept it as it was on the same evidence";
|
|
14985
|
+
case "stale": return "not suggested: it changed while this was being read";
|
|
14986
|
+
case "waiting":
|
|
14987
|
+
case "proposed": {
|
|
14988
|
+
const phrase = to === "answered" ? "may be answered" : to === "partially-answered" ? "may be partly answered" : to === "resolved" ? "may be resolved" : to === "needs-investigation" ? "needs further investigation" : to === "unanswered" ? "may no longer be answered: this reply contradicts what you accepted" : to === "open" ? "may no longer be resolved: this reply contradicts what you accepted" : null;
|
|
14989
|
+
if (!phrase) return null;
|
|
14990
|
+
return status.kind === "waiting" ? `${phrase} (already waiting on the card)` : phrase;
|
|
14991
|
+
}
|
|
14992
|
+
}
|
|
14993
|
+
}
|
|
14994
|
+
function plural(n, noun) {
|
|
14995
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
14996
|
+
}
|
|
14997
|
+
/** "3 questions may be answered · 1 issue may be resolved · 1 needs further investigation". */
|
|
14998
|
+
function headline(outcome) {
|
|
14999
|
+
const count = (kind, to) => outcome.judgements.filter(({ judgement, status }) => judgement.kind === kind && status.kind === "proposed" && status.entry.to === to).length;
|
|
15000
|
+
return [
|
|
15001
|
+
[count("question", "answered"), (n) => `${plural(n, "question")} may be answered`],
|
|
15002
|
+
[count("question", "partially-answered"), (n) => `${plural(n, "question")} may be partly answered`],
|
|
15003
|
+
[count("issue", "resolved"), (n) => `${plural(n, "issue")} may be resolved`],
|
|
15004
|
+
[count("issue", "needs-investigation"), (n) => `${n} ${n === 1 ? "needs" : "need"} further investigation`],
|
|
15005
|
+
[outcome.judgements.filter(({ status }) => status.kind === "proposed" && (status.entry.to === "open" || status.entry.to === "unanswered")).length, (n) => `${n} earlier ${n === 1 ? "decision" : "decisions"} contradicted`],
|
|
15006
|
+
[outcome.judgements.filter(({ grade }) => grade?.kind === "proposed").length, (n) => `${plural(n, "grade change")} suggested`]
|
|
15007
|
+
].flatMap(([n, say]) => n > 0 ? [say(n)] : []).join(" · ");
|
|
15008
|
+
}
|
|
15009
|
+
/**
|
|
15010
|
+
* What the conversation shows for the turn: the suggestions, finding by finding, and nothing that
|
|
15011
|
+
* reads as a change made. Never the JSON. Findings the reply did not address are not mentioned,
|
|
15012
|
+
* because they were not judged.
|
|
15013
|
+
*/
|
|
15014
|
+
function reconcileTurnText(read, outcome, identity) {
|
|
15015
|
+
if (!read.reading) return `${read.raw.trim()}\n\n*This answer was not in the form suggestions need, so nothing was suggested and no finding changed.*`;
|
|
15016
|
+
const lines = outcome.judgements.flatMap(({ judgement, status, grade }) => {
|
|
15017
|
+
const said = [];
|
|
15018
|
+
const phrase = statusPhrase(judgement, status);
|
|
15019
|
+
if (phrase) said.push(phrase);
|
|
15020
|
+
if (grade?.kind === "proposed" || grade?.kind === "waiting") said.push(`its ${judgement.kind === "issue" ? "severity" : "importance"} may change to ${grade.entry.to}`);
|
|
15021
|
+
return said.length ? [`- **${judgement.ref}** ${said.join("; ")}. ${judgement.reason}`] : [];
|
|
15022
|
+
});
|
|
15023
|
+
const unmatched = read.reading.unmatched.map((passage) => `- Could not tell which finding this answers${passage.candidates.length ? ` (${passage.candidates.join(" or ")})` : ""}, so none was changed: “${passage.text}”${passage.why ? ` ${passage.why}` : ""}`);
|
|
15024
|
+
const dropped = read.reading.discarded.unquoted + read.reading.discarded.unknownRef;
|
|
15025
|
+
const droppedLine = dropped ? [`*${dropped === 1 ? "One judgement was" : `${dropped} judgements were`} left out: ${dropped === 1 ? "it" : "each"} named no finding in this review, or quoted words that are not in the turns.*`] : [];
|
|
15026
|
+
const top = headline(outcome);
|
|
15027
|
+
const unnamedLine = identity?.kind === "unnamed" ? [`*This reply does not name review ${identity.tag}, so any I or Q reference in it was trusted only where what it says matches that finding.*`] : [];
|
|
15028
|
+
if (!lines.length && !unmatched.length) return [
|
|
15029
|
+
"**Reconciled with findings.** Nothing in these turns bears on the review’s issues or questions, so nothing is suggested.",
|
|
15030
|
+
...unnamedLine,
|
|
15031
|
+
...droppedLine
|
|
15032
|
+
].join("\n\n");
|
|
15033
|
+
return [
|
|
15034
|
+
`**Reconciled with findings.**${top ? ` ${top}.` : ""}`,
|
|
15035
|
+
"These are suggestions. Nothing changes until you accept one on its card.",
|
|
15036
|
+
[...lines, ...unmatched].join("\n"),
|
|
15037
|
+
...unnamedLine,
|
|
15038
|
+
...droppedLine
|
|
15039
|
+
].join("\n\n");
|
|
15040
|
+
}
|
|
15041
|
+
/** The turns as the prompt labels them. */
|
|
15042
|
+
function evidenceTurns(turns) {
|
|
15043
|
+
return labelTurns(turns);
|
|
15044
|
+
}
|
|
14252
15045
|
//#endregion
|
|
14253
15046
|
//#region ../../packages/review-harness/src/workspace/diagram.ts
|
|
14254
15047
|
/** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
|
|
@@ -16853,6 +17646,22 @@ async function handleAsk(req, res, workspace) {
|
|
|
16853
17646
|
...reassessed,
|
|
16854
17647
|
from: currentGradeIn(workspace.session, reassessed.kind, reassessed.id) ?? null
|
|
16855
17648
|
} : null;
|
|
17649
|
+
let reconcileTurns = null;
|
|
17650
|
+
let reconcileIdentity = null;
|
|
17651
|
+
if (body?.reconciles !== void 0 && !explains && !reassessed) {
|
|
17652
|
+
if (!workspace.session.result) return send(res, 409, { error: "This review has no findings to reconcile with yet." });
|
|
17653
|
+
const chosen = reconcileTurnsOf(workspace.transcript, body.reconciles);
|
|
17654
|
+
if (!chosen.ok) return send(res, 400, {
|
|
17655
|
+
error: TURNS_REFUSED[chosen.refusal],
|
|
17656
|
+
refusal: chosen.refusal
|
|
17657
|
+
});
|
|
17658
|
+
reconcileIdentity = reviewIdentityOf(chosen.turns, workspace.session.id);
|
|
17659
|
+
if (reconcileIdentity.kind === "other") return send(res, 409, {
|
|
17660
|
+
error: otherReviewRefusal(reconcileIdentity),
|
|
17661
|
+
refusal: "other-review"
|
|
17662
|
+
});
|
|
17663
|
+
reconcileTurns = evidenceTurns(chosen.turns);
|
|
17664
|
+
}
|
|
16856
17665
|
if (liveUpdateFor(workspace.id)) return send(res, 409, {
|
|
16857
17666
|
error: "This review is being updated. Ask again when the new review is ready.",
|
|
16858
17667
|
updating: true
|
|
@@ -16893,6 +17702,13 @@ async function handleAsk(req, res, workspace) {
|
|
|
16893
17702
|
...explains ? { explaining: explains } : {},
|
|
16894
17703
|
...continuing ? { continuing } : {},
|
|
16895
17704
|
...reassessed ? { reassessing: reassessed } : {},
|
|
17705
|
+
...reconcileTurns && reconcileIdentity ? { reconciling: {
|
|
17706
|
+
turns: reconcileTurns,
|
|
17707
|
+
review: {
|
|
17708
|
+
tag: reconcileIdentity.tag,
|
|
17709
|
+
named: reconcileIdentity.kind === "named"
|
|
17710
|
+
}
|
|
17711
|
+
} } : {},
|
|
16896
17712
|
conversation: prepared
|
|
16897
17713
|
});
|
|
16898
17714
|
} catch (error) {
|
|
@@ -16915,21 +17731,39 @@ async function handleAsk(req, res, workspace) {
|
|
|
16915
17731
|
commitSha: workspace.session.metadata.commitSha
|
|
16916
17732
|
}) : null;
|
|
16917
17733
|
let reassessOutcome = null;
|
|
17734
|
+
const reconcileRead = reconcileTurns && workspace.session.result ? await readReconcileTurn(answer, workspace.session.result, reconcileTurns, {
|
|
17735
|
+
repoPath: workspace.repoPath,
|
|
17736
|
+
commitSha: workspace.session.metadata.commitSha
|
|
17737
|
+
}) : null;
|
|
17738
|
+
let reconcileOutcome = null;
|
|
17739
|
+
let keptAt = 0;
|
|
16918
17740
|
let explanation;
|
|
16919
17741
|
let shown = answer;
|
|
16920
17742
|
const saved = mutateWorkspace(workspace.id, (fresh) => {
|
|
16921
17743
|
const askedAt = Date.now();
|
|
16922
17744
|
const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
|
|
17745
|
+
keptAt = askedAt;
|
|
16923
17746
|
if (turn && reassessing) {
|
|
16924
17747
|
reassessOutcome = keepReassessTurn(fresh.session, reassessing, turn, askedAt);
|
|
16925
17748
|
shown = reassessTurnText(reassessing, turn, reassessOutcome);
|
|
16926
17749
|
}
|
|
17750
|
+
let reconciles;
|
|
17751
|
+
if (reconcileRead && reconcileTurns) {
|
|
17752
|
+
const exchanges = reconcileTurns.map((evidence) => evidence.askedAt);
|
|
17753
|
+
reconcileOutcome = keepReconcileTurn(fresh.session, reconcileRead, exchanges, askedAt);
|
|
17754
|
+
shown = reconcileTurnText(reconcileRead, reconcileOutcome, reconcileIdentity ?? void 0);
|
|
17755
|
+
reconciles = {
|
|
17756
|
+
exchanges,
|
|
17757
|
+
proposals: proposedIds(reconcileOutcome)
|
|
17758
|
+
};
|
|
17759
|
+
}
|
|
16927
17760
|
fresh.transcript.push({
|
|
16928
17761
|
question,
|
|
16929
17762
|
answer: shown,
|
|
16930
17763
|
sectionId,
|
|
16931
17764
|
askedAt,
|
|
16932
|
-
...kept ? { explains: kept } : {}
|
|
17765
|
+
...kept ? { explains: kept } : {},
|
|
17766
|
+
...reconciles ? { reconciles } : {}
|
|
16933
17767
|
});
|
|
16934
17768
|
if (kept) {
|
|
16935
17769
|
explanation = {
|
|
@@ -16945,13 +17779,23 @@ async function handleAsk(req, res, workspace) {
|
|
|
16945
17779
|
outcome: reassessOutcome,
|
|
16946
17780
|
...card ? { card } : {}
|
|
16947
17781
|
} } : {};
|
|
16948
|
-
|
|
16949
|
-
|
|
16950
|
-
|
|
16951
|
-
|
|
16952
|
-
|
|
17782
|
+
const reconciliation = reconcileOutcome && saved ? { reconciliation: {
|
|
17783
|
+
proposals: proposedIds(reconcileOutcome),
|
|
17784
|
+
cards: reconcileOutcome.judgements.flatMap(({ judgement }) => {
|
|
17785
|
+
const parts = gradeFragments(saved, judgement.kind, judgement.findingId);
|
|
17786
|
+
return parts ? [parts] : [];
|
|
17787
|
+
})
|
|
17788
|
+
} } : {};
|
|
17789
|
+
const turnRef = saved ? {
|
|
17790
|
+
askedAt: keptAt,
|
|
17791
|
+
...reconcileOutcome ? { reconcileTurn: true } : {}
|
|
17792
|
+
} : {};
|
|
17793
|
+
sendIfOpen(res, 200, {
|
|
16953
17794
|
answer: shown,
|
|
16954
|
-
...
|
|
17795
|
+
...explanation ? { explanation } : {},
|
|
17796
|
+
...reassessment,
|
|
17797
|
+
...reconciliation,
|
|
17798
|
+
...turnRef
|
|
16955
17799
|
});
|
|
16956
17800
|
} finally {
|
|
16957
17801
|
endRun(run);
|
|
@@ -17362,7 +18206,8 @@ var ASK_SOURCES = [
|
|
|
17362
18206
|
"free_text",
|
|
17363
18207
|
"explain_simply",
|
|
17364
18208
|
"selection",
|
|
17365
|
-
"finding"
|
|
18209
|
+
"finding",
|
|
18210
|
+
"reconcile"
|
|
17366
18211
|
];
|
|
17367
18212
|
function askSource(value) {
|
|
17368
18213
|
return ASK_SOURCES.includes(value) ? value : "free_text";
|