prreviewbuddy 0.52.2 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as compactQueue, $t as loadWorkspace, A as createPrState, An as reviewerDispositions, At as followedBranch, B as toneOf, Bn as resolveModel, Bt as phasesFor, C as readEvents, Cn as queueStamp, Ct as claimRequestUrl, D as announceJob, Dn as isQuestionOutstanding, Dt as fillFileUrlTemplate, E as telemetryUploadConsent, En as isIssueOutstanding, Et as remoteHost, F as markAllRead, Ft as isTerminal, G as extractJsonText, Gn as STORE_ROOT, Gt as isClaimed, H as withUsageRecorded, Hn as readAgentPreference, Ht as purposeOf, I as markRead, It as loadJob, J as readMarker, Jt as indexKeptIds, K as ReviewBeingDeletedError, Kn as AGENT_IDS, Kt as followedRefName, L as pruneNotifications, Ln as isCommitReview, Lt as saveJob, M as eventName, Mn as describeAuthorship, Mt as allJobs, N as headline, Nn as WITHHELD_REASON, Nt as endedAt, O as branchOf, On as issuesOutstanding, Ot as checkCodeFreshness, P as listNotifications, Pn as kissOutcome, Pt as fail, Q as activateQueued, Qn as AgentUnavailableError, Qt as lineagePosition, R as readNotification, Rn as git, Sn as queueRemove, St as writePortPreference, T as recordTelemetryUploadConsent, Tn as doneVerb, Tt as forgeFor, U as EXPLAIN_SIMPLY_PROMPT, Un as writeAgentPreference, V as unreadCount$1, Vn as clearAgentPreference, Vt as progressSteps, W as asString, Xt as latestKissRun, Y as removeWorktree, Yn as agentFor, Yt as indexReviewGroups, Z as relativeTime$1, Zn as AgentCancelledError, _ as agentEnvOf, _n as offersLifecycle, _t as MAX_WORKSPACE_PORT, a as wasBlocked, an as reviewedCommit, bn as queueAdd, bt as resolveWorkspacePort, c as RefreshUnavailableError, cn as touchWorkspace, ct as serverEntryExists, d as refreshPrContext, dn as LIFECYCLES, f as isUnchanged, fn as activityLabel, ft as writeServerRecord, gn as lifecyclePill, gt as DEFAULT_WORKSPACE_PORT, h as liveUpdateFor, hn as lifecycleChip, ht as feedbackUrl, i as liveJobsFor, in as recentWorkspaces, it as clearServerRecord, j as createPrUpdate, jn as processDiscussion, jt as reviewedShaOf, k as subjectOf, kn as questionsOutstanding, kt as checkReview, l as mergeUpdateInto, ln as workspaceRevision, lt as spawnServer, m as UpdateAlreadyRunningError, mn as lifecycleAction, n as deleteReview, nt as readIndexToken, o as discardJob, ot as readServerRecord, p as askCheckout, pn as isLifecycle, pt as BUILD_VERSION, q as MANAGED_ROOT, qt as groupByLineage, r as lineageIds, rn as previousKissResult, s as FETCH_TIMEOUT_MS, t as deleteLineage, tn as mutateWorkspace, tt as settleQueue, u as updateReview, un as workspaceStamps, vn as parseLifecycle, vt as MIN_WORKSPACE_PORT, w as record, wn as readQueue, x as runJob, xn as queueAddMany, xt as statedWorkspacePort, y as startKissJob, yn as sectionOf, yt as parseWorkspacePort, z as removeNotification, zt as isLiveJob } from "./delete_review-BiV1aBpx.js";
1
+ import { $ as relativeTime$1, $n as AGENT_IDS, $t as kissDecisionRefusal, A as createPrState, An as readQueue, At as checkCodeFreshness, B as toneOf, Bn as WITHHELD_REASON, C as readEvents, Cn as offersLifecycle, Ct as statedWorkspacePort, D as announceJob, Dn as queueAddMany, Dt as forgeFor, E as telemetryUploadConsent, En as queueAdd, F as markAllRead, Fn as questionsOutstanding, Ft as endedAt, G as isLongDescription, Gn as git, H as withUsageRecorded, Ht as phasesFor, I as markRead, In as reviewerDispositions, It as fail, J as ReviewBeingDeletedError, Jn as clearAgentPreference, Jt as decideKissRecommendation, K as asString, L as pruneNotifications, Ln as processDiscussion, Lt as isTerminal, M as eventName, Mn as isIssueOutstanding, Mt as followedBranch, N as headline, Nn as isQuestionOutstanding, Nt as reviewedShaOf, O as branchOf, On as queueRemove, Ot as remoteHost, P as listNotifications, Pn as issuesOutstanding, Pt as allJobs, Qn as STORE_ROOT, Qt as indexReviewGroups, R as readNotification, Rn as recommendationsOf, Rt as loadJob, Sn as lifecyclePill, St as resolveWorkspacePort, T as recordTelemetryUploadConsent, Tn as sectionOf, Tt as claimRequestUrl, U as EXPLAIN_SIMPLY_PROMPT, Ut as progressSteps, V as unreadCount$1, Vn as kissOutcome, Vt as isLiveJob, W as hasAuthorContent, Wn as isCommitReview, Wt as purposeOf, X as readMarker, Xn as writeAgentPreference, Xt as groupByLineage, Y as MANAGED_ROOT, Yn as readAgentPreference, Yt as followedRefName, Z as removeWorktree, Zt as indexKeptIds, _ as agentEnvOf, _n as LIFECYCLES, _t as feedbackUrl, a as wasBlocked, ar as AgentUnavailableError, b as reanalyseReview, bn as lifecycleAction, bt as MIN_WORKSPACE_PORT, c as RefreshUnavailableError, cn as presentedKissRunOf, ct as readServerRecord, d as refreshPrContext, dn as reviewedCommit, dt as spawnServer, en as kissDecisionsOf, et as activateQueued, f as isUnchanged, gn as workspaceStamps, h as liveUpdateFor, hn as workspaceRevision, ht as BUILD_VERSION, i as liveJobsFor, in as loadWorkspace, ir as AgentCancelledError, it as readIndexToken, j as createPrUpdate, jn as doneVerb, jt as checkReview, k as subjectOf, kn as queueStamp, kt as fillFileUrlTemplate, l as mergeUpdateInto, ln as previousKissResult, m as UpdateAlreadyRunningError, mn as touchWorkspace, mt as writeServerRecord, n as deleteReview, nr as agentFor, o as discardJob, on as mutateWorkspace, ot as clearServerRecord, p as askCheckout, q as extractJsonText, qn as resolveModel, qt as isClaimed, r as lineageIds, rn as lineagePosition, rt as settleQueue, s as FETCH_TIMEOUT_MS, t as deleteLineage, tn as latestKissRun, tt as compactQueue, u as updateReview, un as recentWorkspaces, ut as serverEntryExists, vn as activityLabel, vt as DEFAULT_WORKSPACE_PORT, w as record, wn as parseLifecycle, wt as writePortPreference, x as runJob, xn as lifecycleChip, xt as parseWorkspacePort, y as startKissJob, yn as isLifecycle, yt as MAX_WORKSPACE_PORT, z as removeNotification, zn as describeAuthorship, zt as saveJob } from "./delete_review-B2bqhgtx.js";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -186,6 +186,7 @@ function composeReview(session) {
186
186
  return {
187
187
  schemaVersion: 1,
188
188
  summary: primary.summary,
189
+ ...primary.motivation ? { motivation: primary.motivation } : {},
189
190
  recommendation: primary.recommendation,
190
191
  nextSteps: primary.nextSteps,
191
192
  overallRisk,
@@ -839,13 +840,28 @@ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
839
840
  * a chat reply's "Copy message".
840
841
  */
841
842
  function renderFindingCopySource(source, kind) {
842
- const name = kind === "issue" ? "Copy finding" : "Copy question";
843
+ const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
843
844
  return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
844
845
  label: "",
845
846
  aria: name,
846
847
  tip: name
847
848
  });
848
849
  }
850
+ /**
851
+ * The same control with its word showing, for a footer of worded actions.
852
+ *
853
+ * A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
854
+ * would read as decoration. `extra` is for attributes the caller's own client needs, such as the
855
+ * handle Copy recommendations is found by; the source may be updated after render, since the
856
+ * listener reads it at the moment of the click.
857
+ */
858
+ function renderLabelledCopySource(source, label, extra = "") {
859
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
860
+ label,
861
+ aria: label,
862
+ tip: label
863
+ });
864
+ }
849
865
  /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
850
866
  var IDLE_LABEL_ATTR = "data-copy-idle-label";
851
867
  var IDLE_ARIA_ATTR = "data-copy-idle-aria";
@@ -1097,6 +1113,32 @@ function renderInline(value) {
1097
1113
  }).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
1098
1114
  }
1099
1115
  //#endregion
1116
+ //#region ../../packages/review-harness/src/domain/ui/components/intent_note.ts
1117
+ /**
1118
+ * The Overview's unclear motive, kept in step with its Intent question.
1119
+ *
1120
+ * Its own module for the same reason the Author's context toggle is: the workspace marks a question
1121
+ * answered in place, without re-rendering the Overview, and its client should not have to bundle
1122
+ * the tab renderer to keep the two in agreement. The markup is written by `renderWhyThisChange`.
1123
+ *
1124
+ * Marking a question answered records that it was answered, never what the answer was, so nothing
1125
+ * here touches the stored motivation. It only says the analysis is older than the answer.
1126
+ */
1127
+ var INTENT_NOTE_COPY = "This question is marked answered. If the answer was added to the PR description or conversation, run the review again to incorporate it.";
1128
+ var RUN_REVIEW_AGAIN = "Run review again";
1129
+ //#endregion
1130
+ //#region ../../packages/review-harness/src/domain/ui/components/author_context_toggle.ts
1131
+ /**
1132
+ * The Author's context preview, opened and closed.
1133
+ *
1134
+ * Its own module because both hosts call it from their own click handling, and the workspace
1135
+ * client should not have to bundle the whole tab renderer to do it. The markup it acts on is
1136
+ * written by `renderAuthorContext` in review_tabs.ts, from the same constants.
1137
+ */
1138
+ var AUTHOR_CONTEXT_COLLAPSED_CLASS = "pr-helper-author-context-collapsed";
1139
+ var SHOW_FULL_DESCRIPTION = "Show full description";
1140
+ var AUTHOR_CONTEXT_SECTION_ID = "pr-helper-author-context";
1141
+ //#endregion
1100
1142
  //#region ../../packages/review-harness/src/domain/utils/model_label.ts
1101
1143
  var KNOWN_BRANDS = {
1102
1144
  gpt: "GPT",
@@ -1735,6 +1777,109 @@ function renderOverviewActions(session, options) {
1735
1777
  if (!options?.diagrams) return explain;
1736
1778
  return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-map-button" data-view-nav="map">Diagrams</button></div>`;
1737
1779
  }
1780
+ /**
1781
+ * The pull request's description, as the author wrote it, ahead of anything we say about the change.
1782
+ *
1783
+ * Read from the analysis's own snapshot, never from the live request, so an older analysis shows
1784
+ * the description it was made against. Nothing at all when there is none or it is an unfilled
1785
+ * template: an empty "Author's context" would read as the author having said nothing, when on a
1786
+ * local branch there was never anyone to ask.
1787
+ *
1788
+ * Long descriptions are clamped rather than cut. The whole text is in the page, checklists and
1789
+ * testing notes included, and the button only changes how much of it shows.
1790
+ */
1791
+ function renderAuthorContext(session) {
1792
+ const description = session.metadata.authorDescription;
1793
+ if (!hasAuthorContent(description)) return "";
1794
+ const id = "pr-helper-author-context-body";
1795
+ const long = isLongDescription(description);
1796
+ const toggle = long ? `<button type="button" class="pr-helper-author-context-toggle" aria-expanded="false" aria-controls="${id}" data-author-context-toggle>${SHOW_FULL_DESCRIPTION}</button>` : "";
1797
+ return `
1798
+ <section class="pr-helper-author-context" id="${AUTHOR_CONTEXT_SECTION_ID}" aria-labelledby="pr-helper-author-context-heading">
1799
+ <h4 class="pr-helper-overview-heading" id="pr-helper-author-context-heading" tabindex="-1">Author's context</h4>
1800
+ <div class="pr-helper-author-context-body pr-helper-markdown${long ? ` ${AUTHOR_CONTEXT_COLLAPSED_CLASS}` : ""}" id="${id}">${renderMarkdown(description)}</div>
1801
+ ${toggle}
1802
+ </section>
1803
+ `;
1804
+ }
1805
+ /**
1806
+ * An evidence path, presented as what the host could establish about it. A changed file opens in
1807
+ * the diff and a real unchanged one in the source view; a path nobody could check is shown as text,
1808
+ * with no `data-file`, so nothing invites the reader to trust it as a link.
1809
+ */
1810
+ function renderEvidenceFile(item) {
1811
+ if (!item.file) return "";
1812
+ if (item.fileState === "changed") return renderFileLocation(item.file, "pr-helper-why-evidence-file");
1813
+ if (item.fileState === "unchanged") return renderRelatedFiles([item.file]);
1814
+ return `<span class="pr-helper-why-evidence-unverified"><code>${escapeHtml$1(item.file)}</code> · unverified path</span>`;
1815
+ }
1816
+ var MOTIVATION_LABELS = {
1817
+ stated: "Stated by the author",
1818
+ inferred: "Likely motivation",
1819
+ unclear: "Motivation unclear"
1820
+ };
1821
+ var EVIDENCE_LABELS = {
1822
+ test: "Test",
1823
+ behaviour: "Behaviour",
1824
+ issue: "Issue",
1825
+ commit: "Commit",
1826
+ docs: "Docs",
1827
+ code: "Code"
1828
+ };
1829
+ /**
1830
+ * Why the change exists, and how the review knows.
1831
+ *
1832
+ * Every status shows what supports it: a stated motive its quote, an inferred one its evidence, an
1833
+ * unclear one the question that would settle it. `verifyMotivation` guarantees each is there, so a
1834
+ * status rendered without its support would be a stored result this version did not write.
1835
+ *
1836
+ * Nothing for a result with no motivation, which covers every review made before the field and
1837
+ * every review where the motive would not change how it should be read.
1838
+ */
1839
+ function renderWhyThisChange(motivation, session, questions) {
1840
+ if (!motivation) return "";
1841
+ if (motivation.status === "unclear") return renderUnclearMotive(motivation, session, questions);
1842
+ let support = "";
1843
+ if (motivation.status === "stated" && motivation.sourceQuote) {
1844
+ const source = hasAuthorContent(session.metadata.authorDescription) ? `<button type="button" class="pr-helper-why-link" data-author-context-reveal>From Author's context</button>` : "";
1845
+ support = `<blockquote class="pr-helper-why-quote">${renderInlineText(motivation.sourceQuote)}</blockquote>${source}`;
1846
+ } else if (motivation.status === "inferred" && motivation.evidence?.length) support = `<ul class="pr-helper-why-evidence">${motivation.evidence.map((item) => `<li><span class="pr-helper-why-evidence-kind">${EVIDENCE_LABELS[item.kind]}</span> ${renderInlineText(item.detail)}${renderEvidenceFile(item)}</li>`).join("")}</ul>`;
1847
+ return `
1848
+ <section class="pr-helper-why" aria-labelledby="pr-helper-why-heading">
1849
+ <h4 class="pr-helper-overview-heading" id="pr-helper-why-heading">Why this change?</h4>
1850
+ <p class="pr-helper-why-status pr-helper-why-status-${motivation.status}">${MOTIVATION_LABELS[motivation.status]}</p>
1851
+ <p class="pr-helper-why-summary">${renderInlineText(motivation.summary)}</p>
1852
+ ${support}
1853
+ </section>
1854
+ `;
1855
+ }
1856
+ /**
1857
+ * An unclear motive, and what became of its question.
1858
+ *
1859
+ * Once the question is answered the stored motivation is still the analysis's, so it is shown as
1860
+ * that: unclear when analysed. Only a new analysis that reads the answer may replace it, and the
1861
+ * button runs one. Both halves are rendered and one is hidden, so the workspace can switch them
1862
+ * when a question is marked answered in place (`syncIntentNote`).
1863
+ */
1864
+ function renderUnclearMotive(motivation, session, questions) {
1865
+ const intent = questions.find((q) => q.category === "intent");
1866
+ const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? [])) : false;
1867
+ const hide = (yes) => yes ? " hidden" : "";
1868
+ return `
1869
+ <section class="pr-helper-why" aria-labelledby="pr-helper-why-heading"${intent ? ` data-intent-question="${escapeHtml$1(intent.id)}"` : ""}>
1870
+ <h4 class="pr-helper-overview-heading" id="pr-helper-why-heading">Why this change?</h4>
1871
+ <p class="pr-helper-why-status pr-helper-why-status-unclear" data-intent-open${hide(answered)}>${MOTIVATION_LABELS.unclear}</p>
1872
+ <p class="pr-helper-why-status pr-helper-why-status-unclear" data-intent-answered${hide(!answered)}>Motivation was unclear when analysed</p>
1873
+ <p class="pr-helper-why-summary">${renderInlineText(motivation.summary)}</p>
1874
+ ${motivation.authorQuestion ? `<p class="pr-helper-why-question">${renderInlineText(motivation.authorQuestion)}</p>` : ""}
1875
+ <button type="button" class="pr-helper-why-link" data-overview-nav="questions" data-intent-open${hide(answered)}>Asked in Questions, under Intent</button>
1876
+ <div class="pr-helper-why-answered" data-intent-answered${hide(!answered)}>
1877
+ <p class="pr-helper-why-answered-note">${INTENT_NOTE_COPY}</p>
1878
+ <button type="button" class="pr-helper-why-rerun" data-run-review-again>${RUN_REVIEW_AGAIN}</button>
1879
+ </div>
1880
+ </section>
1881
+ `;
1882
+ }
1738
1883
  function renderOverviewTab(session, iconUrl = "", options) {
1739
1884
  if (!session) return renderNoReviewState(iconUrl);
1740
1885
  if (session.status === "generating") return renderGeneratingOverview(session, iconUrl);
@@ -1744,6 +1889,12 @@ function renderOverviewTab(session, iconUrl = "", options) {
1744
1889
  const nextSteps = result.nextSteps.map((step) => `<li class="pr-helper-markdown">${renderMarkdown(step)}</li>`).join("") || "<li>None</li>";
1745
1890
  const metrics = deriveReviewMetrics(result);
1746
1891
  return `
1892
+ ${renderAuthorContext(session)}
1893
+ ${renderWhyThisChange(result.motivation, session, result.questions)}
1894
+ <section class="pr-helper-whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
1895
+ <h4 class="pr-helper-overview-heading" id="pr-helper-whats-changing-heading">What's changing</h4>
1896
+ <div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
1897
+ </section>
1747
1898
  <div class="pr-helper-risk-headline">
1748
1899
  <div class="pr-helper-risk-headline-top">
1749
1900
  <span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
@@ -1759,7 +1910,6 @@ function renderOverviewTab(session, iconUrl = "", options) {
1759
1910
  ${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
1760
1911
  ${renderCoverageStat(session, metrics.files)}
1761
1912
  </div>
1762
- <div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
1763
1913
  ${renderSuggestedPath(result.sections, "Suggested review path")}
1764
1914
  ${renderOverviewActions(session, options)}
1765
1915
  ${renderReviewDetails(session, options)}
@@ -2345,6 +2495,16 @@ function renderDismissToggle(issue, isDismissed, stale, dismissal) {
2345
2495
  const settled = issue.reconciliationStatus === "resolved";
2346
2496
  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\"" : ""}>${label}</button>`;
2347
2497
  }
2498
+ /**
2499
+ * The group an Intent question lives in, keyed like a theme's so the workspace client finds its
2500
+ * way back to it the same way: by the card's `data-section-id`. Prefixed so no model-chosen theme
2501
+ * id can be the same string.
2502
+ */
2503
+ var INTENT_QUESTION_GROUP = "prb-intent";
2504
+ /** Where a question card belongs on the Questions tab: Intent, its theme, or neither. */
2505
+ function questionHome(q) {
2506
+ return q.category === "intent" ? INTENT_QUESTION_GROUP : q.sectionId;
2507
+ }
2348
2508
  function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options) {
2349
2509
  const importanceBadge = q.importance ? `<span${pillAttrs(`pr-helper-question-importance-badge pr-helper-question-importance-${q.importance}`, importanceTip(q.importance))}>${escapeHtml$1(q.importance)}</span>` : "";
2350
2510
  const jumpLink = renderQuestionJump(q.file, options);
@@ -2353,7 +2513,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
2353
2513
  const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
2354
2514
  const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>Answered by the new changes</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
2355
2515
  return `
2356
- <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)}"${q.sectionId ? ` data-section-id="${escapeHtml$1(q.sectionId)}"` : ""}>
2516
+ <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))}"` : ""}>
2357
2517
  <div class="pr-helper-issue-header">
2358
2518
  <div class="pr-helper-issue-title-row">
2359
2519
  <p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
@@ -2429,10 +2589,18 @@ function renderQuestionsTab(session, investigatingId, options) {
2429
2589
  const open = result.questions.filter((q) => isQuestionOutstanding(q, answered));
2430
2590
  const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered));
2431
2591
  const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0);
2432
- if (result.sections.length === 0) return `${progress}${open.map(card).join("")}${closed}`;
2592
+ const intent = open.filter((q) => q.category === "intent");
2593
+ const intentHtml = result.questions.some((q) => q.category === "intent") ? `
2594
+ <div class="pr-helper-question-group" data-question-group="${INTENT_QUESTION_GROUP}"${intent.length ? "" : " hidden"}>
2595
+ <h5 class="pr-helper-question-group-title">Intent</h5>
2596
+ ${intent.map(card).join("")}
2597
+ </div>
2598
+ ` : "";
2599
+ const rest = open.filter((q) => q.category !== "intent");
2600
+ if (result.sections.length === 0) return `${progress}${intentHtml}${rest.map(card).join("")}${closed}`;
2433
2601
  const grouped = /* @__PURE__ */ new Map();
2434
2602
  const ungrouped = [];
2435
- for (const q of open) if (q.sectionId && sections.has(q.sectionId)) {
2603
+ for (const q of rest) if (q.sectionId && sections.has(q.sectionId)) {
2436
2604
  const list = grouped.get(q.sectionId) ?? [];
2437
2605
  list.push(q);
2438
2606
  grouped.set(q.sectionId, list);
@@ -2447,8 +2615,8 @@ function renderQuestionsTab(session, investigatingId, options) {
2447
2615
  * card to the bottom of the tab (which is what it did) puts it below the answered drawer,
2448
2616
  * outside any theme, where a reload then moves it somewhere else.
2449
2617
  */
2450
- const themed = new Set(result.questions.filter((q) => q.sectionId).map((q) => q.sectionId));
2451
- return `${progress}${result.sections.filter((s) => themed.has(s.id)).map((s) => `
2618
+ const themed = new Set(result.questions.filter((q) => q.sectionId && q.category !== "intent").map((q) => q.sectionId));
2619
+ return `${progress}${intentHtml}${result.sections.filter((s) => themed.has(s.id)).map((s) => `
2452
2620
  <div class="pr-helper-question-group" data-question-group="${escapeHtml$1(s.id)}"${grouped.has(s.id) ? "" : " hidden"}>
2453
2621
  <h5 class="pr-helper-question-group-title">${escapeHtml$1(s.title)}</h5>
2454
2622
  ${(grouped.get(s.id) ?? []).map(card).join("")}
@@ -3565,6 +3733,58 @@ function verdictLabel(verdict) {
3565
3733
  return VERDICT_LABELS[verdict] ?? verdict;
3566
3734
  }
3567
3735
  //#endregion
3736
+ //#region ../../packages/review-harness/src/domain/kiss/handoff.ts
3737
+ function handoffSource(recommendation) {
3738
+ const { id, title, gist, complexity, whyNotJustified, simplerDirection, draftComment, locations } = recommendation;
3739
+ return {
3740
+ id,
3741
+ title,
3742
+ ...gist ? { gist } : {},
3743
+ ...complexity ? { complexity } : {},
3744
+ whyNotJustified,
3745
+ simplerDirection,
3746
+ ...draftComment ? { draftComment } : {},
3747
+ locations
3748
+ };
3749
+ }
3750
+ /**
3751
+ * One recommendation: the title, why it matters, the simpler approach, where to start, and the
3752
+ * draft comment when the run wrote one.
3753
+ *
3754
+ * "Why it matters" is the gist, which carries the case in a sentence or two. A schema 1 record has
3755
+ * none, and its `whyNotJustified` is the field of that shape that says why the complexity is not
3756
+ * needed, so it stands in rather than leaving the line out.
3757
+ */
3758
+ function recommendationMarkdown(recommendation) {
3759
+ const why = recommendation.gist || recommendation.whyNotJustified;
3760
+ const primary = recommendation.locations.find((location) => location.primary) ?? recommendation.locations[0];
3761
+ return [
3762
+ `### ${recommendation.title.trim()}`,
3763
+ why ? `**Why it matters:** ${why.trim()}` : "",
3764
+ `**Simpler approach:** ${recommendation.simplerDirection.trim()}`,
3765
+ primary ? `**Start here:** \`${primary.line ? `${primary.path}:${primary.line}` : primary.path}\`` : "",
3766
+ recommendation.draftComment?.trim() ? `**Draft comment:**\n\n${quote(recommendation.draftComment.trim())}` : ""
3767
+ ].filter((part) => part !== "").join("\n\n");
3768
+ }
3769
+ /** The reviewer's handoff: the recommendations they chose to raise, in the order given. */
3770
+ function handoffMarkdown(recommendations) {
3771
+ if (recommendations.length === 0) return "";
3772
+ return [`## Simplification suggestions (${recommendations.length})`, ...recommendations.map(recommendationMarkdown)].join("\n\n");
3773
+ }
3774
+ function quote(text) {
3775
+ return text.split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
3776
+ }
3777
+ /**
3778
+ * The sources as JSON that is safe inside a `<script type="application/json">` element.
3779
+ *
3780
+ * JSON unicode escapes rather than HTML entities: `JSON.parse` restores the original characters,
3781
+ * while `<\/script>` in a title or a draft can no longer end the element early. `&` is escaped too
3782
+ * so no sequence in the text can be read as markup by anything that looks at the raw element.
3783
+ */
3784
+ function embedHandoffSources(sources) {
3785
+ return JSON.stringify(sources).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
3786
+ }
3787
+ //#endregion
3568
3788
  //#region ../../packages/review-harness/src/workspace/kiss_view.ts
3569
3789
  /**
3570
3790
  * The KISS review, on the page.
@@ -3628,11 +3848,13 @@ var INTENTS = [{
3628
3848
  audience: "author",
3629
3849
  label: "Review my code",
3630
3850
  describes: "Reviewing my code",
3851
+ perspective: "My code",
3631
3852
  consequence: "Get direct suggestions to simplify your implementation."
3632
3853
  }, {
3633
3854
  audience: "reviewer",
3634
3855
  label: "Review someone else's code",
3635
3856
  describes: "Reviewing someone else's code",
3857
+ perspective: "Someone else's code",
3636
3858
  consequence: "Decide which simplifications are worth raising with the author."
3637
3859
  }];
3638
3860
  /**
@@ -3648,6 +3870,61 @@ function audienceSentence(audience) {
3648
3870
  return INTENTS.find((intent) => intent.audience === audience)?.describes ?? "";
3649
3871
  }
3650
3872
  /**
3873
+ * The two decisions, in each audience's words.
3874
+ *
3875
+ * One stored value, two vocabularies: an author accepts by planning the change and a reviewer by
3876
+ * raising it, and neither declines by dismissing, because declining is not a verdict that the
3877
+ * recommendation was wrong. Total over both unions, so a new audience or decision will not build
3878
+ * without its words. Exported for the client, which relabels a card after a click.
3879
+ */
3880
+ var DECISION_LABELS = {
3881
+ author: {
3882
+ accepted: "Plan to simplify",
3883
+ declined: "Keep as implemented"
3884
+ },
3885
+ reviewer: {
3886
+ accepted: "Raise with author",
3887
+ declined: "Leave it"
3888
+ }
3889
+ };
3890
+ /** What a card's status pill says. "Open" until decided, then the decision in the audience's words. */
3891
+ function decisionStatusLabel(audience, decision) {
3892
+ return decision === "undecided" ? "Open" : DECISION_LABELS[audience][decision];
3893
+ }
3894
+ /** The line over the list. Shared with the client so a click and a reload say the same thing. */
3895
+ function kissProgressBody(total, open) {
3896
+ return open === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">\u2713</span><span class="pr-helper-questions-progress-count">All ${total} decided</span>` : `<span class="pr-helper-questions-progress-count">${open} of ${total} undecided</span>`;
3897
+ }
3898
+ /** The drawer's heading. Shared with the client for the same reason. */
3899
+ function decidedDrawerTitle(count) {
3900
+ return `Decided (${count})`;
3901
+ }
3902
+ /**
3903
+ * Whether the reviewer's handoff can be copied, and the sentence that says why or why not.
3904
+ *
3905
+ * Stale comes first: handing off advice about commits the branch has moved past presents old advice
3906
+ * as current, however carefully it was chosen. The sentence is visible text beside the button
3907
+ * rather than a tooltip, so a disabled control always says what would enable it.
3908
+ */
3909
+ function handoffState(accepted, stale, saving = false) {
3910
+ if (saving) return {
3911
+ enabled: false,
3912
+ note: "Saving your decision. You can copy once it is saved."
3913
+ };
3914
+ if (stale) return {
3915
+ enabled: false,
3916
+ note: "Run KISS again before handing these recommendations off."
3917
+ };
3918
+ if (accepted === 0) return {
3919
+ enabled: false,
3920
+ note: "Choose Raise with author on the recommendations you want to hand off."
3921
+ };
3922
+ return {
3923
+ enabled: true,
3924
+ note: `${accepted === 1 ? "1 recommendation" : `${accepted} recommendations`} to raise with the author.`
3925
+ };
3926
+ }
3927
+ /**
3651
3928
  * The KISS summary: the assessment, what drives it, and the complexity worth keeping.
3652
3929
  *
3653
3930
  * This is the pane the rail's KISS entry opens, so it carries every state the section can be in.
@@ -3659,13 +3936,53 @@ function renderKissSummary(view) {
3659
3936
  if (!run) return renderKissEmpty(view.canRun);
3660
3937
  if (run.status === "generating") return renderRunning(run, view.job ?? null);
3661
3938
  if (run.status === "failed" || !run.result) return renderFailed(run, view);
3662
- return `${renderDocHead("KISS review", `${renderAnalysisDetails(provenanceFacts(run))}${KISS_TOGGLE}`)}
3663
- <div class="prb-kiss-fresh-region" data-kiss-fresh-region data-reviewed-sha="${escapeHtml$1(run.reviewedSha)}"${view.freshness ? ` data-freshness="${escapeHtml$1(JSON.stringify(view.freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderKissFreshness(run.reviewedSha, view.freshness ?? null, {
3939
+ return `${renderDocHead("KISS review", `${renderPerspective(run.audience)}${renderAnalysisDetails(provenanceFacts(run))}${renderRunAgain(view.canRun)}${KISS_TOGGLE}`)}
3940
+ ${renderFreshRegion(run, view)}
3941
+ ${renderResultBody(run, "header")}
3942
+ ${renderRunDialog(view.canRun, run.audience)}`;
3943
+ }
3944
+ /**
3945
+ * Whether the answer on the page still describes the branch, for the run the page presents.
3946
+ *
3947
+ * Drawn wherever a result is shown, including the earlier answer under a failed rerun: that answer
3948
+ * is the one being decided and handed off, and without this region the client has nowhere to repaint
3949
+ * a push it notices later.
3950
+ */
3951
+ function renderFreshRegion(run, view) {
3952
+ return `<div class="prb-kiss-fresh-region" data-kiss-fresh-region data-reviewed-sha="${escapeHtml$1(run.reviewedSha)}"${view.freshness ? ` data-freshness="${escapeHtml$1(JSON.stringify(view.freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderKissFreshness(run.reviewedSha, view.freshness ?? null, {
3664
3953
  dismissedSha: view.dismissedFreshnessSha,
3665
3954
  actions: view.canRun
3666
- })}</div>
3667
- ${renderResultBody(run, "header")}
3668
- ${renderRunControl(view.canRun, "Run KISS review again")}`;
3955
+ })}</div>`;
3956
+ }
3957
+ /**
3958
+ * Whose code the run was about, said as configuration rather than as part of the report.
3959
+ *
3960
+ * A compact line in the header rather than the question left standing under the result: the
3961
+ * choice was made, and what a finished report owes the reader is which choice it was.
3962
+ */
3963
+ function renderPerspective(audience) {
3964
+ const intent = INTENTS.find((candidate) => candidate.audience === audience);
3965
+ if (!intent) return "";
3966
+ return `<span class="prb-kiss-perspective" data-kiss-perspective="${audience}">Perspective: ${escapeHtml$1(intent.perspective)}</span>`;
3967
+ }
3968
+ /** Opens the run dialog. Beside Analysis details, because running again is a fact about the run too. */
3969
+ function renderRunAgain(canRun) {
3970
+ return canRun ? "<button type=\"button\" class=\"prb-kiss-run-again\" data-kiss-run-open>Run again</button>" : "";
3971
+ }
3972
+ /**
3973
+ * The run control once a result exists: the same question, asked in a dialog.
3974
+ *
3975
+ * The previous run's audience is preselected, and the choice is still shown and still has to be
3976
+ * confirmed by pressing Run. Preselecting carries yesterday's framing forward only as far as the
3977
+ * reader can see it, which is the difference from the remembered default the empty state refuses.
3978
+ */
3979
+ function renderRunDialog(canRun, audience) {
3980
+ if (!canRun) return "";
3981
+ return `<dialog class="prb-kiss-run-dialog" data-kiss-run-dialog aria-labelledby="prb-kiss-run-dialog-title">
3982
+ <h4 class="prb-kiss-run-dialog-title" id="prb-kiss-run-dialog-title">Run KISS review again</h4>
3983
+ ${renderRunControl(true, "Run KISS review", audience)}
3984
+ <button type="button" class="prb-kiss-run-cancel" data-kiss-run-cancel>Cancel</button>
3985
+ </dialog>`;
3669
3986
  }
3670
3987
  /**
3671
3988
  * A completed run's answer, from the outcome down to how it was made.
@@ -3692,12 +4009,15 @@ ${provenance === "header" ? "" : renderProvenance(run)}`;
3692
4009
  }
3693
4010
  function renderKissFreshness(reviewedSha, freshness, options = {}) {
3694
4011
  if (!freshness || freshness.state !== "changed") return "";
3695
- if (options.dismissedSha && options.dismissedSha === freshness.latestSha) return "";
4012
+ const runAgain = options.actions ? "<button type=\"button\" class=\"prb-kiss-fresh-run\" data-kiss-run-open>Run again</button>" : "";
4013
+ if (options.dismissedSha && options.dismissedSha === freshness.latestSha) return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" data-collapsed role="status">
4014
+ <p><strong>This KISS review describes commit ${escapeHtml$1(reviewedSha.slice(0, 7))}.</strong> The branch has moved on since. ${runAgain}</p>
4015
+ </div>`;
3696
4016
  const moved = freshness.rewritten ? "The branch has been rewritten since, so this report describes commits that are no longer on it." : freshness.newCommits !== null ? `There ${freshness.newCommits === 1 ? "has been 1 commit" : `have been ${freshness.newCommits} commits`} on the branch since.` : "The branch has moved on since.";
3697
4017
  return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" role="status">
3698
4018
  <p><strong>This KISS review describes commit ${escapeHtml$1(reviewedSha.slice(0, 7))}.</strong> ${escapeHtml$1(moved)}</p>
3699
- <p class="prb-kiss-fresh-note">Running KISS again analyses the branch as it is now. The report below is kept either way.</p>
3700
- ${options.actions && freshness.latestSha ? `<button type="button" class="prb-kiss-fresh-dismiss" data-kiss-freshness-dismiss="${escapeHtml$1(freshness.latestSha)}">Not now</button>` : ""}
4019
+ <p class="prb-kiss-fresh-note">Running KISS again analyses the branch as it is now. The report below and your decisions about it are kept either way.</p>
4020
+ <p class="prb-kiss-fresh-actions">${runAgain}${options.actions && freshness.latestSha ? `<button type="button" class="prb-kiss-fresh-dismiss" data-kiss-freshness-dismiss="${escapeHtml$1(freshness.latestSha)}">Not now</button>` : ""}</p>
3701
4021
  </div>`;
3702
4022
  }
3703
4023
  /**
@@ -3718,8 +4038,73 @@ function renderKissRecommendations(view) {
3718
4038
  <h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
3719
4039
  <p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
3720
4040
  </div>`;
4041
+ const decisions = decisionsFor(view, presented);
4042
+ const stale = view.freshness?.state === "changed";
4043
+ const card = (recommendation, rank) => renderRecommendation(recommendation, {
4044
+ runId: presented.id,
4045
+ audience: presented.audience,
4046
+ decision: decisions.get(recommendation.id) ?? "undecided",
4047
+ rank,
4048
+ stale,
4049
+ actions: view.canRun
4050
+ });
4051
+ const recommendations = recommendationsOf(result);
4052
+ const ranked = recommendations.map((recommendation, rank) => ({
4053
+ recommendation,
4054
+ rank
4055
+ }));
4056
+ const open = ranked.filter(({ recommendation }) => !decisions.has(recommendation.id));
4057
+ const decided = ranked.filter(({ recommendation }) => decisions.has(recommendation.id));
4058
+ const total = recommendations.length;
3721
4059
  return `${RECS_HEAD}${earlier}${renderWithheldNote(presented)}
3722
- ${result.recommendations.map((recommendation) => renderRecommendation(recommendation)).join("")}`;
4060
+ ${presented.audience === "reviewer" ? renderHandoff(recommendations, decisions, stale) : ""}
4061
+ <div class="pr-helper-questions-progress prb-kiss-progress" data-kiss-progress role="status">${kissProgressBody(total, open.length)}</div>
4062
+ <div class="prb-kiss-open-list" data-kiss-open-list>${open.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
4063
+ <details class="pr-helper-closed-findings prb-kiss-decided" data-kiss-decided${decided.length === 0 ? " hidden" : ""}${open.length === 0 ? " data-nothing-open" : ""}>
4064
+ <summary class="pr-helper-closed-findings-summary" data-kiss-decided-summary>${escapeHtml$1(decidedDrawerTitle(decided.length))}</summary>
4065
+ <div class="pr-helper-closed-findings-list" data-kiss-decided-list>${decided.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
4066
+ </details>`;
4067
+ }
4068
+ /**
4069
+ * What was decided about the run this pane presents, by recommendation id.
4070
+ *
4071
+ * Only decisions about recommendations the run still lists: a stored decision is never shown on a
4072
+ * card it was not made about, and a recommendation withheld as incomplete has no card to decide on.
4073
+ */
4074
+ function decisionsFor(view, run) {
4075
+ const listed = new Set(recommendationsOf(run.result).map((recommendation) => recommendation.id));
4076
+ return new Map((view.decisions ?? []).filter((entry) => entry.runId === run.id && listed.has(entry.recommendationId)).map((entry) => [entry.recommendationId, entry.decision]));
4077
+ }
4078
+ /** The decisions made about the run the panes present, for the rail. */
4079
+ function kissDecisionCounts(view) {
4080
+ const run = presentedKissRun(view);
4081
+ if (!run?.result) return null;
4082
+ const total = recommendationsOf(run.result).length;
4083
+ return {
4084
+ total,
4085
+ open: total - decisionsFor(view, run).size
4086
+ };
4087
+ }
4088
+ /**
4089
+ * The reviewer's handoff: what they chose to raise, as Markdown to paste.
4090
+ *
4091
+ * An export of the decisions on this page, not a report of its own. The Markdown comes from one
4092
+ * helper, over the model's own fields embedded beside the button, so what is copied is never read
4093
+ * back out of the rendered cards. The button's source is filled here for the decisions the page
4094
+ * loaded with, and refilled by the client after every decision.
4095
+ *
4096
+ * Reviewer runs only: an author was never going to hand these to anybody.
4097
+ */
4098
+ function renderHandoff(recommendations, decisions, stale) {
4099
+ const accepted = recommendations.filter((recommendation) => decisions.get(recommendation.id) === "accepted");
4100
+ const state = handoffState(accepted.length, stale);
4101
+ const sources = recommendations.map(handoffSource);
4102
+ const copy = renderLabelledCopySource(state.enabled ? handoffMarkdown(accepted.map(handoffSource)) : "", "Copy recommendations", `data-kiss-handoff-copy aria-describedby="prb-kiss-handoff-note"${state.enabled ? "" : " disabled"}`);
4103
+ return `<div class="prb-kiss-handoff" data-kiss-handoff${stale ? " data-stale" : ""}>
4104
+ ${copy}
4105
+ <p class="prb-kiss-handoff-note" id="prb-kiss-handoff-note" data-kiss-handoff-note>${escapeHtml$1(state.note)}</p>
4106
+ <script type="application/json" data-kiss-handoff-source>${embedHandoffSources(sources)}<\/script>
4107
+ </div>`;
3723
4108
  }
3724
4109
  /**
3725
4110
  * A run whose every recommendation was withheld: incomplete, and said to be.
@@ -3780,22 +4165,23 @@ ${renderRunControl(canRun, "Run KISS review")}`;
3780
4165
  * the reader that the page argues with them; one that is plainly waiting on the choice above it
3781
4166
  * teaches them what the choice is for.
3782
4167
  *
3783
- * Nothing is preselected, on a first run or on a rerun. The audience is confirmed every time,
3784
- * because the same person is an author on Monday and a reviewer on Tuesday, and a remembered
3785
- * answer would carry Monday's framing into Tuesday's report without saying so.
4168
+ * Nothing is preselected on a first run: there is no earlier answer, and a default would be the
4169
+ * page guessing whose code this is. A rerun preselects the audience the run it replaces was asked
4170
+ * for, inside a dialog that shows both choices and waits for Run, so the framing carried forward is
4171
+ * one the reader can see and change rather than one remembered for them.
3786
4172
  */
3787
- function renderRunControl(canRun, label) {
4173
+ function renderRunControl(canRun, label, preselected) {
3788
4174
  if (!canRun) return "";
3789
4175
  return `<div class="prb-kiss-run" data-kiss-run-control>
3790
4176
  <fieldset class="prb-kiss-intents">
3791
4177
  <legend>Whose code is this?</legend>
3792
4178
  ${INTENTS.map((intent) => `<label class="prb-kiss-intent">
3793
- <input type="radio" name="kiss-audience" value="${intent.audience}" data-kiss-audience>
4179
+ <input type="radio" name="kiss-audience${preselected ? "-again" : ""}" value="${intent.audience}" data-kiss-audience${intent.audience === preselected ? " checked" : ""}>
3794
4180
  <span class="prb-kiss-intent-label">${escapeHtml$1(intent.label)}</span>
3795
4181
  <span class="prb-kiss-intent-note">${escapeHtml$1(intent.consequence)}</span>
3796
4182
  </label>`).join("")}
3797
4183
  </fieldset>
3798
- <button type="button" class="prb-kiss-start" data-kiss-run disabled>${escapeHtml$1(label)}</button>
4184
+ <button type="button" class="prb-kiss-start" data-kiss-run${preselected ? "" : " disabled"}>${escapeHtml$1(label)}</button>
3799
4185
  </div>`;
3800
4186
  }
3801
4187
  /**
@@ -3833,15 +4219,15 @@ function renderRunning(run, job) {
3833
4219
  */
3834
4220
  function renderFailed(run, view) {
3835
4221
  const standing = view.previous ?? null;
3836
- return `${renderDocHead("KISS review", KISS_TOGGLE)}
4222
+ return `${renderDocHead("KISS review", `${standing ? renderRunAgain(view.canRun) : ""}${KISS_TOGGLE}`)}
3837
4223
  <div class="prb-kiss-outcome" data-review-outcome="failed">
3838
4224
  <h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
3839
4225
  <p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
3840
4226
  ${standing ? "" : "<p class=\"prb-kiss-outcome-note\">Nothing was recorded from this run.</p>"}
3841
4227
  ${renderRejectedAnswer(run)}
3842
4228
  </div>
3843
- ${standing ? renderEarlier(standing) : ""}
3844
- ${renderRunControl(view.canRun, "Run KISS review again")}`;
4229
+ ${standing ? `${renderFreshRegion(standing, view)}${renderEarlier(standing)}` : ""}
4230
+ ${standing ? renderRunDialog(view.canRun, run.audience) : renderRunControl(view.canRun, "Run KISS review again", run.audience)}`;
3845
4231
  }
3846
4232
  /**
3847
4233
  * What the agent actually replied, folded away.
@@ -3935,14 +4321,14 @@ function renderProvenance(run) {
3935
4321
  */
3936
4322
  function renderJustified(items) {
3937
4323
  if (items.length === 0) return "";
3938
- return `<section class="prb-kiss-justified">
3939
- <h4 class="prb-kiss-heading">Complexity worth keeping</h4>
3940
- ${items.map((item) => `<div class="prb-kiss-kept">
3941
- <p class="prb-kiss-kept-title">${escapeHtml$1(item.title)}</p>
4324
+ return `<details class="prb-kiss-justified" data-kiss-justified>
4325
+ <summary class="prb-kiss-justified-summary">Complexity worth keeping (${items.length})</summary>
4326
+ ${items.map((item) => `<details class="prb-kiss-kept" data-kiss-kept>
4327
+ <summary class="prb-kiss-kept-summary"><span class="prb-kiss-kept-title">${escapeHtml$1(item.title)}</span>${item.reason ? `<span class="prb-kiss-kept-reason" data-kiss-kept-reason>${escapeHtml$1(item.reason)}</span>` : ""}</summary>
3942
4328
  <div class="pr-helper-markdown">${renderMarkdown(item.rationale)}</div>
3943
4329
  ${renderLocations(item.locations)}
3944
- </div>`).join("")}
3945
- </section>`;
4330
+ </details>`).join("")}
4331
+ </details>`;
3946
4332
  }
3947
4333
  /**
3948
4334
  * What validation had to withdraw, disclosed rather than swallowed.
@@ -3964,26 +4350,20 @@ function renderAudit(run) {
3964
4350
  ${notes.map((note) => `<p>${escapeHtml$1(note)}</p>`).join("")}
3965
4351
  </div>`;
3966
4352
  }
3967
- /**
3968
- * One recommendation, as a decision aid rather than an analysis record.
3969
- *
3970
- * The card carries what a reviewer needs to understand and weigh the recommendation: the title,
3971
- * the case in a sentence or two, the simpler approach, what it gains, the trade-off that could
3972
- * change the decision, and where to start. Everything that exists to audit or challenge it sits in
3973
- * one disclosure beneath, closed until asked for.
3974
- *
3975
- * Reads both schemas. A record written under schema 1 has no gist, and its `complexity` stands in
3976
- * as the lede, because it is the only field of that shape that names what the card is about; it is
3977
- * then not repeated in the disclosure. Stored runs are history and are not rewritten to fit.
3978
- */
3979
- function renderRecommendation(recommendation) {
4353
+ function renderRecommendation(recommendation, context) {
3980
4354
  const worthiness = recommendation.commentWorthiness;
3981
4355
  const lede = recommendation.gist || recommendation.complexity || "";
3982
4356
  const [start, ...rest] = orderedLocations(recommendation.locations);
3983
- return `<article class="prb-kiss-rec" data-payoff="${recommendation.payoff}"${worthiness ? ` data-worthiness="${worthiness}"` : ""}>
4357
+ return `<article class="prb-kiss-rec" data-finding-card="${escapeHtml$1(recommendation.id)}" data-kiss-rec="${escapeHtml$1(recommendation.id)}" data-kiss-run-id="${escapeHtml$1(context.runId)}" data-audience="${context.audience}" data-rank="${context.rank}" data-decision="${context.decision}" data-payoff="${recommendation.payoff}"${worthiness ? ` data-worthiness="${worthiness}"` : ""}>
3984
4358
  <header class="prb-kiss-rec-head">
3985
- <h4 class="prb-kiss-rec-title">${escapeHtml$1(recommendation.title)}</h4>
4359
+ <div class="prb-kiss-rec-title-row">
4360
+ <h4 class="prb-kiss-rec-title" tabindex="-1">${escapeHtml$1(recommendation.title)}</h4>
4361
+ ${renderFindingCopySource(recommendationMarkdown(handoffSource(recommendation)), "recommendation")}
4362
+ ${context.actions ? renderAskBuddyMenu("recommendation") : ""}
4363
+ </div>
3986
4364
  <div class="prb-kiss-rec-pills">
4365
+ <span class="pr-helper-finding-status prb-kiss-decision-status" data-kiss-status="${context.decision}">${escapeHtml$1(decisionStatusLabel(context.audience, context.decision))}</span>
4366
+ <span class="prb-kiss-stale" data-kiss-stale${context.stale ? "" : " hidden"}>Stale</span>
3987
4367
  <span class="prb-kiss-payoff" data-payoff="${recommendation.payoff}">${escapeHtml$1(PAYOFF[recommendation.payoff])}</span>
3988
4368
  ${worthiness ? `<span class="prb-kiss-worthiness" data-worthiness="${worthiness}">${escapeHtml$1(WORTHINESS[worthiness])}</span>` : ""}
3989
4369
  </div>
@@ -4000,11 +4380,33 @@ function renderRecommendation(recommendation) {
4000
4380
  ${recommendation.draftComment ? `<details class="prb-kiss-draft">
4001
4381
  <summary class="prb-kiss-draft-title">Draft comment</summary>
4002
4382
  <blockquote class="pr-helper-markdown">${renderMarkdown(recommendation.draftComment)}</blockquote>
4003
- ${renderMessageCopySource(recommendation.draftComment)}
4004
4383
  </details>` : ""}
4384
+ ${renderCardActions(recommendation, context, Boolean(start))}
4005
4385
  </article>`;
4006
4386
  }
4007
4387
  /**
4388
+ * The card's actions: open the file, copy the draft, and the decision.
4389
+ *
4390
+ * The decision is two toggles rather than a menu, because both answers are equally ordinary and
4391
+ * the reader should see both before choosing. Pressing the pressed one again takes the decision
4392
+ * back, which is how a decision is reversed; nothing is deleted either way.
4393
+ *
4394
+ * Open file presses the card's own Start here row, so there is exactly one way a KISS location
4395
+ * opens. The draft comment copies the Markdown the model wrote, which is what a forge needs.
4396
+ */
4397
+ function renderCardActions(recommendation, context, hasStart) {
4398
+ const labels = DECISION_LABELS[context.audience];
4399
+ const toggle = (decision) => `<button type="button" class="prb-kiss-decide" data-kiss-decide="${decision}" aria-pressed="${context.decision === decision}">${escapeHtml$1(labels[decision])}</button>`;
4400
+ const openFile = hasStart ? "<button type=\"button\" class=\"prb-kiss-open-file\" data-kiss-open-primary>Open file</button>" : "";
4401
+ const draft = recommendation.draftComment ? renderLabelledCopySource(recommendation.draftComment, "Copy draft comment") : "";
4402
+ const decide = context.actions ? `<div class="prb-kiss-decide-group" role="group" aria-label="${escapeHtml$1(`Decision on ${recommendation.title}`)}">${toggle("accepted")}${toggle("declined")}</div>` : "";
4403
+ if (!openFile && !draft && !decide) return "";
4404
+ return `<div class="prb-kiss-rec-actions" data-kiss-actions>
4405
+ <div class="prb-kiss-rec-actions-aside">${openFile}${draft}</div>
4406
+ ${decide}
4407
+ </div>`;
4408
+ }
4409
+ /**
4008
4410
  * What acting on it gains, on one line: the estimate first, then the gains.
4009
4411
  *
4010
4412
  * The estimate says it is one in words beside it, so a phrase like "~35–50 lines" cannot be taken
@@ -4927,7 +5329,8 @@ function kissViewOf(view) {
4927
5329
  job: view.kissJob ?? null,
4928
5330
  freshness: view.kissFreshness ?? null,
4929
5331
  dismissedFreshnessSha: view.dismissedKissFreshnessSha,
4930
- previous: view.previousKissRun ?? null
5332
+ previous: view.previousKissRun ?? null,
5333
+ decisions: view.kissDecisions ?? []
4931
5334
  };
4932
5335
  }
4933
5336
  function renderKissViews(view) {
@@ -5434,16 +5837,16 @@ function renderRunningRail(view) {
5434
5837
  * item that opens a page saying "nothing has run yet" is a promise the rail cannot keep.
5435
5838
  */
5436
5839
  function renderKissRail(view) {
5437
- const result = presentedKissRun(kissViewOf(view))?.result;
5840
+ const counts = kissDecisionCounts(kissViewOf(view));
5438
5841
  return railGroup({
5439
5842
  id: "prb-rail-kiss",
5440
5843
  title: "KISS",
5441
5844
  icon: "kiss",
5442
5845
  view: "kiss",
5443
5846
  current: view.session.result === null && !showsProgress(view),
5444
- badge: result ? recommendationsBadge(result.recommendations.length) : null,
5847
+ badge: counts ? recommendationsBadge(counts) : null,
5445
5848
  collapses: "the KISS review",
5446
- body: result ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(result.recommendations.length)) : ""
5849
+ body: counts ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(counts)) : ""
5447
5850
  });
5448
5851
  }
5449
5852
  /** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
@@ -5473,17 +5876,20 @@ function railGroup(group) {
5473
5876
  </div>`;
5474
5877
  }
5475
5878
  /**
5476
- * How many recommendations a KISS run made, in KISS's own noun.
5879
+ * How many recommendations are still waiting on a decision, in KISS's own noun.
5477
5880
  *
5478
- * A zero keeps its `0` rather than becoming a tick, for the reason `remainingBadge` gives about
5479
- * findings and for one more: a recommendation is not work assigned to anybody, so there is nothing
5480
- * here that can be finished, and a tick would claim somebody had dealt with all of them.
5881
+ * The same shape as the Issues and Questions badges: the open count, a tick once every one has been
5882
+ * decided, and the history in the label. "Decided" rather than "resolved", because accepting or
5883
+ * declining a suggestion fixes nothing in the code. A zero total keeps its `0`: a run that
5884
+ * recommended nothing has had nothing decided.
5481
5885
  */
5482
- function recommendationsBadge(count) {
5483
- return {
5484
- text: String(count),
5485
- label: count === 1 ? "1 recommendation" : `${count} recommendations`
5486
- };
5886
+ function recommendationsBadge(counts) {
5887
+ return remainingBadge({
5888
+ total: counts.total,
5889
+ open: counts.open,
5890
+ done: counts.total - counts.open,
5891
+ reviewerClosed: counts.total - counts.open
5892
+ }, "recommendation", "decided");
5487
5893
  }
5488
5894
  /** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
5489
5895
  /** The map and the implementation diagrams together: everything behind the one rail item. */
@@ -5604,6 +6010,8 @@ function renderDisclosure(view) {
5604
6010
  const findings = audit.relocatedFindings > 0 ? ` ${audit.relocatedFindings === 1 ? "One finding" : `${audit.relocatedFindings} findings`} pointed there; the ${audit.relocatedFindings === 1 ? "finding itself was" : "findings themselves were"} kept.` : "";
5605
6011
  notes.push(removal + findings);
5606
6012
  } else if (audit.relocatedFindings > 0) notes.push(`${audit.relocatedFindings} finding${audit.relocatedFindings === 1 ? "" : "s"} pointed at a file that is not in this change. The finding is kept; its location is not.`);
6013
+ const motivation = motivationNote(audit.motivationCorrection);
6014
+ if (motivation) notes.push(motivation);
5607
6015
  if (notes.length === 0) return "";
5608
6016
  return `<aside class="prb-disclosure">
5609
6017
  <strong>What the harness corrected</strong>
@@ -5611,6 +6019,21 @@ function renderDisclosure(view) {
5611
6019
  </aside>`;
5612
6020
  }
5613
6021
  /**
6022
+ * A refused motivation, described by what kind of claim it was and never by its wording: that
6023
+ * wording is exactly what the check decided the reader should not be shown.
6024
+ *
6025
+ * An unclear motive with no question is not mentioned. Withholding it costs the reader nothing,
6026
+ * since it said nothing they could act on.
6027
+ */
6028
+ function motivationNote(correction) {
6029
+ if (!correction || correction.reason === "no-question") return null;
6030
+ return `The review gave ${{
6031
+ "missing-quote": "that the author had stated why this change was made, without quoting where",
6032
+ "unverified-quote": "that the author had stated why this change was made, quoting words that are not in the description",
6033
+ "no-evidence": "a likely reason for this change, without evidence for it"
6034
+ }[correction.reason]}, ${correction.outcome === "downgraded" ? "so the reason is shown as unclear, with its question to the author under Intent" : "so no reason is shown"}.`;
6035
+ }
6036
+ /**
5614
6037
  * Every changed file, split by whether the guide sends the reviewer there.
5615
6038
  *
5616
6039
  * The split used to be analysed / not analysed, which was the truth under a file ceiling and is a
@@ -7070,7 +7493,7 @@ function renderAuthorFilter(authors) {
7070
7493
  * the reviewer their place in Active. Hidden until the client has counted, so a browser with no
7071
7494
  * JavaScript gets every card and no control that does nothing.
7072
7495
  */
7073
- function renderLoadMore(section) {
7496
+ function renderLoadMore$1(section) {
7074
7497
  return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
7075
7498
  }
7076
7499
  /**
@@ -7089,7 +7512,7 @@ function renderSection(section, groups, renderCards) {
7089
7512
  <div class="prb-cards" data-cards="${section}">
7090
7513
  ${renderCards(groups)}
7091
7514
  </div>
7092
- ${renderLoadMore(section)}
7515
+ ${renderLoadMore$1(section)}
7093
7516
  </details>`;
7094
7517
  }
7095
7518
  /**
@@ -8177,7 +8600,8 @@ ${renderWorkspaceRail({
8177
8600
  <main class="prb-index prb-notifications-page">
8178
8601
  ${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<ol class="prb-notifications" data-notifications>
8179
8602
  ${view.notifications.map((notification) => renderEntry(notification, view.gone?.has(notification.id) === true)).join("")}
8180
- </ol>`}
8603
+ </ol>
8604
+ ${renderLoadMore()}`}
8181
8605
  <p class="prb-index-note">Kept for thirty days, on this machine only.</p>
8182
8606
  </main>
8183
8607
  </div>
@@ -8199,6 +8623,14 @@ function renderReadAll(unread) {
8199
8623
  </div>`;
8200
8624
  }
8201
8625
  /**
8626
+ * Load more, which the browser shows once the list holds more than it is showing, as each section
8627
+ * of the Reviews page does. Hidden until the client has counted, so a browser with no JavaScript
8628
+ * gets every entry and no control that does nothing.
8629
+ */
8630
+ function renderLoadMore() {
8631
+ return `<button type="button" class="prb-index-more" data-notifications-more hidden>Load more</button>`;
8632
+ }
8633
+ /**
8202
8634
  * One event: what happened, then what to, then when. The whole row opens the review and so marks it
8203
8635
  * read, which is why a row has no Mark read of its own. An event, not a card: nothing about the
8204
8636
  * review's findings belongs here.
@@ -9540,6 +9972,7 @@ async function handle(req, res, options) {
9540
9972
  }
9541
9973
  if (action === "/job") return send(res, 200, jobReport(workspace));
9542
9974
  if (action === "/retry") return handleRetry(res, workspace);
9975
+ if (action === "/reanalyse") return handleReanalyse(req, res, workspace);
9543
9976
  if (action === "/freshness") return handleFreshness(res, workspace, url.searchParams.get("quiet") === "1");
9544
9977
  if (action === "/fetch") return handleFetchOwn(req, res, workspace);
9545
9978
  if (action === "/refresh") return handleRefreshPr(res, workspace);
@@ -10434,7 +10867,8 @@ async function sendWorkspacePage(res, workspace) {
10434
10867
  kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
10435
10868
  kissFreshness,
10436
10869
  dismissedKissFreshnessSha: workspace.dismissedKissFreshnessSha,
10437
- previousKissRun: previousKissResult(workspace)
10870
+ previousKissRun: previousKissResult(workspace),
10871
+ kissDecisions: workspace.kissDecisions ?? []
10438
10872
  });
10439
10873
  recordWorkspaceOpened(workspace);
10440
10874
  if (freshness) record({
@@ -10578,7 +11012,7 @@ async function handleFreshness(res, workspace, quiet) {
10578
11012
  * an answer would be wrong.
10579
11013
  */
10580
11014
  async function kissFreshnessOrNull(workspace) {
10581
- const run = latestKissRun(workspace);
11015
+ const run = presentedKissRunOf(workspace);
10582
11016
  if (!run) return null;
10583
11017
  try {
10584
11018
  return await checkCodeFreshness(workspace, run.reviewedSha);
@@ -10697,6 +11131,27 @@ async function handleRetry(res, workspace) {
10697
11131
  send(res, 200, { ok: true });
10698
11132
  }
10699
11133
  /**
11134
+ * Analyse this review again, at the commit it describes, with the agent that made it.
11135
+ *
11136
+ * The page's counterpart to `prreviewbuddy review --reanalyse`, and the same function behind it.
11137
+ * It answers once the new job exists, so the page can go and watch it; a refusal before that point
11138
+ * (a job still running, a review being deleted) comes back as one, and nothing has changed.
11139
+ */
11140
+ async function handleReanalyse(req, res, workspace) {
11141
+ if (req.method !== "POST") return send(res, 405, { error: "Analysing a review again needs a POST." });
11142
+ const current = workspace.jobId ? loadJob(workspace.jobId) : null;
11143
+ if (current && (!isTerminal(current) || isClaimed(current.id))) return send(res, 409, { error: "This review is still being made. Wait for it to finish before analysing it again." });
11144
+ await new Promise((resolve) => {
11145
+ track(reanalyseReview(workspace.id, void 0, () => {
11146
+ send(res, 200, { ok: true });
11147
+ resolve();
11148
+ })).catch((error) => {
11149
+ if (!res.headersSent) send(res, 409, { error: error instanceof Error ? error.message : String(error) });
11150
+ resolve();
11151
+ });
11152
+ });
11153
+ }
11154
+ /**
10700
11155
  * The two audiences, as a closed list this file checks against rather than a string it forwards.
10701
11156
  *
10702
11157
  * The page sends this, so it is input. A run records who its report was written for, and a value
@@ -11019,6 +11474,7 @@ async function handlePrefs(req, res) {
11019
11474
  }
11020
11475
  async function handleState(req, res, workspace) {
11021
11476
  const body = await readJson(req);
11477
+ if (body !== null && "kissDecision" in body) return handleKissDecision(res, workspace, body.kissDecision);
11022
11478
  const saved = STATE_FIELDS.some((field) => body !== null && field in body) ? mutateWorkspace(workspace.id, (current) => applyState(current, body)) : workspace;
11023
11479
  if (!saved) return send(res, 404, { error: "No such review" });
11024
11480
  send(res, 200, {
@@ -11028,6 +11484,51 @@ async function handleState(req, res, workspace) {
11028
11484
  queued: readQueue().includes(saved.id)
11029
11485
  });
11030
11486
  }
11487
+ var KISS_DECISION_VALUES = [
11488
+ "accepted",
11489
+ "declined",
11490
+ "undecided"
11491
+ ];
11492
+ function kissDecisionInput(value) {
11493
+ const input = value;
11494
+ if (typeof input?.runId !== "string" || input.runId === "") return null;
11495
+ if (typeof input.recommendationId !== "string" || input.recommendationId === "") return null;
11496
+ if (!KISS_DECISION_VALUES.includes(input.decision)) return null;
11497
+ return {
11498
+ runId: input.runId,
11499
+ recommendationId: input.recommendationId,
11500
+ decision: input.decision
11501
+ };
11502
+ }
11503
+ var KISS_DECISION_REFUSED = {
11504
+ "unknown-run": "This review has no completed KISS run with that id.",
11505
+ "unknown-recommendation": "That KISS run has no recommendation with that id."
11506
+ };
11507
+ /**
11508
+ * One decision, written onto the review as it is now and answered with what was stored.
11509
+ *
11510
+ * Checked against a fresh read before writing, so a refused decision leaves the file alone, and
11511
+ * checked again inside the write, where the answer is final. The reply carries the stored decision
11512
+ * so the page confirms from the server rather than from what it asked for.
11513
+ */
11514
+ function handleKissDecision(res, workspace, value) {
11515
+ const input = kissDecisionInput(value);
11516
+ if (!input) return send(res, 400, { error: "A KISS decision names a run, a recommendation, and accepted, declined or undecided." });
11517
+ const before = loadWorkspace(workspace.id);
11518
+ if (!before) return send(res, 404, { error: "No such review" });
11519
+ const early = kissDecisionRefusal(before, input);
11520
+ if (early) return send(res, 400, { error: KISS_DECISION_REFUSED[early] });
11521
+ let refused = null;
11522
+ const saved = mutateWorkspace(workspace.id, (current) => {
11523
+ refused = decideKissRecommendation(current, input);
11524
+ });
11525
+ if (!saved) return send(res, 404, { error: "No such review" });
11526
+ if (refused) return send(res, 400, { error: KISS_DECISION_REFUSED[refused] });
11527
+ send(res, 200, {
11528
+ ok: true,
11529
+ decision: kissDecisionsOf(saved, input.runId).find((entry) => entry.recommendationId === input.recommendationId)?.decision ?? "undecided"
11530
+ });
11531
+ }
11031
11532
  /** What `/state` can change. A body naming none of these writes nothing. */
11032
11533
  var STATE_FIELDS = [
11033
11534
  "answeredQuestionIds",