prreviewbuddy 0.83.3 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/{delete_review-BED4mPxB.js → delete_review-rwn_-SnJ.js} +303 -40
- package/dist/main.js +1 -1
- package/dist/server.js +1056 -82
- package/package.json +1 -1
- package/static/help.js +1 -1
- package/static/shell.css +227 -37
- package/static/sidebar.css +36 -23
- package/static/workspace.js +90 -83
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)}"` : ""}>
|
|
@@ -2562,11 +2705,11 @@ function renderOverviewTab(session, iconUrl = "", options) {
|
|
|
2562
2705
|
return `
|
|
2563
2706
|
${options?.authorContext ? renderAuthorContext(session, options.descriptionLink) : renderDescriptionLink(session, options?.descriptionLink)}
|
|
2564
2707
|
${renderWhyThisChange(result.motivation, session, result.questions, options)}
|
|
2565
|
-
<section class="pr-helper-whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
|
|
2708
|
+
<section class="pr-helper-whats-changing" data-tour="whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
|
|
2566
2709
|
<h4 class="pr-helper-overview-heading" id="pr-helper-whats-changing-heading">What's changing</h4>
|
|
2567
2710
|
<div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
|
|
2568
2711
|
</section>
|
|
2569
|
-
<div class="pr-helper-risk-headline">
|
|
2712
|
+
<div class="pr-helper-risk-headline" data-tour="risk">
|
|
2570
2713
|
<div class="pr-helper-risk-headline-top">
|
|
2571
2714
|
<span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
|
|
2572
2715
|
<span class="pr-helper-risk-headline-label">${escapeHtml$1(titleCase(result.overallRisk))} Risk</span>
|
|
@@ -2575,10 +2718,10 @@ function renderOverviewTab(session, iconUrl = "", options) {
|
|
|
2575
2718
|
${renderProvisionalReason(session)}
|
|
2576
2719
|
${result.recommendation ? `<div class="pr-helper-risk-headline-takeaway pr-helper-markdown">${renderMarkdown(result.recommendation)}</div>` : ""}
|
|
2577
2720
|
</div>
|
|
2578
|
-
<div class="pr-helper-overview-stats">
|
|
2721
|
+
<div class="pr-helper-overview-stats" data-tour="stats">
|
|
2579
2722
|
${renderOverviewStat(metrics.sections, "Themes", "guide")}
|
|
2580
2723
|
${renderIssuesStat(issuesOutstanding(result.issues, reviewerDispositions(session)))}
|
|
2581
|
-
${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
|
|
2724
|
+
${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? []), verdictClosure(session).question), "Questions", "questions", "answered")}
|
|
2582
2725
|
${renderCoverageStat(session, metrics.files)}
|
|
2583
2726
|
</div>
|
|
2584
2727
|
${renderSuggestedPath(result.sections, "Suggested review path")}
|
|
@@ -2621,13 +2764,13 @@ function discussionDetails(session) {
|
|
|
2621
2764
|
if (reviewThreads > 0) {
|
|
2622
2765
|
const outdated = stats.outdatedThreads ?? 0;
|
|
2623
2766
|
const state = stats.unresolvedThreads > 0 ? `${stats.unresolvedThreads} unresolved${outdated > 0 ? `, ${outdated} on outdated lines` : ""}` : "all resolved";
|
|
2624
|
-
parts.push(`${reviewThreads} ${plural$
|
|
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) {
|
|
@@ -2698,7 +2841,7 @@ function renderSuggestedPath(sections, heading) {
|
|
|
2698
2841
|
if (sections.length === 0) return "";
|
|
2699
2842
|
const items = sections.map((s) => `<li>${escapeHtml$1(s.title)}</li>`).join("");
|
|
2700
2843
|
return `
|
|
2701
|
-
<div class="pr-helper-suggested-path">
|
|
2844
|
+
<div class="pr-helper-suggested-path" data-tour="suggested-path">
|
|
2702
2845
|
<h4 class="pr-helper-suggested-path-title">${escapeHtml$1(heading)}</h4>
|
|
2703
2846
|
<ol class="pr-helper-suggested-path-list">${items}</ol>
|
|
2704
2847
|
<p class="pr-helper-suggested-path-time">Estimated review time ≈${estimateTotalMinutes(sections)} minutes</p>
|
|
@@ -3068,8 +3211,11 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3068
3211
|
const dismissals = new Map((session.dismissedIssues ?? []).map((entry) => [entry.id, entry]));
|
|
3069
3212
|
const dispositions = reviewerDispositions(session);
|
|
3070
3213
|
const assess = assessmentLookup(session);
|
|
3214
|
+
const statusOf = statusLookup(session);
|
|
3071
3215
|
const grading = gradeOptions(options, now);
|
|
3216
|
+
const refs = options?.findingRefs ? findingRefs(result) : null;
|
|
3072
3217
|
const card = (issue) => {
|
|
3218
|
+
const ref = refs ? refOf(refs, "issue", issue.id) : void 0;
|
|
3073
3219
|
const isDismissed = dispositions.isDismissed(issue);
|
|
3074
3220
|
const stale = dispositions.isStale(issue);
|
|
3075
3221
|
const assessment = assess("issue", issue);
|
|
@@ -3077,7 +3223,8 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3077
3223
|
<div class="pr-helper-issue-card ${cardGradeClass("issue", assessment.current)}${issue.reconciliationStatus ? ` pr-helper-issue-${issue.reconciliationStatus}` : ""}${issue.investigationImpact ? ` pr-helper-issue-investigation-${issue.investigationImpact}` : ""}${isDismissed && !stale ? " pr-helper-issue-card-dismissed" : ""}" data-finding-card="${escapeHtml$1(issue.id)}">
|
|
3078
3224
|
<div class="pr-helper-issue-header">
|
|
3079
3225
|
<div class="pr-helper-issue-title-row">
|
|
3080
|
-
${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue)) : ""}
|
|
3226
|
+
${options?.feedbackSelect ? renderFeedbackPick("issue", issue.title, issueFeedbackMarkdown(issue), ref) : ""}
|
|
3227
|
+
${renderFindingRef(ref)}
|
|
3081
3228
|
<p class="pr-helper-issue-title">${renderInlineText(issue.title)}</p>
|
|
3082
3229
|
${renderFindingCopySource(issueMarkdown(issue, assessment), "issue")}
|
|
3083
3230
|
${options?.askBuddy ? renderAskBuddyMenu("issue") : ""}
|
|
@@ -3092,7 +3239,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3092
3239
|
</div>
|
|
3093
3240
|
</div>
|
|
3094
3241
|
${renderStaleDismissal(stale)}
|
|
3095
|
-
${renderAssessmentRegion("issue", assessment, grading)}
|
|
3242
|
+
${renderAssessmentRegion("issue", assessment, grading, statusOf("issue", issue.id))}
|
|
3096
3243
|
<p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(explanationOf(session, "issue", issue.id), options)}
|
|
3097
3244
|
${renderReconciliationNote(issue.reconciliationNote)}
|
|
3098
3245
|
${renderStatusHistory(issue.statusHistory, now)}
|
|
@@ -3103,7 +3250,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3103
3250
|
${renderInvestigationProvenance("issue", issue.id, records, now)}
|
|
3104
3251
|
<div class="pr-helper-issue-actions">
|
|
3105
3252
|
${renderInvestigationActions("issue", issue.id, records, investigatingId)}
|
|
3106
|
-
${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id))}
|
|
3253
|
+
${renderDismissToggle(issue, isDismissed, stale, dismissals.get(issue.id), dispositions.isClosedByVerdict(issue))}
|
|
3107
3254
|
</div>
|
|
3108
3255
|
</div>
|
|
3109
3256
|
`;
|
|
@@ -3113,7 +3260,7 @@ function renderIssuesTab(session, investigatingId, options) {
|
|
|
3113
3260
|
const open = result.issues.filter((issue) => isIssueOutstanding(issue, dispositions)).map(card).join("");
|
|
3114
3261
|
const closedIssues = result.issues.filter((issue) => !isIssueOutstanding(issue, dispositions));
|
|
3115
3262
|
const closed = closedIssues.map(card).join("");
|
|
3116
|
-
return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending).length)}`;
|
|
3263
|
+
return `<div class="pr-helper-findings-progress" role="status">${findingsProgressBody(counts.total, counts.open, doneVerb(counts))}</div>${open}${renderClosedFindings(closedFindingsSummary(counts), closed, "issues", counts.open === 0, closedIssues.filter((issue) => assess("issue", issue).pending || statusOf("issue", issue.id).pending).length)}`;
|
|
3117
3264
|
}
|
|
3118
3265
|
/**
|
|
3119
3266
|
* What to call the closed pile, by what is actually in it.
|
|
@@ -3130,7 +3277,9 @@ function closedFindingsSummary(counts) {
|
|
|
3130
3277
|
if (byConversation === counts.done) return `Resolved by the conversation (${counts.done})`;
|
|
3131
3278
|
return `Resolved (${counts.done})`;
|
|
3132
3279
|
}
|
|
3133
|
-
|
|
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.
|
|
@@ -3555,6 +3749,42 @@ function renderDrawnFrom(diagram, reviewedSha) {
|
|
|
3555
3749
|
return `<p class="prb-diagram-stale">Drawn from <code>${escapeHtml$1(diagram.commitSha.slice(0, 7))}</code>, before this review was brought up to date. Regenerate to draw it from the commit the review now describes.</p>`;
|
|
3556
3750
|
}
|
|
3557
3751
|
//#endregion
|
|
3752
|
+
//#region ../../packages/review-harness/src/workspace/guided_tour.ts
|
|
3753
|
+
function isGuidedTourState(value) {
|
|
3754
|
+
return value === "completed" || value === "dismissed";
|
|
3755
|
+
}
|
|
3756
|
+
/**
|
|
3757
|
+
* Whether a review page opens the tour by itself: a live page (one with a server behind it) over a
|
|
3758
|
+
* standard result that no update is replacing, for a reviewer who has not already finished or
|
|
3759
|
+
* skipped it. Running reviews never qualify, so the tour cannot interrupt one.
|
|
3760
|
+
*/
|
|
3761
|
+
function offersGuidedTour(page) {
|
|
3762
|
+
return page.live && page.hasResult && !page.updating && page.state === void 0;
|
|
3763
|
+
}
|
|
3764
|
+
/** The hash Replay opens a review at. Not a place the page knows, so the page drops it once read. */
|
|
3765
|
+
var TOUR_REPLAY_HASH = "#tour";
|
|
3766
|
+
/**
|
|
3767
|
+
* Whether the tour can be shown on a review: its analysis finished, and nothing is analysing or
|
|
3768
|
+
* updating it now. Whether the reviewer has marked it Reviewed has nothing to do with it.
|
|
3769
|
+
*/
|
|
3770
|
+
function tourEligible(review, busy) {
|
|
3771
|
+
return review.hasStandardReview && !busy.has(review.id);
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* The review Replay from Help opens: the one last opened, when the tour can be shown on it, and
|
|
3775
|
+
* otherwise the newest it can be shown on. Never a busy review, so none is chosen only when no
|
|
3776
|
+
* review qualifies. Replay from a review's own menu does not ask: it uses the review on screen.
|
|
3777
|
+
*/
|
|
3778
|
+
function chooseTourReview(reviews, busy) {
|
|
3779
|
+
const last = reviews.filter((review) => review.lastViewedAt !== void 0).reduce((best, review) => !best || review.lastViewedAt > best.lastViewedAt ? review : best, null);
|
|
3780
|
+
if (last && tourEligible(last, busy)) return last;
|
|
3781
|
+
return reviews.filter((review) => tourEligible(review, busy)).reduce((best, review) => !best || review.createdAt > best.createdAt ? review : best, null);
|
|
3782
|
+
}
|
|
3783
|
+
function tourUnavailable(reviews, busy) {
|
|
3784
|
+
if (chooseTourReview(reviews, busy)) return null;
|
|
3785
|
+
return reviews.some((review) => review.hasStandardReview) ? "busy" : "none";
|
|
3786
|
+
}
|
|
3787
|
+
//#endregion
|
|
3558
3788
|
//#region ../../packages/review-harness/src/domain/change_map/types.ts
|
|
3559
3789
|
var MAP_ROLES = [
|
|
3560
3790
|
"intent",
|
|
@@ -5546,7 +5776,7 @@ function updateFacts(outcome) {
|
|
|
5546
5776
|
counts.affected ? `${counts.affected} to look at again` : "",
|
|
5547
5777
|
counts.questionsAnswered ? `${plural(counts.questionsAnswered, "question", "questions")} answered` : ""
|
|
5548
5778
|
].filter(Boolean);
|
|
5549
|
-
const grades = counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [];
|
|
5779
|
+
const grades = [...counts.gradeChanges ? [`${plural(counts.gradeChanges, "grade change", "grade changes")} suggested`] : [], ...counts.decisionsChallenged ? [`${plural(counts.decisionsChallenged, "accepted answer", "accepted answers")} put back to you`] : []];
|
|
5550
5780
|
const basis = outcome.reassessed;
|
|
5551
5781
|
return [...basis && !basis.code ? [escapeHtml$1("Reassessed against the conversation")] : [`<code>${escapeHtml$1(outcome.toSha.slice(0, 7))}</code>`, ...basis?.discussion ? [escapeHtml$1("with the conversation")] : []], ...[...changes.length ? changes : ["No change to the findings"], ...grades].map(escapeHtml$1)];
|
|
5552
5782
|
}
|
|
@@ -6151,7 +6381,12 @@ function renderWorkspace(view) {
|
|
|
6151
6381
|
<link rel="stylesheet" href="/assets/sidebar.css">
|
|
6152
6382
|
<link rel="stylesheet" href="/assets/shell.css">
|
|
6153
6383
|
</head>
|
|
6154
|
-
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}"
|
|
6384
|
+
<body data-api-base="${escapeHtml$1(view.apiBase)}"${view.token ? ` data-token="${escapeHtml$1(view.token)}" data-notifications-since="${Date.now()}"` : ""}${view.editor ? ` data-editor-label="${escapeHtml$1(view.editor.label)}" data-editor-template="${escapeHtml$1(view.editor.template)}"` : ""}${view.repoPath ? ` data-repo-path="${escapeHtml$1(view.repoPath)}"` : ""}${view.revision === void 0 ? "" : ` data-workspace-revision="${escapeHtml$1(String(view.revision))}"`} data-session-id="${escapeHtml$1(view.session.id)}" data-explain-prompt="${escapeHtml$1(EXPLAIN_SIMPLY_PROMPT)}" data-way-back="${escapeHtml$1(wayBackSentence())}" data-default-view="${session.result ? "overview" : showsProgress(view) ? "progress" : "kiss"}"${offersGuidedTour({
|
|
6385
|
+
live: view.token !== void 0,
|
|
6386
|
+
hasResult: Boolean(session.result),
|
|
6387
|
+
updating: updating(view),
|
|
6388
|
+
state: view.guidedTour
|
|
6389
|
+
}) ? " data-guided-tour=\"offer\"" : ""}${view.tourBusy ? " data-tour-busy" : ""}>
|
|
6155
6390
|
${renderGlobalBar({
|
|
6156
6391
|
unread: view.unreadNotifications ?? 0,
|
|
6157
6392
|
live: Boolean(view.token),
|
|
@@ -6230,7 +6465,7 @@ function renderReviewBody(view, sections) {
|
|
|
6230
6465
|
${renderRail(view, sections, updating(view))}
|
|
6231
6466
|
<main class="prb-doc">
|
|
6232
6467
|
${updating(view) ? renderUpdatingDoc(view) : renderReviewDoc(view, sections)}
|
|
6233
|
-
${offersFeedback(view) ? renderFeedbackTray() : ""}
|
|
6468
|
+
${offersFeedback(view) ? renderFeedbackTray(reviewTag(view.session.id)) : ""}
|
|
6234
6469
|
</main>
|
|
6235
6470
|
${result ? renderAsk(view, sections, updating(view)) : ""}
|
|
6236
6471
|
${result ? renderAskTab() : ""}
|
|
@@ -6405,7 +6640,8 @@ function renderStandardViews(view, sections) {
|
|
|
6405
6640
|
${renderIssuesTab(session, null, {
|
|
6406
6641
|
askBuddy: true,
|
|
6407
6642
|
feedbackSelect: true,
|
|
6408
|
-
reassess: true
|
|
6643
|
+
reassess: true,
|
|
6644
|
+
findingRefs: true
|
|
6409
6645
|
})}
|
|
6410
6646
|
</section>
|
|
6411
6647
|
<section class="prb-view" data-view="questions" hidden>
|
|
@@ -6414,7 +6650,8 @@ function renderStandardViews(view, sections) {
|
|
|
6414
6650
|
suppressFileJump: true,
|
|
6415
6651
|
askBuddy: true,
|
|
6416
6652
|
feedbackSelect: true,
|
|
6417
|
-
reassess: true
|
|
6653
|
+
reassess: true,
|
|
6654
|
+
findingRefs: true
|
|
6418
6655
|
})}
|
|
6419
6656
|
</section>
|
|
6420
6657
|
<section class="prb-view" data-view="prcontext" hidden>
|
|
@@ -6689,6 +6926,7 @@ function renderIdMenu(view, freshness) {
|
|
|
6689
6926
|
branchItems ? `<button type="button" class="prb-menu-item" data-queue-action="${view.queued ? "remove" : "add"}">${queueItemLabel(view.queued === true)}</button>` : "",
|
|
6690
6927
|
canWrite ? `<button type="button" class="prb-menu-item" data-pin-toggle aria-pressed="${view.pinned === true}">${pinItemLabel(view.pinned === true)}</button>` : "",
|
|
6691
6928
|
`<button type="button" class="prb-menu-item" data-copy-id="${safe}">Copy review ID</button>`,
|
|
6929
|
+
canWrite && view.session.result ? "<button type=\"button\" class=\"prb-menu-item\" data-tour-replay>Replay guided tour</button>" : "",
|
|
6692
6930
|
canWrite ? `<button type="button" class="prb-menu-item prb-menu-item-danger" data-delete="${safe}">Delete review</button>` : ""
|
|
6693
6931
|
].filter(Boolean);
|
|
6694
6932
|
return `<div class="prb-menu" data-menu data-review-menu${[
|
|
@@ -6798,7 +7036,7 @@ function renderRail(view, sections, stale = false) {
|
|
|
6798
7036
|
</div>
|
|
6799
7037
|
${railLink("map", "diagrams", "Diagrams", diagramsBadge(diagramCount(view)), void 0, "diagram architecture")}
|
|
6800
7038
|
${railLink("issues", "issues", "Issues", findingsBadge(issuesOutstanding(issues, dispositions)), void 0, void 0, true)}
|
|
6801
|
-
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered), "question", "answered"), void 0, void 0, true)}
|
|
7039
|
+
${railLink("questions", "questions", "Questions", remainingBadge(questionsOutstanding(questions, answered, verdictClosure(view.session).question), "question", "answered"), void 0, void 0, true)}
|
|
6802
7040
|
${railLink("files", "files", "Files", totalBadge(view.changeSet.data.files.length, "files"))}
|
|
6803
7041
|
${view.prContext ? railLink("prcontext", "prcontext", "PR context", totalBadge(view.prContext.processed.stats.totalThreads, "PR comment threads")) : railLink("prcontext", "prcontext", "PR context", null, "No pull request was connected to this review")}`
|
|
6804
7042
|
}) : "";
|
|
@@ -6960,7 +7198,7 @@ function totalBadge(count, noun) {
|
|
|
6960
7198
|
* with all of them", and a tick would claim the second on a review that earned the first.
|
|
6961
7199
|
*/
|
|
6962
7200
|
/** "1 issue", "2 issues". Shared so a row's words and its badge cannot drift apart. */
|
|
6963
|
-
function plural$
|
|
7201
|
+
function plural$2(n, noun) {
|
|
6964
7202
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
6965
7203
|
}
|
|
6966
7204
|
function remainingBadge(counts, noun, verb) {
|
|
@@ -6970,16 +7208,16 @@ function remainingBadge(counts, noun, verb) {
|
|
|
6970
7208
|
};
|
|
6971
7209
|
if (counts.open === 0) return {
|
|
6972
7210
|
text: "✓",
|
|
6973
|
-
label: `All ${plural$
|
|
7211
|
+
label: `All ${plural$2(counts.total, noun)} ${verb}`,
|
|
6974
7212
|
done: true
|
|
6975
7213
|
};
|
|
6976
7214
|
if (counts.done === 0) return {
|
|
6977
7215
|
text: String(counts.open),
|
|
6978
|
-
label: plural$
|
|
7216
|
+
label: plural$2(counts.total, noun)
|
|
6979
7217
|
};
|
|
6980
7218
|
return {
|
|
6981
7219
|
text: String(counts.open),
|
|
6982
|
-
label: `${counts.open} of ${plural$
|
|
7220
|
+
label: `${counts.open} of ${plural$2(counts.total, noun)} left, ${counts.done} ${verb}`
|
|
6983
7221
|
};
|
|
6984
7222
|
}
|
|
6985
7223
|
/**
|
|
@@ -7003,9 +7241,9 @@ function findingsBadge(counts) {
|
|
|
7003
7241
|
*/
|
|
7004
7242
|
function themeFindingsNote(counts) {
|
|
7005
7243
|
if (counts.total === 0) return null;
|
|
7006
|
-
if (counts.open === 0) return `all ${plural$
|
|
7007
|
-
if (counts.done === 0) return plural$
|
|
7008
|
-
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`;
|
|
7009
7247
|
}
|
|
7010
7248
|
/**
|
|
7011
7249
|
* `aria-label` rather than `title`: the same sentence, without a native tooltip sitting on top of
|
|
@@ -7297,7 +7535,8 @@ function renderAsk(view, sections, paused = false) {
|
|
|
7297
7535
|
${paused ? "" : renderPromptBank(view.prompts ?? [], view.promptsCollapsed === true)}
|
|
7298
7536
|
<div class="prb-ask-log" data-ask-log>
|
|
7299
7537
|
${transcript.length > 0 || pending ? transcript.map((exchange) => `<div class="prb-msg prb-msg-user">${escapeHtml$1(exchange.question)}</div>
|
|
7300
|
-
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div
|
|
7538
|
+
<div class="prb-msg prb-msg-assistant prb-md">${renderMarkdown(exchange.answer)}${renderMessageCopySource(exchange.answer)}</div>
|
|
7539
|
+
${paused ? "" : renderTurnActions(exchange, waitingProposals(view.session, exchange.reconciles?.proposals))}`).join("") + pendingTurn : `<div class="prb-ask-empty"${paused ? " inert" : ""}>
|
|
7301
7540
|
<p>Ask about this change. Answers come only from the review, the diff and the repository on disk.</p>
|
|
7302
7541
|
${suggestions.map((text) => `<button type="button" class="prb-suggestion" data-suggestion>${escapeHtml$1(text)}</button>`).join("")}
|
|
7303
7542
|
</div>`}
|
|
@@ -7319,6 +7558,12 @@ function renderAsk(view, sections, paused = false) {
|
|
|
7319
7558
|
* The list is always present, even when empty: an empty bank with an Add button explains what
|
|
7320
7559
|
* the feature is, where a hidden one leaves it undiscoverable until someone reads a changelog.
|
|
7321
7560
|
*/
|
|
7561
|
+
/** Of the suggestions a reconcile made, the ones still waiting on the reviewer on this analysis. */
|
|
7562
|
+
function waitingProposals(session, ids) {
|
|
7563
|
+
if (!ids?.length) return [];
|
|
7564
|
+
const wanted = new Set(ids);
|
|
7565
|
+
return (session.findingReassessments ?? []).filter((entry) => wanted.has(entry.id) && isOffered(session, entry)).map((entry) => entry.id);
|
|
7566
|
+
}
|
|
7322
7567
|
function renderPromptBank(prompts, collapsed = false) {
|
|
7323
7568
|
return `<div class="prb-bank" data-prompt-bank data-prompts="${escapeHtml$1(JSON.stringify(prompts))}"${collapsed ? " data-collapsed" : ""}>
|
|
7324
7569
|
<div class="prb-bank-head">
|
|
@@ -8216,7 +8461,7 @@ function readPrStates() {
|
|
|
8216
8461
|
if (parsed.version !== VERSION$2) return {};
|
|
8217
8462
|
const entries = parsed.entries;
|
|
8218
8463
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
|
|
8219
|
-
return normalise$
|
|
8464
|
+
return normalise$2(entries);
|
|
8220
8465
|
} catch {
|
|
8221
8466
|
return {};
|
|
8222
8467
|
}
|
|
@@ -8241,7 +8486,7 @@ function prunePrStates(states, referenced) {
|
|
|
8241
8486
|
* state with no time or a time with no state says nothing, and an unknown state must not be read
|
|
8242
8487
|
* as one this build knows.
|
|
8243
8488
|
*/
|
|
8244
|
-
function normalise$
|
|
8489
|
+
function normalise$2(entries) {
|
|
8245
8490
|
const states = {};
|
|
8246
8491
|
for (const [key, value] of Object.entries(entries)) {
|
|
8247
8492
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
@@ -8356,7 +8601,7 @@ function readWatchDocument() {
|
|
|
8356
8601
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return null;
|
|
8357
8602
|
return {
|
|
8358
8603
|
version,
|
|
8359
|
-
entries: normalise(entries)
|
|
8604
|
+
entries: normalise$1(entries)
|
|
8360
8605
|
};
|
|
8361
8606
|
} catch {
|
|
8362
8607
|
return null;
|
|
@@ -8369,7 +8614,7 @@ function readWatch() {
|
|
|
8369
8614
|
if (parsed.version !== VERSION) return {};
|
|
8370
8615
|
const entries = parsed.entries;
|
|
8371
8616
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {};
|
|
8372
|
-
return normalise(entries);
|
|
8617
|
+
return normalise$1(entries);
|
|
8373
8618
|
} catch {
|
|
8374
8619
|
return {};
|
|
8375
8620
|
}
|
|
@@ -8410,7 +8655,7 @@ function pruneWatch(watched) {
|
|
|
8410
8655
|
* `notifiedSha` that survived as a number would compare unequal to every real sha and announce on
|
|
8411
8656
|
* every cycle for ever.
|
|
8412
8657
|
*/
|
|
8413
|
-
function normalise(entries) {
|
|
8658
|
+
function normalise$1(entries) {
|
|
8414
8659
|
const state = {};
|
|
8415
8660
|
for (const [id, value] of Object.entries(entries)) {
|
|
8416
8661
|
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
@@ -10673,7 +10918,7 @@ function readPreferences() {
|
|
|
10673
10918
|
try {
|
|
10674
10919
|
const parsed = JSON.parse(readFileSync(PREFERENCES_PATH, "utf8"));
|
|
10675
10920
|
if (!parsed || typeof parsed !== "object") return {};
|
|
10676
|
-
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout, sidebar } = parsed;
|
|
10921
|
+
const { askWidth, dismissedNotices, promptsCollapsed, changeMapRegister, theme, reviewSections, runsSections, reviewLayout, sidebar, guidedTour } = parsed;
|
|
10677
10922
|
return {
|
|
10678
10923
|
...typeof askWidth === "number" && Number.isFinite(askWidth) ? { askWidth: clampAskWidth(askWidth) } : {},
|
|
10679
10924
|
...Array.isArray(dismissedNotices) ? { dismissedNotices: dismissedNotices.filter((key) => typeof key === "string" && NOTICE_KEY.test(key)) } : {},
|
|
@@ -10683,7 +10928,8 @@ function readPreferences() {
|
|
|
10683
10928
|
...readReviewSections(reviewSections),
|
|
10684
10929
|
...readRunsSections(runsSections),
|
|
10685
10930
|
...readLayout(reviewLayout),
|
|
10686
|
-
...readSidebar(sidebar)
|
|
10931
|
+
...readSidebar(sidebar),
|
|
10932
|
+
...isGuidedTourState(guidedTour) ? { guidedTour } : {}
|
|
10687
10933
|
};
|
|
10688
10934
|
} catch {
|
|
10689
10935
|
return {};
|
|
@@ -10745,6 +10991,21 @@ function writeSidebar(patch) {
|
|
|
10745
10991
|
}, null, 2)}\n`);
|
|
10746
10992
|
} catch {}
|
|
10747
10993
|
}
|
|
10994
|
+
/**
|
|
10995
|
+
* Remember how the guided tour was first answered. The first answer stands: a replay from Help, or
|
|
10996
|
+
* a second window finishing a tour another already skipped, is not a new answer to the offer.
|
|
10997
|
+
*/
|
|
10998
|
+
function writeGuidedTour(state) {
|
|
10999
|
+
try {
|
|
11000
|
+
const current = readPreferences();
|
|
11001
|
+
if (current.guidedTour) return;
|
|
11002
|
+
mkdirSync(STORE_ROOT, { recursive: true });
|
|
11003
|
+
writeFileSync(PREFERENCES_PATH, `${JSON.stringify({
|
|
11004
|
+
...current,
|
|
11005
|
+
guidedTour: state
|
|
11006
|
+
}, null, 2)}\n`);
|
|
11007
|
+
} catch {}
|
|
11008
|
+
}
|
|
10748
11009
|
/** Remember whether "Your prompts" is folded. Read-modify-write, like every other field here. */
|
|
10749
11010
|
function writePromptsCollapsed(collapsed) {
|
|
10750
11011
|
try {
|
|
@@ -11143,7 +11404,7 @@ function renderPrivacySection(settings) {
|
|
|
11143
11404
|
* A record with no facts -- every one written before 0.74.0 -- gets no line, and its row looks as
|
|
11144
11405
|
* it always did.
|
|
11145
11406
|
*/
|
|
11146
|
-
var plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
11407
|
+
var plural$1 = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
11147
11408
|
function shortSha(sha) {
|
|
11148
11409
|
return sha.slice(0, 7);
|
|
11149
11410
|
}
|
|
@@ -11155,15 +11416,15 @@ function headMoveLine(notification, facts) {
|
|
|
11155
11416
|
const where = notification.source === "branch" ? "locally" : "on the pull request";
|
|
11156
11417
|
const parts = [`${step(facts.fromSha, notification.headSha)} ${where}`];
|
|
11157
11418
|
if (facts.rewritten) parts.push("branch rewritten since the review");
|
|
11158
|
-
else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural(facts.behind, "commit")} behind`);
|
|
11419
|
+
else if (typeof facts.behind === "number" && facts.behind > 0) parts.push(`review ${plural$1(facts.behind, "commit")} behind`);
|
|
11159
11420
|
else parts.push("review behind");
|
|
11160
11421
|
return parts.join(" · ");
|
|
11161
11422
|
}
|
|
11162
11423
|
/** The card's own words for what is left (`whatIsLeft`), so a row and a card never disagree in vocabulary. */
|
|
11163
11424
|
function reviewLine(facts) {
|
|
11164
11425
|
const parts = [];
|
|
11165
|
-
if (facts.openIssues > 0) parts.push(plural(facts.openIssues, "issue"));
|
|
11166
|
-
if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
|
|
11426
|
+
if (facts.openIssues > 0) parts.push(plural$1(facts.openIssues, "issue"));
|
|
11427
|
+
if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
|
|
11167
11428
|
if (parts.length === 0) parts.push("Nothing flagged");
|
|
11168
11429
|
if (facts.risk) parts.push(`${facts.risk} risk`);
|
|
11169
11430
|
return parts.join(" · ");
|
|
@@ -11186,8 +11447,8 @@ function updateLine(facts) {
|
|
|
11186
11447
|
const delta = updateDelta(facts);
|
|
11187
11448
|
parts.push(...delta.length > 0 ? delta : ["findings unchanged"]);
|
|
11188
11449
|
if (facts.stillOpen > 0) parts.push(`${facts.stillOpen} still open`);
|
|
11189
|
-
if (facts.questionsAnswered > 0) parts.push(`${plural(facts.questionsAnswered, "question")} answered`);
|
|
11190
|
-
if (facts.unansweredQuestions > 0) parts.push(plural(facts.unansweredQuestions, "unanswered question"));
|
|
11450
|
+
if (facts.questionsAnswered > 0) parts.push(`${plural$1(facts.questionsAnswered, "question")} answered`);
|
|
11451
|
+
if (facts.unansweredQuestions > 0) parts.push(plural$1(facts.unansweredQuestions, "unanswered question"));
|
|
11191
11452
|
if (facts.fetchFailed) parts.push("remote not reached");
|
|
11192
11453
|
else if (facts.upToDate) parts.push("up to date");
|
|
11193
11454
|
return parts.join(" · ");
|
|
@@ -11223,7 +11484,7 @@ function toastFact(notification) {
|
|
|
11223
11484
|
return delta.length > 0 ? delta.slice(0, 2).join(", ") : "findings unchanged";
|
|
11224
11485
|
}
|
|
11225
11486
|
const facts = notification.facts;
|
|
11226
|
-
return facts.openIssues > 0 ? plural(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
|
|
11487
|
+
return facts.openIssues > 0 ? plural$1(facts.openIssues, "issue") : facts.unansweredQuestions > 0 ? void 0 : "nothing flagged";
|
|
11227
11488
|
}
|
|
11228
11489
|
/**
|
|
11229
11490
|
* A toast's whole line: what happened, to what, and at most one fact.
|
|
@@ -11474,6 +11735,30 @@ var AGENT_PRODUCTS = ((names) => names.length <= 1 ? names[0] ?? "" : `${names.s
|
|
|
11474
11735
|
* says what to type and points here for what it means. The diagrams are text on purpose.
|
|
11475
11736
|
*/
|
|
11476
11737
|
var TOPICS = [
|
|
11738
|
+
{
|
|
11739
|
+
id: "welcome",
|
|
11740
|
+
title: "Welcome",
|
|
11741
|
+
heading: `<p class="prb-help-eyebrow">Welcome to PR Review Buddy</p>
|
|
11742
|
+
<h2 class="prb-help-headline">Understand before you approve.</h2>`,
|
|
11743
|
+
body: `<p>Code review isn't just about finding bugs. It's about understanding what changed, why it
|
|
11744
|
+
changed, and whether those changes make sense.</p>
|
|
11745
|
+
<p>AI can generate code faster than ever. But someone still needs to understand it, challenge its
|
|
11746
|
+
assumptions, and take responsibility for approving it.</p>
|
|
11747
|
+
<p>That's why we built PR Review Buddy.</p>
|
|
11748
|
+
<p>PR Review Buddy helps you make sense of changes before you approve them. It brings together
|
|
11749
|
+
code, context, conversations and AI-assisted analysis so you can focus on what matters: making
|
|
11750
|
+
informed engineering decisions.</p>
|
|
11751
|
+
<p>The goal isn't to replace the reviewer. It's to make the reviewer better informed.</p>
|
|
11752
|
+
<h3 class="prb-help-eyebrow">Our principles</h3>
|
|
11753
|
+
<dl class="prb-help-principles">
|
|
11754
|
+
<div><dt>Understanding over automation</dt>
|
|
11755
|
+
<dd>AI should help you reason about changes, not make decisions on your behalf.</dd></div>
|
|
11756
|
+
<div><dt>Evidence over assertions</dt>
|
|
11757
|
+
<dd>Findings should be grounded in the code and context, so you can investigate and challenge them.</dd></div>
|
|
11758
|
+
<div><dt>You remain in control</dt>
|
|
11759
|
+
<dd>Choose your AI agent, explore the analysis, and make the final judgement yourself.</dd></div>
|
|
11760
|
+
</dl>`
|
|
11761
|
+
},
|
|
11477
11762
|
{
|
|
11478
11763
|
id: "getting-started",
|
|
11479
11764
|
title: "Getting started",
|
|
@@ -11570,6 +11855,26 @@ feature: E</pre>
|
|
|
11570
11855
|
<li><b>Delete</b> removes a review and the checkouts it was made in. See
|
|
11571
11856
|
<a href="#reviews-and-ids">Reviews and review IDs</a>.</li>
|
|
11572
11857
|
</ul>`
|
|
11858
|
+
},
|
|
11859
|
+
{
|
|
11860
|
+
id: "author-replies",
|
|
11861
|
+
title: "Asking the author",
|
|
11862
|
+
body: `<p>Every issue and question has a reference, such as <b>I3</b> or <b>Q2</b>, shown on its
|
|
11863
|
+
card. <b>Prepare feedback</b> on Issues or Questions turns the ones you choose into a draft to send
|
|
11864
|
+
the author, each led by its reference, so the author can answer “Q2: …”.</p>
|
|
11865
|
+
<p>When the reply comes back, paste it into Buddy AI as one message and press <b>Reconcile with
|
|
11866
|
+
findings</b> under it. Only that message is read. Buddy AI reads it against the review and
|
|
11867
|
+
suggests what it settles: a question answered or partly answered, an issue resolved, or one that
|
|
11868
|
+
needs investigating. Each suggestion appears on its card with the words it rests on, and nothing
|
|
11869
|
+
changes until you accept it.</p>
|
|
11870
|
+
<p>The draft ends with the review it came from. A reply naming a different review, such as one
|
|
11871
|
+
analysed afresh since, is not reconciled, because its references number different findings.</p>
|
|
11872
|
+
<p>Buddy AI is asked to read the code before suggesting an issue is resolved, and to cite the lines
|
|
11873
|
+
that show it. The review checks that the cited files exist, not that the code fixes anything, so
|
|
11874
|
+
read the cited lines before you accept. A fix the author has not pushed, or has only promised, is
|
|
11875
|
+
never suggested as resolved. A partly answered question and an issue that needs investigating stay
|
|
11876
|
+
open. If a later reply or new commits contradict an answer you accepted, you are asked again
|
|
11877
|
+
rather than the answer being undone.</p>`
|
|
11573
11878
|
},
|
|
11574
11879
|
{
|
|
11575
11880
|
id: "reviews-and-ids",
|
|
@@ -11675,7 +11980,27 @@ feature: E</pre>
|
|
|
11675
11980
|
<div data-shortcut-table></div>`
|
|
11676
11981
|
}
|
|
11677
11982
|
];
|
|
11678
|
-
/**
|
|
11983
|
+
/**
|
|
11984
|
+
* Every topic by name beside the topics, so one in the middle is found by reading a list, not a
|
|
11985
|
+
* page. First in the document so a keyboard or a screen reader reaches it before the prose; the
|
|
11986
|
+
* stylesheet places it. `help_index.ts` marks the topic being read.
|
|
11987
|
+
*/
|
|
11988
|
+
/**
|
|
11989
|
+
* Where the tour lives once it has been seen. `/tour` picks the review when Replay is pressed (see
|
|
11990
|
+
* `chooseTourReview`); with none to pick, Help says why rather than offering a button that would
|
|
11991
|
+
* open a review the tour cannot start on.
|
|
11992
|
+
*/
|
|
11993
|
+
function renderTourAction(tour, failed) {
|
|
11994
|
+
const lead = failed ? "<strong>The guided tour couldn't start.</strong> " : "";
|
|
11995
|
+
const role = failed ? " role=\"status\"" : "";
|
|
11996
|
+
if (tour === "available") return `<p class="prb-help-tour">The guided tour walks through a completed review, one part at a time,
|
|
11997
|
+
starting with the one you opened last. A review's ⋯ menu replays it on that review.</p>
|
|
11998
|
+
<p><a class="prb-help-tour-action" href="/tour">Replay guided tour</a></p>`;
|
|
11999
|
+
if (tour === "busy") return `<p class="prb-help-tour"${role}>${lead}The guided tour walks through a completed review, and every
|
|
12000
|
+
one you have is being analysed or updated right now. Replay it from here once that finishes.</p>`;
|
|
12001
|
+
return `<p class="prb-help-tour"${role}>${lead}The guided tour walks through a completed review, and there
|
|
12002
|
+
isn't one yet. Start a review from <a href="/">Reviews</a>, then replay the tour from here once it has finished.</p>`;
|
|
12003
|
+
}
|
|
11679
12004
|
function renderContents() {
|
|
11680
12005
|
return `<nav class="prb-help-contents" aria-label="Help topics">
|
|
11681
12006
|
<ul>${TOPICS.map((topic) => `<li><a href="#${topic.id}">${escapeHtml$1(topic.title)}</a></li>`).join("")}</ul>
|
|
@@ -11706,13 +12031,16 @@ ${renderWorkspaceRail({
|
|
|
11706
12031
|
sidebar: view.sidebar
|
|
11707
12032
|
})}
|
|
11708
12033
|
<main class="prb-index prb-help">
|
|
11709
|
-
<div class="prb-
|
|
12034
|
+
<div class="prb-help-layout">
|
|
11710
12035
|
${renderContents()}
|
|
12036
|
+
<div class="prb-page-panel">
|
|
11711
12037
|
${TOPICS.map((topic) => `<section class="prb-help-topic" id="${topic.id}">
|
|
11712
|
-
|
|
12038
|
+
${topic.heading ?? `<h2>${escapeHtml$1(topic.title)}</h2>`}
|
|
11713
12039
|
${topic.body}
|
|
12040
|
+
${topic.id === "welcome" ? renderTourAction(view.tour, view.tourFailed === true) : ""}
|
|
11714
12041
|
</section>`).join("")}
|
|
11715
12042
|
</div>
|
|
12043
|
+
</div>
|
|
11716
12044
|
<p class="prb-help-version">PR Review Buddy v${escapeHtml$1(BUILD_VERSION)}</p>
|
|
11717
12045
|
</main>
|
|
11718
12046
|
</div>
|
|
@@ -13664,6 +13992,7 @@ function gradeFragments$1(session, kind, id) {
|
|
|
13664
13992
|
const finding = findingIn(session, kind, id);
|
|
13665
13993
|
if (!finding) return null;
|
|
13666
13994
|
const assessment = findingAssessment(session.findingReassessments, kind, finding, analysisRefOf(session));
|
|
13995
|
+
const status = findingStatus(session.findingReassessments, kind, id, analysisRefOf(session));
|
|
13667
13996
|
const options = {
|
|
13668
13997
|
editable: true,
|
|
13669
13998
|
askMore: true
|
|
@@ -13674,8 +14003,9 @@ function gradeFragments$1(session, kind, id) {
|
|
|
13674
14003
|
current: assessment.current,
|
|
13675
14004
|
cardClass: cardGradeClass(kind, assessment.current),
|
|
13676
14005
|
grade: renderGradeControl(kind, assessment, options),
|
|
13677
|
-
region: renderAssessmentRegion(kind, assessment, options),
|
|
13678
|
-
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment)
|
|
14006
|
+
region: renderAssessmentRegion(kind, assessment, options, status),
|
|
14007
|
+
copy: kind === "issue" ? issueMarkdown(finding, assessment) : questionMarkdown(finding, assessment),
|
|
14008
|
+
verdictClosed: closesFinding(status.current)
|
|
13679
14009
|
};
|
|
13680
14010
|
}
|
|
13681
14011
|
/** The finding a request is about: named outright, or through the proposal it decides. */
|
|
@@ -13757,7 +14087,7 @@ function applyReassessmentRequest(workspaceId, value, now = Date.now) {
|
|
|
13757
14087
|
//#endregion
|
|
13758
14088
|
//#region ../../packages/review-harness/src/domain/ai_review/reassess_prompt.ts
|
|
13759
14089
|
/** More than any real answer cites; a list longer than this is padding, not evidence. */
|
|
13760
|
-
var MAX_EVIDENCE = 10;
|
|
14090
|
+
var MAX_EVIDENCE$1 = 10;
|
|
13761
14091
|
function scaleText(kind) {
|
|
13762
14092
|
return gradeScale(kind).map((grade) => `"${grade}"`).join(" | ");
|
|
13763
14093
|
}
|
|
@@ -13809,7 +14139,7 @@ function buildReassessInstructions(input) {
|
|
|
13809
14139
|
];
|
|
13810
14140
|
}
|
|
13811
14141
|
var REASSESS_ANSWER = objectWithAnyOf("verdict", "reason");
|
|
13812
|
-
function readEvidence(value) {
|
|
14142
|
+
function readEvidence$1(value) {
|
|
13813
14143
|
if (!Array.isArray(value)) return [];
|
|
13814
14144
|
const out = [];
|
|
13815
14145
|
for (const item of value) {
|
|
@@ -13821,7 +14151,7 @@ function readEvidence(value) {
|
|
|
13821
14151
|
file,
|
|
13822
14152
|
line
|
|
13823
14153
|
});
|
|
13824
|
-
if (out.length === MAX_EVIDENCE) break;
|
|
14154
|
+
if (out.length === MAX_EVIDENCE$1) break;
|
|
13825
14155
|
}
|
|
13826
14156
|
return out;
|
|
13827
14157
|
}
|
|
@@ -13842,7 +14172,7 @@ function parseReassessAnswer(raw, kind, current) {
|
|
|
13842
14172
|
const obj = parsed;
|
|
13843
14173
|
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
13844
14174
|
if (!reason) return null;
|
|
13845
|
-
const evidence = readEvidence(obj.evidence);
|
|
14175
|
+
const evidence = readEvidence$1(obj.evidence);
|
|
13846
14176
|
if (obj.verdict === "keep") return {
|
|
13847
14177
|
verdict: "keep",
|
|
13848
14178
|
reason,
|
|
@@ -13925,6 +14255,267 @@ function reassessTurnText(target, turn, outcome) {
|
|
|
13925
14255
|
const move = `**Suggests ${from} → ${answer.to}.** ${answer.reason}`;
|
|
13926
14256
|
return outcome === "stale" ? `${move}\n\n*The grade changed while this was being answered, so the suggestion was not kept. Reassess again to judge it as it stands.*` : `${move}\n\n*Accept or keep it on the card.*`;
|
|
13927
14257
|
}
|
|
14258
|
+
/** More than any finding a real reply rests on; past this it is padding. */
|
|
14259
|
+
var MAX_QUOTES = 4;
|
|
14260
|
+
var MAX_EVIDENCE = 10;
|
|
14261
|
+
/** The turns, labelled in the order the reviewer wrote them. */
|
|
14262
|
+
function labelTurns(turns) {
|
|
14263
|
+
return [...turns].sort((a, b) => a.askedAt - b.askedAt).map((turn, index) => ({
|
|
14264
|
+
label: `A${index + 1}`,
|
|
14265
|
+
askedAt: turn.askedAt,
|
|
14266
|
+
text: turn.question
|
|
14267
|
+
}));
|
|
14268
|
+
}
|
|
14269
|
+
function issueLine(issue, index, assess, status) {
|
|
14270
|
+
const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
|
|
14271
|
+
const severity = ` [severity: ${promptGrade(assess("issue", issue))}]`;
|
|
14272
|
+
return `${findingRef("issue", index)}.${severity}${where}${priorStatus(issue.reconciliationStatus === "resolved" ? "resolved by the code" : void 0)}${acceptedLine(status("issue", issue.id))} ${issue.title.trim()}\n ${issue.description.trim().replace(/\n/g, "\n ")}`;
|
|
14273
|
+
}
|
|
14274
|
+
function questionLine(question, index, assess, status, ticked) {
|
|
14275
|
+
const importance = assess("question", question).current ? ` [importance: ${promptGrade(assess("question", question))}]` : "";
|
|
14276
|
+
const where = question.file ? ` [${question.file}]` : "";
|
|
14277
|
+
const why = question.whyThisMatters?.trim() ? `\n ${question.whyThisMatters.trim()}` : "";
|
|
14278
|
+
const prior = question.reconciliationStatus === "answered" ? "answered by the code" : ticked ? "marked answered by the reviewer" : void 0;
|
|
14279
|
+
return `${findingRef("question", index)}.${importance}${where}${priorStatus(prior)}${acceptedLine(status("question", question.id))} ${question.question.trim()}${why}`;
|
|
14280
|
+
}
|
|
14281
|
+
function priorStatus(text) {
|
|
14282
|
+
return text ? ` [${text}]` : "";
|
|
14283
|
+
}
|
|
14284
|
+
/**
|
|
14285
|
+
* The verdict the reviewer accepted earlier, said as their decision and not as a fact, with what it
|
|
14286
|
+
* rested on, so evidence that contradicts it is recognised as a contradiction.
|
|
14287
|
+
*/
|
|
14288
|
+
function acceptedLine(status) {
|
|
14289
|
+
if (!status.current) return "";
|
|
14290
|
+
const last = status.history[status.history.length - 1];
|
|
14291
|
+
return ` [the reviewer accepted "${status.current}" ${sourcePhrase(last.source)}${last.reason ? `: ${last.reason}` : ""}]`;
|
|
14292
|
+
}
|
|
14293
|
+
var SHAPE = `{
|
|
14294
|
+
"findings": [
|
|
14295
|
+
{
|
|
14296
|
+
"ref": string,
|
|
14297
|
+
"status": "answered" | "partially-answered" | "still-open" | "resolved" | "needs-investigation",
|
|
14298
|
+
"reason": string,
|
|
14299
|
+
"answer"?: string,
|
|
14300
|
+
"basis"?: "premise" | "code",
|
|
14301
|
+
"evidence"?: [{ "file": string, "line"?: number }],
|
|
14302
|
+
"quotes": [{ "turn": string, "text": string }],
|
|
14303
|
+
"severity"?: ${gradeScale("issue").map((grade) => `"${grade}"`).join(" | ")},
|
|
14304
|
+
"importance"?: ${gradeScale("question").map((grade) => `"${grade}"`).join(" | ")},
|
|
14305
|
+
"gradeReason"?: string
|
|
14306
|
+
}
|
|
14307
|
+
],
|
|
14308
|
+
"unmatched": [{ "turn": string, "text": string, "candidates": string[], "why": string }]
|
|
14309
|
+
}`;
|
|
14310
|
+
/** The block a reconcile turn adds to Buddy AI's prompt: the turns, the findings, the rules and the shape. */
|
|
14311
|
+
function buildReplyReconciliationInstructions(input) {
|
|
14312
|
+
const { result, assess, status } = input;
|
|
14313
|
+
const issues = result.issues.length ? result.issues.map((issue, index) => issueLine(issue, index, assess, status)).join("\n") : "(The review raised no issues.)";
|
|
14314
|
+
const questions = result.questions.length ? result.questions.map((question, index) => questionLine(question, index, assess, status, input.answeredQuestionIds.has(question.id))).join("\n") : "(The review raised no questions.)";
|
|
14315
|
+
return [
|
|
14316
|
+
"---",
|
|
14317
|
+
"RECONCILE WITH FINDINGS",
|
|
14318
|
+
"The reviewer has asked you to read the turns below, which they wrote in this conversation, as evidence about the review's existing issues and questions. Most often they are an author's replies to feedback the reviewer sent, pasted in. Nothing you return changes a finding: each judgement becomes a suggestion the reviewer accepts or rejects on its own.",
|
|
14319
|
+
"",
|
|
14320
|
+
"THE TURNS (the reviewer's own messages, labelled; Buddy AI's answers are not evidence):",
|
|
14321
|
+
...input.turns.map((turn) => `[${turn.label}]\n${turn.text.trim()}`),
|
|
14322
|
+
"",
|
|
14323
|
+
input.review.named ? `The turns name this review (${input.review.tag}), so the references in them are this review's.` : `The turns do not name this review (${input.review.tag}). A reference in them may come from a draft of an earlier analysis whose findings were numbered differently: use a reference only where what the passage says matches that finding, and otherwise treat the passage as unmatched.`,
|
|
14324
|
+
"",
|
|
14325
|
+
"THE FINDINGS, by the reference the review's cards and its feedback drafts use:",
|
|
14326
|
+
"Issues:",
|
|
14327
|
+
issues,
|
|
14328
|
+
"Questions:",
|
|
14329
|
+
questions,
|
|
14330
|
+
"",
|
|
14331
|
+
"RULES",
|
|
14332
|
+
"- Match a passage to a finding by its reference where it names one (\"Q2:\", \"I3.\"), and otherwise only by what it unmistakably addresses. Never match a passage in order to produce a judgement. Where a passage could be about more than one finding, or you cannot tell which it answers, put it in \"unmatched\" with its candidate references and leave those findings out. The turns need not follow the order of the findings, one passage may address several findings, and one finding may be addressed across several turns.",
|
|
14333
|
+
"- Include only the findings the turns address. Leave every other finding out: an omitted finding is one the turns do not bear on, not one that is still open.",
|
|
14334
|
+
"- A question is \"answered\" only when the turns answer its substance. A reply that acknowledges it, defers it, restates it or answers a different question does not answer it. Where they answer part of it, use \"partially-answered\" and say in \"answer\" what they establish and what is still missing. Where they address it without answering it, use \"still-open\". For \"answered\" and \"partially-answered\", put the answer itself in \"answer\", in a sentence or two.",
|
|
14335
|
+
"- What an author says about an issue is one of three things, and they are judged differently:",
|
|
14336
|
+
" 1. The concern never held: an assumption the issue made is false (the code concerned is unreachable, is being removed, or a requirement it assumed does not exist). That may be \"resolved\" with \"basis\": \"premise\". Check the claim against the code wherever the code can settle it, and where the code contradicts it, it is not resolved.",
|
|
14337
|
+
" 2. It is already implemented: the code at the reviewed commit is said to handle it. That may be \"resolved\" with \"basis\": \"code\" only after you have opened that code and read it, and only when it addresses this concern, not merely when the file exists or touches the same area. List in \"evidence\" each place, with its line, where the code does so. A file name on its own is not evidence, and neither is code that exists but does not address the concern.",
|
|
14338
|
+
" 3. It is fixed somewhere you cannot read, or will be: fixed locally, in another branch, in a commit not yet pushed, or planned. That is a claim about code, not evidence, and never \"resolved\". Use \"needs-investigation\" where the claim deserves checking once the code exists, otherwise \"still-open\". Agreeing that it is a problem, with a plan to fix it later, is \"still-open\".",
|
|
14339
|
+
"- \"needs-investigation\": the turns raise something that bears on the issue and that only further reading or a further question can settle. Say what in \"reason\".",
|
|
14340
|
+
"- Treat what the author asserts as context, not proof. Where you can check a claim in the repository, check it before relying on it.",
|
|
14341
|
+
"- A verdict the reviewer accepted earlier is marked on its finding. It was their decision, not a fact. Where the turns contradict it, report the finding as \"still-open\", with the contradiction in \"reason\", so it is put to them.",
|
|
14342
|
+
"- Every judgement must quote, in \"quotes\", the exact words from the turns it rests on, copied character for character (a sentence or two each), with the turn's label. A judgement whose words cannot be found in the turn it names is discarded.",
|
|
14343
|
+
`- "severity" on an issue or "importance" on a question is optional: only where the turns change how much the finding matters, never the grade it already has, and always with "gradeReason".`,
|
|
14344
|
+
"- In \"reason\" and \"answer\", name the author by role (\"the author confirms the job is retired\"), and write no references or labels: the reviewer reads those sentences beside the finding.",
|
|
14345
|
+
"",
|
|
14346
|
+
"Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:",
|
|
14347
|
+
SHAPE,
|
|
14348
|
+
"\"status\" on a question (a Q reference) is \"answered\", \"partially-answered\" or \"still-open\"; on an issue (an I reference) it is \"resolved\", \"needs-investigation\" or \"still-open\". \"basis\" and \"evidence\" go only with \"resolved\". \"severity\" goes only on an issue and \"importance\" only on a question.",
|
|
14349
|
+
"Empty arrays are valid and far better than judgements the turns do not support."
|
|
14350
|
+
];
|
|
14351
|
+
}
|
|
14352
|
+
var QUESTION_STATUSES = [
|
|
14353
|
+
"answered",
|
|
14354
|
+
"partially-answered",
|
|
14355
|
+
"still-open"
|
|
14356
|
+
];
|
|
14357
|
+
var ISSUE_STATUSES = [
|
|
14358
|
+
"resolved",
|
|
14359
|
+
"needs-investigation",
|
|
14360
|
+
"still-open"
|
|
14361
|
+
];
|
|
14362
|
+
var ANSWER = objectWithAnyOf("findings", "unmatched");
|
|
14363
|
+
/** Whitespace and quote marks as a model may retype them: two texts are the same words if these agree. */
|
|
14364
|
+
function normalise(text) {
|
|
14365
|
+
return text.replace(/[‘’]/g, "'").replace(/[“”]/g, "\"").replace(/\s+/g, " ").trim();
|
|
14366
|
+
}
|
|
14367
|
+
/** The quotes found, word for word, in the turns they name. Nothing else is kept. */
|
|
14368
|
+
function verifiedQuotes(value, turns) {
|
|
14369
|
+
if (!Array.isArray(value)) return [];
|
|
14370
|
+
const byLabel = new Map(turns.map((turn) => [turn.label.toUpperCase(), turn]));
|
|
14371
|
+
const kept = [];
|
|
14372
|
+
for (const item of value) {
|
|
14373
|
+
const quote = item;
|
|
14374
|
+
if (typeof quote?.turn !== "string" || typeof quote.text !== "string") continue;
|
|
14375
|
+
const turn = byLabel.get(quote.turn.trim().replace(/^\[|\]$/g, "").toUpperCase());
|
|
14376
|
+
const text = normalise(quote.text);
|
|
14377
|
+
if (!turn || !text || text.length > 600) continue;
|
|
14378
|
+
if (!normalise(turn.text).includes(text)) continue;
|
|
14379
|
+
if (kept.some((existing) => existing.askedAt === turn.askedAt && existing.text === text)) continue;
|
|
14380
|
+
kept.push({
|
|
14381
|
+
askedAt: turn.askedAt,
|
|
14382
|
+
text
|
|
14383
|
+
});
|
|
14384
|
+
if (kept.length === MAX_QUOTES) break;
|
|
14385
|
+
}
|
|
14386
|
+
return kept;
|
|
14387
|
+
}
|
|
14388
|
+
function readEvidence(value) {
|
|
14389
|
+
if (!Array.isArray(value)) return [];
|
|
14390
|
+
return value.flatMap((item) => {
|
|
14391
|
+
const place = item;
|
|
14392
|
+
if (typeof place?.file !== "string" || !place.file.trim()) return [];
|
|
14393
|
+
const line = typeof place.line === "number" && Number.isInteger(place.line) && place.line > 0 ? place.line : void 0;
|
|
14394
|
+
return [{
|
|
14395
|
+
file: place.file.trim(),
|
|
14396
|
+
...line ? { line } : {}
|
|
14397
|
+
}];
|
|
14398
|
+
}).slice(0, MAX_EVIDENCE);
|
|
14399
|
+
}
|
|
14400
|
+
function text(value) {
|
|
14401
|
+
return typeof value === "string" ? value.trim() : "";
|
|
14402
|
+
}
|
|
14403
|
+
/**
|
|
14404
|
+
* The answer, read strictly. Null when it is not an answer at all; otherwise every judgement that
|
|
14405
|
+
* names a finding the review holds, with a status on that finding's scale, a reason, and at least one
|
|
14406
|
+
* quote found in the turns. A judgement missing any of those is counted and dropped, never repaired.
|
|
14407
|
+
*/
|
|
14408
|
+
function parseReplyReconciliation(raw, result, turns) {
|
|
14409
|
+
let parsed;
|
|
14410
|
+
try {
|
|
14411
|
+
parsed = JSON.parse(extractJsonText(raw, ANSWER));
|
|
14412
|
+
} catch {
|
|
14413
|
+
return null;
|
|
14414
|
+
}
|
|
14415
|
+
if (!ANSWER(parsed)) return null;
|
|
14416
|
+
const obj = parsed;
|
|
14417
|
+
const discarded = {
|
|
14418
|
+
unknownRef: 0,
|
|
14419
|
+
unquoted: 0
|
|
14420
|
+
};
|
|
14421
|
+
const judgements = [];
|
|
14422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14423
|
+
for (const item of Array.isArray(obj.findings) ? obj.findings : []) {
|
|
14424
|
+
const entry = item;
|
|
14425
|
+
const at = typeof entry?.ref === "string" ? findingAtRef(result, entry.ref) : null;
|
|
14426
|
+
if (!at) {
|
|
14427
|
+
discarded.unknownRef += 1;
|
|
14428
|
+
continue;
|
|
14429
|
+
}
|
|
14430
|
+
const allowed = at.kind === "issue" ? ISSUE_STATUSES : QUESTION_STATUSES;
|
|
14431
|
+
const status = entry.status;
|
|
14432
|
+
const reason = text(entry.reason);
|
|
14433
|
+
if (typeof status !== "string" || !allowed.includes(status) || !reason) {
|
|
14434
|
+
discarded.unknownRef += 1;
|
|
14435
|
+
continue;
|
|
14436
|
+
}
|
|
14437
|
+
const quotes = verifiedQuotes(entry.quotes, turns);
|
|
14438
|
+
if (!quotes.length) {
|
|
14439
|
+
discarded.unquoted += 1;
|
|
14440
|
+
continue;
|
|
14441
|
+
}
|
|
14442
|
+
const key = `${at.kind}:${at.index}`;
|
|
14443
|
+
if (seen.has(key)) continue;
|
|
14444
|
+
seen.add(key);
|
|
14445
|
+
const answer = text(entry.answer);
|
|
14446
|
+
const basis = entry.basis === "premise" || entry.basis === "code" ? entry.basis : void 0;
|
|
14447
|
+
const gradeTo = at.kind === "issue" ? entry.severity : entry.importance;
|
|
14448
|
+
const gradeReason = text(entry.gradeReason);
|
|
14449
|
+
judgements.push({
|
|
14450
|
+
kind: at.kind,
|
|
14451
|
+
index: at.index,
|
|
14452
|
+
findingId: at.finding.id,
|
|
14453
|
+
ref: findingRef(at.kind, at.index),
|
|
14454
|
+
status,
|
|
14455
|
+
reason,
|
|
14456
|
+
...answer ? { answer } : {},
|
|
14457
|
+
...basis ? { basis } : {},
|
|
14458
|
+
evidence: readEvidence(entry.evidence),
|
|
14459
|
+
quotes,
|
|
14460
|
+
...isGradeFor(at.kind, gradeTo) && gradeReason ? { grade: {
|
|
14461
|
+
to: gradeTo,
|
|
14462
|
+
reason: gradeReason
|
|
14463
|
+
} } : {}
|
|
14464
|
+
});
|
|
14465
|
+
}
|
|
14466
|
+
return {
|
|
14467
|
+
judgements,
|
|
14468
|
+
unmatched: (Array.isArray(obj.unmatched) ? obj.unmatched : []).flatMap((item) => {
|
|
14469
|
+
const passage = item;
|
|
14470
|
+
const words = text(passage?.text);
|
|
14471
|
+
if (!words) return [];
|
|
14472
|
+
const candidates = (Array.isArray(passage.candidates) ? passage.candidates : []).filter((ref) => typeof ref === "string" && findingAtRef(result, ref) !== null).map((ref) => ref.trim().toUpperCase());
|
|
14473
|
+
return [{
|
|
14474
|
+
text: words.slice(0, 600),
|
|
14475
|
+
candidates,
|
|
14476
|
+
why: text(passage.why)
|
|
14477
|
+
}];
|
|
14478
|
+
}),
|
|
14479
|
+
discarded
|
|
14480
|
+
};
|
|
14481
|
+
}
|
|
14482
|
+
/** Said beside a resolution the harness could not let stand as one. */
|
|
14483
|
+
var UNSUPPORTED_RESOLUTION = "Treated as needing investigation: the reply was read as resolving it, but cited no line of the reviewed code that shows it.";
|
|
14484
|
+
/**
|
|
14485
|
+
* A resolution, checked against what it says it rests on, as far as the harness can check it.
|
|
14486
|
+
*
|
|
14487
|
+
* `premise` stands as the model judged it. `code` stands only with at least one cited place, with a
|
|
14488
|
+
* line, in a file that exists at the reviewed commit (`existing` is the set of paths found there).
|
|
14489
|
+
* Anything else is downgraded to "needs investigation", with the reason saying so.
|
|
14490
|
+
*
|
|
14491
|
+
* This is citation validation, not verification: a file that exists does not show that the code in it
|
|
14492
|
+
* fixes anything. Whether it does is the model's reading, which the prompt requires it to make by
|
|
14493
|
+
* opening the code, and the reviewer's to confirm before accepting. The card says as much.
|
|
14494
|
+
*/
|
|
14495
|
+
function groundResolution(judgement, existing) {
|
|
14496
|
+
if (judgement.kind !== "issue" || judgement.status !== "resolved") return judgement;
|
|
14497
|
+
const evidence = judgement.evidence.filter((place) => existing.has(place.file));
|
|
14498
|
+
if (judgement.basis === "premise") return {
|
|
14499
|
+
...judgement,
|
|
14500
|
+
evidence
|
|
14501
|
+
};
|
|
14502
|
+
if (judgement.basis === "code" && evidence.some((place) => place.line !== void 0)) return {
|
|
14503
|
+
...judgement,
|
|
14504
|
+
evidence
|
|
14505
|
+
};
|
|
14506
|
+
return {
|
|
14507
|
+
...judgement,
|
|
14508
|
+
status: "needs-investigation",
|
|
14509
|
+
evidence,
|
|
14510
|
+
reason: `${judgement.reason} ${UNSUPPORTED_RESOLUTION}`
|
|
14511
|
+
};
|
|
14512
|
+
}
|
|
14513
|
+
/** A proposal a judgement becomes on the status axis, or null when it is not one. */
|
|
14514
|
+
function verdictFor(judgement, current) {
|
|
14515
|
+
if (judgement.status !== "still-open") return judgement.status;
|
|
14516
|
+
if (!closesFinding(current)) return null;
|
|
14517
|
+
return judgement.kind === "issue" ? "open" : "unanswered";
|
|
14518
|
+
}
|
|
13928
14519
|
//#endregion
|
|
13929
14520
|
//#region ../../packages/review-harness/src/workspace/ask.ts
|
|
13930
14521
|
/** Long enough for a real look at the repository; short enough that a hang is not forever. */
|
|
@@ -14039,6 +14630,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
|
|
|
14039
14630
|
"REVIEW GUIDE (JSON):",
|
|
14040
14631
|
JSON.stringify(workspace.session.result),
|
|
14041
14632
|
...reassessedGrades(workspace),
|
|
14633
|
+
...acceptedStatuses(workspace),
|
|
14042
14634
|
"---",
|
|
14043
14635
|
"DIFF:",
|
|
14044
14636
|
workspace.diff,
|
|
@@ -14056,6 +14648,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
|
|
|
14056
14648
|
...continuingFrom(workspace, context.continuing),
|
|
14057
14649
|
...explainingFinding(workspace, context.explaining),
|
|
14058
14650
|
...reassessingFinding(workspace, context.reassessing),
|
|
14651
|
+
...reconcilingTurns(workspace, context.reconciling),
|
|
14059
14652
|
"---",
|
|
14060
14653
|
`QUESTION: ${question}`
|
|
14061
14654
|
].join("\n");
|
|
@@ -14119,6 +14712,33 @@ function reassessedGrades(workspace) {
|
|
|
14119
14712
|
];
|
|
14120
14713
|
}
|
|
14121
14714
|
/**
|
|
14715
|
+
* The statuses the reviewer has accepted from later evidence, such as an author's reply. Listed only
|
|
14716
|
+
* when there are some, as the reviewer's decisions rather than facts, so a turn neither re-asks a
|
|
14717
|
+
* question they have accepted an answer to nor treats that answer as beyond question.
|
|
14718
|
+
*/
|
|
14719
|
+
function acceptedStatuses(workspace) {
|
|
14720
|
+
const result = workspace.session.result;
|
|
14721
|
+
if (!result) return [];
|
|
14722
|
+
const status = statusLookup(workspace.session);
|
|
14723
|
+
const lines = [...result.issues.map((issue) => ({
|
|
14724
|
+
name: `ISSUE "${issue.title.trim()}"`,
|
|
14725
|
+
status: status("issue", issue.id)
|
|
14726
|
+
})), ...result.questions.map((q) => ({
|
|
14727
|
+
name: `QUESTION "${q.question.trim()}"`,
|
|
14728
|
+
status: status("question", q.id)
|
|
14729
|
+
}))].flatMap(({ name, status: s }) => {
|
|
14730
|
+
if (!s.current) return [];
|
|
14731
|
+
const last = s.history[s.history.length - 1];
|
|
14732
|
+
return [`- ${name}: accepted as ${s.current}${last.reason ? ` (${last.reason})` : ""}${last.answer ? `. Answer: ${last.answer}` : ""}`];
|
|
14733
|
+
});
|
|
14734
|
+
if (!lines.length) return [];
|
|
14735
|
+
return [
|
|
14736
|
+
"---",
|
|
14737
|
+
"STATUSES THE REVIEWER HAS ACCEPTED FROM LATER EVIDENCE (their decisions, which later evidence may still contradict):",
|
|
14738
|
+
...lines
|
|
14739
|
+
];
|
|
14740
|
+
}
|
|
14741
|
+
/**
|
|
14122
14742
|
* Reassess on a finding: everything the judgement needs, and the exact shape of the answer. The
|
|
14123
14743
|
* thread the analysis tied the finding to goes in when it resolves in the conversation this review
|
|
14124
14744
|
* holds; when it does not, the turn goes ahead without it.
|
|
@@ -14138,12 +14758,290 @@ function reassessingFinding(workspace, reassessing) {
|
|
|
14138
14758
|
...thread ? { thread } : {}
|
|
14139
14759
|
});
|
|
14140
14760
|
}
|
|
14761
|
+
/**
|
|
14762
|
+
* Reconcile with findings: the chosen turns, every finding by its reference with the grade and any
|
|
14763
|
+
* verdict the review holds now, the evidence rules and the exact shape of the answer.
|
|
14764
|
+
*/
|
|
14765
|
+
function reconcilingTurns(workspace, reconciling) {
|
|
14766
|
+
const result = workspace.session.result;
|
|
14767
|
+
if (!reconciling || !result) return [];
|
|
14768
|
+
return buildReplyReconciliationInstructions({
|
|
14769
|
+
result,
|
|
14770
|
+
assess: assessmentLookup(workspace.session),
|
|
14771
|
+
status: statusLookup(workspace.session),
|
|
14772
|
+
answeredQuestionIds: new Set(workspace.session.answeredQuestionIds ?? []),
|
|
14773
|
+
turns: reconciling.turns,
|
|
14774
|
+
review: reconciling.review
|
|
14775
|
+
});
|
|
14776
|
+
}
|
|
14141
14777
|
/** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
|
|
14142
14778
|
function findingName(workspace, finding) {
|
|
14143
14779
|
const result = workspace.session.result;
|
|
14144
14780
|
const title = finding.kind === "issue" ? result?.issues.find((issue) => issue.id === finding.id)?.title : result?.questions.find((question) => question.id === finding.id)?.question;
|
|
14145
14781
|
return `THE ${finding.kind.toUpperCase()}${title ? ` "${title.trim()}"` : ""}`;
|
|
14146
14782
|
}
|
|
14783
|
+
var TURNS_REFUSED = {
|
|
14784
|
+
none: "Choose at least one turn of this conversation to reconcile with the findings.",
|
|
14785
|
+
unknown: "This conversation has no such turn to reconcile.",
|
|
14786
|
+
"too-many": `Reconcile reads at most 10 turns at once. Choose fewer.`,
|
|
14787
|
+
"too-long": "Those turns are too long to read in one reconcile. Choose fewer, or paste the reply in parts."
|
|
14788
|
+
};
|
|
14789
|
+
/**
|
|
14790
|
+
* The turns a request names, from the transcript as stored. Only the reviewer's own turns with words
|
|
14791
|
+
* in them: a reconcile turn is not evidence, and neither is anything Buddy AI wrote.
|
|
14792
|
+
*/
|
|
14793
|
+
function reconcileTurnsOf(transcript, value) {
|
|
14794
|
+
const asked = value?.exchanges;
|
|
14795
|
+
if (!Array.isArray(asked) || asked.length === 0) return {
|
|
14796
|
+
ok: false,
|
|
14797
|
+
refusal: "none"
|
|
14798
|
+
};
|
|
14799
|
+
const wanted = [...new Set(asked)];
|
|
14800
|
+
if (wanted.length > 10) return {
|
|
14801
|
+
ok: false,
|
|
14802
|
+
refusal: "too-many"
|
|
14803
|
+
};
|
|
14804
|
+
const turns = [];
|
|
14805
|
+
for (const askedAt of wanted) {
|
|
14806
|
+
const turn = typeof askedAt === "number" ? transcript.find((exchange) => exchange.askedAt === askedAt) : void 0;
|
|
14807
|
+
if (!turn || turn.reconciles || !turn.question.trim()) return {
|
|
14808
|
+
ok: false,
|
|
14809
|
+
refusal: "unknown"
|
|
14810
|
+
};
|
|
14811
|
+
turns.push(turn);
|
|
14812
|
+
}
|
|
14813
|
+
if (turns.reduce((total, turn) => total + turn.question.length, 0) > 4e4) return {
|
|
14814
|
+
ok: false,
|
|
14815
|
+
refusal: "too-long"
|
|
14816
|
+
};
|
|
14817
|
+
return {
|
|
14818
|
+
ok: true,
|
|
14819
|
+
turns
|
|
14820
|
+
};
|
|
14821
|
+
}
|
|
14822
|
+
function reviewIdentityOf(turns, sessionId) {
|
|
14823
|
+
const tag = reviewTag(sessionId);
|
|
14824
|
+
const named = [...new Set(turns.flatMap((turn) => reviewTagsIn(turn.question)))];
|
|
14825
|
+
if (!named.length) return {
|
|
14826
|
+
kind: "unnamed",
|
|
14827
|
+
tag
|
|
14828
|
+
};
|
|
14829
|
+
if (named.some((other) => other !== tag)) return {
|
|
14830
|
+
kind: "other",
|
|
14831
|
+
tag,
|
|
14832
|
+
named
|
|
14833
|
+
};
|
|
14834
|
+
return {
|
|
14835
|
+
kind: "named",
|
|
14836
|
+
tag
|
|
14837
|
+
};
|
|
14838
|
+
}
|
|
14839
|
+
/** Why a reply naming another review is refused, in the words the page shows. */
|
|
14840
|
+
function otherReviewRefusal(identity) {
|
|
14841
|
+
return `This reply names review ${identity.named.filter((named) => named !== identity.tag).join(" and ")}, but these findings are numbered for review ${identity.tag}. A fresh analysis numbers its findings again, so its references cannot be read against these ones. Nothing was reconciled. Prepare feedback again from this review, or reconcile the reply on the review it names.`;
|
|
14842
|
+
}
|
|
14843
|
+
/**
|
|
14844
|
+
* The answer, with every resolution checked against what it rests on (`groundResolution`). A check
|
|
14845
|
+
* that fails keeps no code evidence rather than unchecked evidence, so a code-based resolution then
|
|
14846
|
+
* falls to "needs investigation".
|
|
14847
|
+
*/
|
|
14848
|
+
async function readReconcileTurn(raw, result, turns, where, check = existingPaths) {
|
|
14849
|
+
const reading = parseReplyReconciliation(raw, result, turns);
|
|
14850
|
+
if (!reading) return {
|
|
14851
|
+
reading: null,
|
|
14852
|
+
raw
|
|
14853
|
+
};
|
|
14854
|
+
const cited = [...new Set(reading.judgements.flatMap((judgement) => judgement.status === "resolved" ? judgement.evidence.map((place) => place.file) : []))];
|
|
14855
|
+
let existing = /* @__PURE__ */ new Set();
|
|
14856
|
+
if (cited.length) try {
|
|
14857
|
+
existing = await check(where.repoPath, cited, where.commitSha);
|
|
14858
|
+
} catch {
|
|
14859
|
+
existing = /* @__PURE__ */ new Set();
|
|
14860
|
+
}
|
|
14861
|
+
return {
|
|
14862
|
+
reading: {
|
|
14863
|
+
...reading,
|
|
14864
|
+
judgements: reading.judgements.map((judgement) => groundResolution(judgement, existing))
|
|
14865
|
+
},
|
|
14866
|
+
raw
|
|
14867
|
+
};
|
|
14868
|
+
}
|
|
14869
|
+
function sameSuggestion(entry, axis, to) {
|
|
14870
|
+
return entry.axis === "status" === (axis === "status") && entry.to === to;
|
|
14871
|
+
}
|
|
14872
|
+
/** The exchanges a buddy-conversation entry read, or none for any other source. */
|
|
14873
|
+
function exchangesOf(source) {
|
|
14874
|
+
return source.kind === "buddy-conversation" ? source.exchanges : [];
|
|
14875
|
+
}
|
|
14876
|
+
/**
|
|
14877
|
+
* Whether this suggestion is already on the ledger for this finding, in a way that means it should
|
|
14878
|
+
* not be written again: offered on the card now (`isOffered`, the projection's own answer), or
|
|
14879
|
+
* declined by the reviewer on evidence that covered every turn this one read. A proposal still marked
|
|
14880
|
+
* proposed but outdated or overtaken is offered by no card, so it holds nothing back. A decline on
|
|
14881
|
+
* less evidence does not either: later turns are new evidence.
|
|
14882
|
+
*/
|
|
14883
|
+
function heldBack(session, kind, findingId, axis, to, exchanges) {
|
|
14884
|
+
for (const entry of session.findingReassessments ?? []) {
|
|
14885
|
+
if (entry.kind !== kind || entry.findingId !== findingId || !sameSuggestion(entry, axis, to)) continue;
|
|
14886
|
+
if (isOffered(session, entry)) return {
|
|
14887
|
+
kind: "waiting",
|
|
14888
|
+
entry
|
|
14889
|
+
};
|
|
14890
|
+
if (entry.state === "declined") {
|
|
14891
|
+
const read = new Set(exchangesOf(entry.source));
|
|
14892
|
+
if (exchanges.every((askedAt) => read.has(askedAt))) return { kind: "declined-before" };
|
|
14893
|
+
}
|
|
14894
|
+
}
|
|
14895
|
+
return null;
|
|
14896
|
+
}
|
|
14897
|
+
/**
|
|
14898
|
+
* Writes each judgement's proposals onto the session, checked against it as it is now. Run inside
|
|
14899
|
+
* the store write on the fresh copy, so a finding the reviewer decided while Buddy AI was reading is
|
|
14900
|
+
* written against what they decided, or not at all.
|
|
14901
|
+
*/
|
|
14902
|
+
function keepReconcileTurn(session, read, exchanges, askedAt) {
|
|
14903
|
+
const outcome = { judgements: [] };
|
|
14904
|
+
if (!read.reading) return outcome;
|
|
14905
|
+
const source = {
|
|
14906
|
+
kind: "buddy-conversation",
|
|
14907
|
+
exchanges: [...exchanges].sort((a, b) => a - b)
|
|
14908
|
+
};
|
|
14909
|
+
const analysis = analysisRefOf(session);
|
|
14910
|
+
for (const judgement of read.reading.judgements) {
|
|
14911
|
+
const finding = findingIn(session, judgement.kind, judgement.findingId);
|
|
14912
|
+
if (!finding) {
|
|
14913
|
+
outcome.judgements.push({
|
|
14914
|
+
judgement,
|
|
14915
|
+
status: { kind: "stale" },
|
|
14916
|
+
grade: null
|
|
14917
|
+
});
|
|
14918
|
+
continue;
|
|
14919
|
+
}
|
|
14920
|
+
const current = findingStatus(session.findingReassessments, judgement.kind, judgement.findingId, analysis).current;
|
|
14921
|
+
const to = verdictFor(judgement, current);
|
|
14922
|
+
let status;
|
|
14923
|
+
if (!to) status = { kind: "still-open" };
|
|
14924
|
+
else {
|
|
14925
|
+
const held = heldBack(session, judgement.kind, judgement.findingId, "status", to, source.exchanges);
|
|
14926
|
+
if (held) status = held;
|
|
14927
|
+
else {
|
|
14928
|
+
const write = reassessIn(session, {
|
|
14929
|
+
op: "propose-status",
|
|
14930
|
+
kind: judgement.kind,
|
|
14931
|
+
findingId: judgement.findingId,
|
|
14932
|
+
from: current,
|
|
14933
|
+
to,
|
|
14934
|
+
reason: judgement.reason,
|
|
14935
|
+
...judgement.answer ? { answer: judgement.answer } : {},
|
|
14936
|
+
...judgement.basis && to === "resolved" ? { basis: judgement.basis } : {},
|
|
14937
|
+
...judgement.evidence.length ? { evidence: judgement.evidence } : {},
|
|
14938
|
+
quotes: judgement.quotes,
|
|
14939
|
+
source
|
|
14940
|
+
}, askedAt);
|
|
14941
|
+
status = write.ok ? {
|
|
14942
|
+
kind: "proposed",
|
|
14943
|
+
entry: write.entry
|
|
14944
|
+
} : { kind: "stale" };
|
|
14945
|
+
}
|
|
14946
|
+
}
|
|
14947
|
+
let grade = null;
|
|
14948
|
+
if (judgement.grade) {
|
|
14949
|
+
const from = findingAssessment(session.findingReassessments, judgement.kind, finding, analysis).current;
|
|
14950
|
+
grade = heldBack(session, judgement.kind, judgement.findingId, "grade", judgement.grade.to, source.exchanges);
|
|
14951
|
+
if (!grade) {
|
|
14952
|
+
const write = reassessIn(session, {
|
|
14953
|
+
op: "propose",
|
|
14954
|
+
kind: judgement.kind,
|
|
14955
|
+
findingId: judgement.findingId,
|
|
14956
|
+
from,
|
|
14957
|
+
to: judgement.grade.to,
|
|
14958
|
+
reason: judgement.grade.reason,
|
|
14959
|
+
source
|
|
14960
|
+
}, askedAt);
|
|
14961
|
+
grade = write.ok ? {
|
|
14962
|
+
kind: "proposed",
|
|
14963
|
+
entry: write.entry
|
|
14964
|
+
} : write.refusal === "unchanged" ? null : { kind: "stale" };
|
|
14965
|
+
}
|
|
14966
|
+
}
|
|
14967
|
+
outcome.judgements.push({
|
|
14968
|
+
judgement,
|
|
14969
|
+
status,
|
|
14970
|
+
grade
|
|
14971
|
+
});
|
|
14972
|
+
}
|
|
14973
|
+
return outcome;
|
|
14974
|
+
}
|
|
14975
|
+
/** The ids of every proposal the turn wrote, for the transcript and for Accept all. */
|
|
14976
|
+
function proposedIds(outcome) {
|
|
14977
|
+
return outcome.judgements.flatMap(({ status, grade }) => [status, grade].flatMap((result) => result && result.kind === "proposed" ? [result.entry.id] : []));
|
|
14978
|
+
}
|
|
14979
|
+
/** One judgement's line, as the conversation says it: always a suggestion, never a change made. */
|
|
14980
|
+
function statusPhrase(judgement, status) {
|
|
14981
|
+
const to = status.kind === "proposed" || status.kind === "waiting" ? status.entry.to : null;
|
|
14982
|
+
switch (status.kind) {
|
|
14983
|
+
case "still-open": return "still open after this reply";
|
|
14984
|
+
case "declined-before": return "not suggested again: you kept it as it was on the same evidence";
|
|
14985
|
+
case "stale": return "not suggested: it changed while this was being read";
|
|
14986
|
+
case "waiting":
|
|
14987
|
+
case "proposed": {
|
|
14988
|
+
const phrase = to === "answered" ? "may be answered" : to === "partially-answered" ? "may be partly answered" : to === "resolved" ? "may be resolved" : to === "needs-investigation" ? "needs further investigation" : to === "unanswered" ? "may no longer be answered: this reply contradicts what you accepted" : to === "open" ? "may no longer be resolved: this reply contradicts what you accepted" : null;
|
|
14989
|
+
if (!phrase) return null;
|
|
14990
|
+
return status.kind === "waiting" ? `${phrase} (already waiting on the card)` : phrase;
|
|
14991
|
+
}
|
|
14992
|
+
}
|
|
14993
|
+
}
|
|
14994
|
+
function plural(n, noun) {
|
|
14995
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
14996
|
+
}
|
|
14997
|
+
/** "3 questions may be answered · 1 issue may be resolved · 1 needs further investigation". */
|
|
14998
|
+
function headline(outcome) {
|
|
14999
|
+
const count = (kind, to) => outcome.judgements.filter(({ judgement, status }) => judgement.kind === kind && status.kind === "proposed" && status.entry.to === to).length;
|
|
15000
|
+
return [
|
|
15001
|
+
[count("question", "answered"), (n) => `${plural(n, "question")} may be answered`],
|
|
15002
|
+
[count("question", "partially-answered"), (n) => `${plural(n, "question")} may be partly answered`],
|
|
15003
|
+
[count("issue", "resolved"), (n) => `${plural(n, "issue")} may be resolved`],
|
|
15004
|
+
[count("issue", "needs-investigation"), (n) => `${n} ${n === 1 ? "needs" : "need"} further investigation`],
|
|
15005
|
+
[outcome.judgements.filter(({ status }) => status.kind === "proposed" && (status.entry.to === "open" || status.entry.to === "unanswered")).length, (n) => `${n} earlier ${n === 1 ? "decision" : "decisions"} contradicted`],
|
|
15006
|
+
[outcome.judgements.filter(({ grade }) => grade?.kind === "proposed").length, (n) => `${plural(n, "grade change")} suggested`]
|
|
15007
|
+
].flatMap(([n, say]) => n > 0 ? [say(n)] : []).join(" · ");
|
|
15008
|
+
}
|
|
15009
|
+
/**
|
|
15010
|
+
* What the conversation shows for the turn: the suggestions, finding by finding, and nothing that
|
|
15011
|
+
* reads as a change made. Never the JSON. Findings the reply did not address are not mentioned,
|
|
15012
|
+
* because they were not judged.
|
|
15013
|
+
*/
|
|
15014
|
+
function reconcileTurnText(read, outcome, identity) {
|
|
15015
|
+
if (!read.reading) return `${read.raw.trim()}\n\n*This answer was not in the form suggestions need, so nothing was suggested and no finding changed.*`;
|
|
15016
|
+
const lines = outcome.judgements.flatMap(({ judgement, status, grade }) => {
|
|
15017
|
+
const said = [];
|
|
15018
|
+
const phrase = statusPhrase(judgement, status);
|
|
15019
|
+
if (phrase) said.push(phrase);
|
|
15020
|
+
if (grade?.kind === "proposed" || grade?.kind === "waiting") said.push(`its ${judgement.kind === "issue" ? "severity" : "importance"} may change to ${grade.entry.to}`);
|
|
15021
|
+
return said.length ? [`- **${judgement.ref}** ${said.join("; ")}. ${judgement.reason}`] : [];
|
|
15022
|
+
});
|
|
15023
|
+
const unmatched = read.reading.unmatched.map((passage) => `- Could not tell which finding this answers${passage.candidates.length ? ` (${passage.candidates.join(" or ")})` : ""}, so none was changed: “${passage.text}”${passage.why ? ` ${passage.why}` : ""}`);
|
|
15024
|
+
const dropped = read.reading.discarded.unquoted + read.reading.discarded.unknownRef;
|
|
15025
|
+
const droppedLine = dropped ? [`*${dropped === 1 ? "One judgement was" : `${dropped} judgements were`} left out: ${dropped === 1 ? "it" : "each"} named no finding in this review, or quoted words that are not in the turns.*`] : [];
|
|
15026
|
+
const top = headline(outcome);
|
|
15027
|
+
const unnamedLine = identity?.kind === "unnamed" ? [`*This reply does not name review ${identity.tag}, so any I or Q reference in it was trusted only where what it says matches that finding.*`] : [];
|
|
15028
|
+
if (!lines.length && !unmatched.length) return [
|
|
15029
|
+
"**Reconciled with findings.** Nothing in these turns bears on the review’s issues or questions, so nothing is suggested.",
|
|
15030
|
+
...unnamedLine,
|
|
15031
|
+
...droppedLine
|
|
15032
|
+
].join("\n\n");
|
|
15033
|
+
return [
|
|
15034
|
+
`**Reconciled with findings.**${top ? ` ${top}.` : ""}`,
|
|
15035
|
+
"These are suggestions. Nothing changes until you accept one on its card.",
|
|
15036
|
+
[...lines, ...unmatched].join("\n"),
|
|
15037
|
+
...unnamedLine,
|
|
15038
|
+
...droppedLine
|
|
15039
|
+
].join("\n\n");
|
|
15040
|
+
}
|
|
15041
|
+
/** The turns as the prompt labels them. */
|
|
15042
|
+
function evidenceTurns(turns) {
|
|
15043
|
+
return labelTurns(turns);
|
|
15044
|
+
}
|
|
14147
15045
|
//#endregion
|
|
14148
15046
|
//#region ../../packages/review-harness/src/workspace/diagram.ts
|
|
14149
15047
|
/** The same budget a question gets. Drawing is reading, and reading is what takes the time. */
|
|
@@ -15081,6 +15979,7 @@ async function handle(req, res, options) {
|
|
|
15081
15979
|
if (url.pathname === "/runs/remove") return void handleRunRemoval(req, res, options.indexToken);
|
|
15082
15980
|
if (url.pathname === "/runs/sections") return void handleRunsSections(req, res, options.indexToken);
|
|
15083
15981
|
if (url.pathname === "/help") return void handleHelp(req, res, url.searchParams, options.indexToken);
|
|
15982
|
+
if (url.pathname === "/tour") return void handleTour(req, res, url.searchParams, options.indexToken);
|
|
15084
15983
|
if (url.pathname === "/notifications/open") return handleNotificationOpen(req, res, url.searchParams, options.indexToken);
|
|
15085
15984
|
if (url.pathname === "/notifications/recent") return handleRecentNotifications(req, res, url.searchParams, options.indexToken);
|
|
15086
15985
|
if (url.pathname === "/notifications/live") return handleLiveNotifications(req, res, url.searchParams, options.indexToken);
|
|
@@ -15341,10 +16240,30 @@ function handleHelp(req, res, params, indexToken) {
|
|
|
15341
16240
|
unreadNotifications: unreadCount$1(),
|
|
15342
16241
|
driving: isDriving(),
|
|
15343
16242
|
theme: readPreferences().theme,
|
|
15344
|
-
sidebar: readPreferences().sidebar
|
|
16243
|
+
sidebar: readPreferences().sidebar,
|
|
16244
|
+
tour: tourUnavailable(recentWorkspaces(Number.MAX_SAFE_INTEGER), busyReviewIds()) ?? "available",
|
|
16245
|
+
tourFailed: params.get("tour") === "unavailable"
|
|
15345
16246
|
}), indexCookieHeader(indexToken));
|
|
15346
16247
|
}
|
|
15347
16248
|
/**
|
|
16249
|
+
* Reviews something is analysing or updating right now. The guided tour is not shown on these: what
|
|
16250
|
+
* it would point at is about to be replaced. An assistant answering, or a diagram being drawn, is
|
|
16251
|
+
* not one of these: the review under it stays as it is.
|
|
16252
|
+
*/
|
|
16253
|
+
function busyReviewIds() {
|
|
16254
|
+
return new Set(runningJobs().map((running) => running.job).filter((job) => purposeOf(job) !== "ask").map((job) => job.workspaceId));
|
|
16255
|
+
}
|
|
16256
|
+
/**
|
|
16257
|
+
* Replay guided tour, from Help. Chosen when it is pressed rather than when Help was drawn, so a
|
|
16258
|
+
* review that started updating in between is not opened to a tour that cannot start. With nothing to
|
|
16259
|
+
* open, back to Help's Welcome, which says why.
|
|
16260
|
+
*/
|
|
16261
|
+
function handleTour(req, res, params, indexToken) {
|
|
16262
|
+
if (!indexAuthorised(req, params, indexToken)) return refuseIndex(req, res);
|
|
16263
|
+
const review = chooseTourReview(recentWorkspaces(Number.MAX_SAFE_INTEGER), busyReviewIds());
|
|
16264
|
+
redirect(res, review ? `/r/${encodeURIComponent(review.id)}?t=${encodeURIComponent(review.token)}${TOUR_REPLAY_HASH}` : "/help?tour=unavailable#welcome");
|
|
16265
|
+
}
|
|
16266
|
+
/**
|
|
15348
16267
|
* Whether the caller is a browser navigating, rather than something reading the API.
|
|
15349
16268
|
*
|
|
15350
16269
|
* `Accept` is the only signal available and browsers are consistent about it: a navigation asks
|
|
@@ -16314,6 +17233,8 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
16314
17233
|
dismissedNotices: readPreferences().dismissedNotices,
|
|
16315
17234
|
promptsCollapsed: readPreferences().promptsCollapsed === true,
|
|
16316
17235
|
reviewLayout: readPreferences().reviewLayout ?? {},
|
|
17236
|
+
guidedTour: readPreferences().guidedTour,
|
|
17237
|
+
tourBusy: busyReviewIds().has(workspace.id),
|
|
16317
17238
|
changeMapRegister: readPreferences().changeMapRegister ?? "simple",
|
|
16318
17239
|
architecture: await architecturePane(workspace),
|
|
16319
17240
|
transcript: workspace.transcript,
|
|
@@ -16725,6 +17646,22 @@ async function handleAsk(req, res, workspace) {
|
|
|
16725
17646
|
...reassessed,
|
|
16726
17647
|
from: currentGradeIn(workspace.session, reassessed.kind, reassessed.id) ?? null
|
|
16727
17648
|
} : null;
|
|
17649
|
+
let reconcileTurns = null;
|
|
17650
|
+
let reconcileIdentity = null;
|
|
17651
|
+
if (body?.reconciles !== void 0 && !explains && !reassessed) {
|
|
17652
|
+
if (!workspace.session.result) return send(res, 409, { error: "This review has no findings to reconcile with yet." });
|
|
17653
|
+
const chosen = reconcileTurnsOf(workspace.transcript, body.reconciles);
|
|
17654
|
+
if (!chosen.ok) return send(res, 400, {
|
|
17655
|
+
error: TURNS_REFUSED[chosen.refusal],
|
|
17656
|
+
refusal: chosen.refusal
|
|
17657
|
+
});
|
|
17658
|
+
reconcileIdentity = reviewIdentityOf(chosen.turns, workspace.session.id);
|
|
17659
|
+
if (reconcileIdentity.kind === "other") return send(res, 409, {
|
|
17660
|
+
error: otherReviewRefusal(reconcileIdentity),
|
|
17661
|
+
refusal: "other-review"
|
|
17662
|
+
});
|
|
17663
|
+
reconcileTurns = evidenceTurns(chosen.turns);
|
|
17664
|
+
}
|
|
16728
17665
|
if (liveUpdateFor(workspace.id)) return send(res, 409, {
|
|
16729
17666
|
error: "This review is being updated. Ask again when the new review is ready.",
|
|
16730
17667
|
updating: true
|
|
@@ -16765,6 +17702,13 @@ async function handleAsk(req, res, workspace) {
|
|
|
16765
17702
|
...explains ? { explaining: explains } : {},
|
|
16766
17703
|
...continuing ? { continuing } : {},
|
|
16767
17704
|
...reassessed ? { reassessing: reassessed } : {},
|
|
17705
|
+
...reconcileTurns && reconcileIdentity ? { reconciling: {
|
|
17706
|
+
turns: reconcileTurns,
|
|
17707
|
+
review: {
|
|
17708
|
+
tag: reconcileIdentity.tag,
|
|
17709
|
+
named: reconcileIdentity.kind === "named"
|
|
17710
|
+
}
|
|
17711
|
+
} } : {},
|
|
16768
17712
|
conversation: prepared
|
|
16769
17713
|
});
|
|
16770
17714
|
} catch (error) {
|
|
@@ -16787,21 +17731,39 @@ async function handleAsk(req, res, workspace) {
|
|
|
16787
17731
|
commitSha: workspace.session.metadata.commitSha
|
|
16788
17732
|
}) : null;
|
|
16789
17733
|
let reassessOutcome = null;
|
|
17734
|
+
const reconcileRead = reconcileTurns && workspace.session.result ? await readReconcileTurn(answer, workspace.session.result, reconcileTurns, {
|
|
17735
|
+
repoPath: workspace.repoPath,
|
|
17736
|
+
commitSha: workspace.session.metadata.commitSha
|
|
17737
|
+
}) : null;
|
|
17738
|
+
let reconcileOutcome = null;
|
|
17739
|
+
let keptAt = 0;
|
|
16790
17740
|
let explanation;
|
|
16791
17741
|
let shown = answer;
|
|
16792
17742
|
const saved = mutateWorkspace(workspace.id, (fresh) => {
|
|
16793
17743
|
const askedAt = Date.now();
|
|
16794
17744
|
const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
|
|
17745
|
+
keptAt = askedAt;
|
|
16795
17746
|
if (turn && reassessing) {
|
|
16796
17747
|
reassessOutcome = keepReassessTurn(fresh.session, reassessing, turn, askedAt);
|
|
16797
17748
|
shown = reassessTurnText(reassessing, turn, reassessOutcome);
|
|
16798
17749
|
}
|
|
17750
|
+
let reconciles;
|
|
17751
|
+
if (reconcileRead && reconcileTurns) {
|
|
17752
|
+
const exchanges = reconcileTurns.map((evidence) => evidence.askedAt);
|
|
17753
|
+
reconcileOutcome = keepReconcileTurn(fresh.session, reconcileRead, exchanges, askedAt);
|
|
17754
|
+
shown = reconcileTurnText(reconcileRead, reconcileOutcome, reconcileIdentity ?? void 0);
|
|
17755
|
+
reconciles = {
|
|
17756
|
+
exchanges,
|
|
17757
|
+
proposals: proposedIds(reconcileOutcome)
|
|
17758
|
+
};
|
|
17759
|
+
}
|
|
16799
17760
|
fresh.transcript.push({
|
|
16800
17761
|
question,
|
|
16801
17762
|
answer: shown,
|
|
16802
17763
|
sectionId,
|
|
16803
17764
|
askedAt,
|
|
16804
|
-
...kept ? { explains: kept } : {}
|
|
17765
|
+
...kept ? { explains: kept } : {},
|
|
17766
|
+
...reconciles ? { reconciles } : {}
|
|
16805
17767
|
});
|
|
16806
17768
|
if (kept) {
|
|
16807
17769
|
explanation = {
|
|
@@ -16817,13 +17779,23 @@ async function handleAsk(req, res, workspace) {
|
|
|
16817
17779
|
outcome: reassessOutcome,
|
|
16818
17780
|
...card ? { card } : {}
|
|
16819
17781
|
} } : {};
|
|
16820
|
-
|
|
16821
|
-
|
|
16822
|
-
|
|
16823
|
-
|
|
16824
|
-
|
|
17782
|
+
const reconciliation = reconcileOutcome && saved ? { reconciliation: {
|
|
17783
|
+
proposals: proposedIds(reconcileOutcome),
|
|
17784
|
+
cards: reconcileOutcome.judgements.flatMap(({ judgement }) => {
|
|
17785
|
+
const parts = gradeFragments(saved, judgement.kind, judgement.findingId);
|
|
17786
|
+
return parts ? [parts] : [];
|
|
17787
|
+
})
|
|
17788
|
+
} } : {};
|
|
17789
|
+
const turnRef = saved ? {
|
|
17790
|
+
askedAt: keptAt,
|
|
17791
|
+
...reconcileOutcome ? { reconcileTurn: true } : {}
|
|
17792
|
+
} : {};
|
|
17793
|
+
sendIfOpen(res, 200, {
|
|
16825
17794
|
answer: shown,
|
|
16826
|
-
...
|
|
17795
|
+
...explanation ? { explanation } : {},
|
|
17796
|
+
...reassessment,
|
|
17797
|
+
...reconciliation,
|
|
17798
|
+
...turnRef
|
|
16827
17799
|
});
|
|
16828
17800
|
} finally {
|
|
16829
17801
|
endRun(run);
|
|
@@ -17119,6 +18091,7 @@ async function handlePrefs(req, res) {
|
|
|
17119
18091
|
const layout = readReviewLayoutPatch(body?.reviewLayout);
|
|
17120
18092
|
if (layout) writeReviewLayout(layout);
|
|
17121
18093
|
if (body?.changeMapRegister === "simple" || body?.changeMapRegister === "technical") writeChangeMapRegister(body.changeMapRegister);
|
|
18094
|
+
if (isGuidedTourState(body?.guidedTour)) writeGuidedTour(body.guidedTour);
|
|
17122
18095
|
if (typeof body?.dismissNotice === "string" && !dismissNotice(body.dismissNotice)) return send(res, 400, { error: "Not a notice this page can dismiss." });
|
|
17123
18096
|
send(res, 200, { ok: true });
|
|
17124
18097
|
}
|
|
@@ -17233,7 +18206,8 @@ var ASK_SOURCES = [
|
|
|
17233
18206
|
"free_text",
|
|
17234
18207
|
"explain_simply",
|
|
17235
18208
|
"selection",
|
|
17236
|
-
"finding"
|
|
18209
|
+
"finding",
|
|
18210
|
+
"reconcile"
|
|
17237
18211
|
];
|
|
17238
18212
|
function askSource(value) {
|
|
17239
18213
|
return ASK_SOURCES.includes(value) ? value : "free_text";
|