prreviewbuddy 0.52.2 → 0.53.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 compactQueue, $t as latestKissRun, A as createPrState, An as isIssueOutstanding, At as followedBranch, B as toneOf, Bt as phasesFor, C as readEvents, Cn as sectionOf, Ct as claimRequestUrl, D as announceJob, Dn as queueStamp, Dt as fillFileUrlTemplate, E as telemetryUploadConsent, En as queueRemove, Et as remoteHost, F as markAllRead, Fn as processDiscussion, Ft as isTerminal, G as extractJsonText, Gn as resolveModel, Gt as isClaimed, H as withUsageRecorded, Hn as isCommitReview, Ht as purposeOf, I as markRead, In as recommendationsOf, It as loadJob, J as readMarker, Jn as writeAgentPreference, Jt as groupByLineage, K as ReviewBeingDeletedError, Kn as clearAgentPreference, Kt as decideKissRecommendation, L as pruneNotifications, Ln as describeAuthorship, Lt as saveJob, M as eventName, Mn as issuesOutstanding, Mt as allJobs, N as headline, Nn as questionsOutstanding, Nt as endedAt, O as branchOf, On as readQueue, Ot as checkCodeFreshness, P as listNotifications, Pn as reviewerDispositions, Pt as fail, Q as activateQueued, Qt as kissDecisionsOf, R as readNotification, Rn as WITHHELD_REASON, Sn as parseLifecycle, St as writePortPreference, T as recordTelemetryUploadConsent, Tn as queueAddMany, Tt as forgeFor, U as EXPLAIN_SIMPLY_PROMPT, Un as git, V as unreadCount$1, Vt as progressSteps, W as asString, Xn as STORE_ROOT, Xt as indexReviewGroups, Y as removeWorktree, Yt as indexKeptIds, Z as relativeTime$1, Zn as AGENT_IDS, Zt as kissDecisionRefusal, _ as agentEnvOf, _n as isLifecycle, _t as MAX_WORKSPACE_PORT, a as wasBlocked, bn as lifecyclePill, bt as resolveWorkspacePort, c as RefreshUnavailableError, cn as recentWorkspaces, ct as serverEntryExists, d as refreshPrContext, er as agentFor, f as isUnchanged, fn as touchWorkspace, ft as writeServerRecord, gn as activityLabel, gt as DEFAULT_WORKSPACE_PORT, h as liveUpdateFor, hn as LIFECYCLES, ht as feedbackUrl, i as liveJobsFor, in as mutateWorkspace, it as clearServerRecord, j as createPrUpdate, jn as isQuestionOutstanding, jt as reviewedShaOf, k as subjectOf, kn as doneVerb, kt as checkReview, l as mergeUpdateInto, ln as reviewedCommit, lt as spawnServer, m as UpdateAlreadyRunningError, mn as workspaceStamps, n as deleteReview, nn as loadWorkspace, nr as AgentCancelledError, nt as readIndexToken, o as discardJob, on as presentedKissRunOf, ot as readServerRecord, p as askCheckout, pn as workspaceRevision, pt as BUILD_VERSION, q as MANAGED_ROOT, qn as readAgentPreference, qt as followedRefName, r as lineageIds, rr as AgentUnavailableError, s as FETCH_TIMEOUT_MS, sn as previousKissResult, t as deleteLineage, tn as lineagePosition, tt as settleQueue, u as updateReview, vn as lifecycleAction, vt as MIN_WORKSPACE_PORT, w as record, wn as queueAdd, x as runJob, xn as offersLifecycle, xt as statedWorkspacePort, y as startKissJob, yn as lifecycleChip, yt as parseWorkspacePort, z as removeNotification, zn as kissOutcome, zt as isLiveJob } from "./delete_review-DMJXD3bf.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";
@@ -839,13 +839,28 @@ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
839
839
  * a chat reply's "Copy message".
840
840
  */
841
841
  function renderFindingCopySource(source, kind) {
842
- const name = kind === "issue" ? "Copy finding" : "Copy question";
842
+ const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
843
843
  return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
844
844
  label: "",
845
845
  aria: name,
846
846
  tip: name
847
847
  });
848
848
  }
849
+ /**
850
+ * The same control with its word showing, for a footer of worded actions.
851
+ *
852
+ * A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
853
+ * would read as decoration. `extra` is for attributes the caller's own client needs, such as the
854
+ * handle Copy recommendations is found by; the source may be updated after render, since the
855
+ * listener reads it at the moment of the click.
856
+ */
857
+ function renderLabelledCopySource(source, label, extra = "") {
858
+ return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
859
+ label,
860
+ aria: label,
861
+ tip: label
862
+ });
863
+ }
849
864
  /** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
850
865
  var IDLE_LABEL_ATTR = "data-copy-idle-label";
851
866
  var IDLE_ARIA_ATTR = "data-copy-idle-aria";
@@ -3565,6 +3580,58 @@ function verdictLabel(verdict) {
3565
3580
  return VERDICT_LABELS[verdict] ?? verdict;
3566
3581
  }
3567
3582
  //#endregion
3583
+ //#region ../../packages/review-harness/src/domain/kiss/handoff.ts
3584
+ function handoffSource(recommendation) {
3585
+ const { id, title, gist, complexity, whyNotJustified, simplerDirection, draftComment, locations } = recommendation;
3586
+ return {
3587
+ id,
3588
+ title,
3589
+ ...gist ? { gist } : {},
3590
+ ...complexity ? { complexity } : {},
3591
+ whyNotJustified,
3592
+ simplerDirection,
3593
+ ...draftComment ? { draftComment } : {},
3594
+ locations
3595
+ };
3596
+ }
3597
+ /**
3598
+ * One recommendation: the title, why it matters, the simpler approach, where to start, and the
3599
+ * draft comment when the run wrote one.
3600
+ *
3601
+ * "Why it matters" is the gist, which carries the case in a sentence or two. A schema 1 record has
3602
+ * none, and its `whyNotJustified` is the field of that shape that says why the complexity is not
3603
+ * needed, so it stands in rather than leaving the line out.
3604
+ */
3605
+ function recommendationMarkdown(recommendation) {
3606
+ const why = recommendation.gist || recommendation.whyNotJustified;
3607
+ const primary = recommendation.locations.find((location) => location.primary) ?? recommendation.locations[0];
3608
+ return [
3609
+ `### ${recommendation.title.trim()}`,
3610
+ why ? `**Why it matters:** ${why.trim()}` : "",
3611
+ `**Simpler approach:** ${recommendation.simplerDirection.trim()}`,
3612
+ primary ? `**Start here:** \`${primary.line ? `${primary.path}:${primary.line}` : primary.path}\`` : "",
3613
+ recommendation.draftComment?.trim() ? `**Draft comment:**\n\n${quote(recommendation.draftComment.trim())}` : ""
3614
+ ].filter((part) => part !== "").join("\n\n");
3615
+ }
3616
+ /** The reviewer's handoff: the recommendations they chose to raise, in the order given. */
3617
+ function handoffMarkdown(recommendations) {
3618
+ if (recommendations.length === 0) return "";
3619
+ return [`## Simplification suggestions (${recommendations.length})`, ...recommendations.map(recommendationMarkdown)].join("\n\n");
3620
+ }
3621
+ function quote(text) {
3622
+ return text.split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
3623
+ }
3624
+ /**
3625
+ * The sources as JSON that is safe inside a `<script type="application/json">` element.
3626
+ *
3627
+ * JSON unicode escapes rather than HTML entities: `JSON.parse` restores the original characters,
3628
+ * while `<\/script>` in a title or a draft can no longer end the element early. `&` is escaped too
3629
+ * so no sequence in the text can be read as markup by anything that looks at the raw element.
3630
+ */
3631
+ function embedHandoffSources(sources) {
3632
+ return JSON.stringify(sources).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
3633
+ }
3634
+ //#endregion
3568
3635
  //#region ../../packages/review-harness/src/workspace/kiss_view.ts
3569
3636
  /**
3570
3637
  * The KISS review, on the page.
@@ -3628,11 +3695,13 @@ var INTENTS = [{
3628
3695
  audience: "author",
3629
3696
  label: "Review my code",
3630
3697
  describes: "Reviewing my code",
3698
+ perspective: "My code",
3631
3699
  consequence: "Get direct suggestions to simplify your implementation."
3632
3700
  }, {
3633
3701
  audience: "reviewer",
3634
3702
  label: "Review someone else's code",
3635
3703
  describes: "Reviewing someone else's code",
3704
+ perspective: "Someone else's code",
3636
3705
  consequence: "Decide which simplifications are worth raising with the author."
3637
3706
  }];
3638
3707
  /**
@@ -3648,6 +3717,61 @@ function audienceSentence(audience) {
3648
3717
  return INTENTS.find((intent) => intent.audience === audience)?.describes ?? "";
3649
3718
  }
3650
3719
  /**
3720
+ * The two decisions, in each audience's words.
3721
+ *
3722
+ * One stored value, two vocabularies: an author accepts by planning the change and a reviewer by
3723
+ * raising it, and neither declines by dismissing, because declining is not a verdict that the
3724
+ * recommendation was wrong. Total over both unions, so a new audience or decision will not build
3725
+ * without its words. Exported for the client, which relabels a card after a click.
3726
+ */
3727
+ var DECISION_LABELS = {
3728
+ author: {
3729
+ accepted: "Plan to simplify",
3730
+ declined: "Keep as implemented"
3731
+ },
3732
+ reviewer: {
3733
+ accepted: "Raise with author",
3734
+ declined: "Leave it"
3735
+ }
3736
+ };
3737
+ /** What a card's status pill says. "Open" until decided, then the decision in the audience's words. */
3738
+ function decisionStatusLabel(audience, decision) {
3739
+ return decision === "undecided" ? "Open" : DECISION_LABELS[audience][decision];
3740
+ }
3741
+ /** The line over the list. Shared with the client so a click and a reload say the same thing. */
3742
+ function kissProgressBody(total, open) {
3743
+ 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>`;
3744
+ }
3745
+ /** The drawer's heading. Shared with the client for the same reason. */
3746
+ function decidedDrawerTitle(count) {
3747
+ return `Decided (${count})`;
3748
+ }
3749
+ /**
3750
+ * Whether the reviewer's handoff can be copied, and the sentence that says why or why not.
3751
+ *
3752
+ * Stale comes first: handing off advice about commits the branch has moved past presents old advice
3753
+ * as current, however carefully it was chosen. The sentence is visible text beside the button
3754
+ * rather than a tooltip, so a disabled control always says what would enable it.
3755
+ */
3756
+ function handoffState(accepted, stale, saving = false) {
3757
+ if (saving) return {
3758
+ enabled: false,
3759
+ note: "Saving your decision. You can copy once it is saved."
3760
+ };
3761
+ if (stale) return {
3762
+ enabled: false,
3763
+ note: "Run KISS again before handing these recommendations off."
3764
+ };
3765
+ if (accepted === 0) return {
3766
+ enabled: false,
3767
+ note: "Choose Raise with author on the recommendations you want to hand off."
3768
+ };
3769
+ return {
3770
+ enabled: true,
3771
+ note: `${accepted === 1 ? "1 recommendation" : `${accepted} recommendations`} to raise with the author.`
3772
+ };
3773
+ }
3774
+ /**
3651
3775
  * The KISS summary: the assessment, what drives it, and the complexity worth keeping.
3652
3776
  *
3653
3777
  * This is the pane the rail's KISS entry opens, so it carries every state the section can be in.
@@ -3659,13 +3783,53 @@ function renderKissSummary(view) {
3659
3783
  if (!run) return renderKissEmpty(view.canRun);
3660
3784
  if (run.status === "generating") return renderRunning(run, view.job ?? null);
3661
3785
  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, {
3786
+ return `${renderDocHead("KISS review", `${renderPerspective(run.audience)}${renderAnalysisDetails(provenanceFacts(run))}${renderRunAgain(view.canRun)}${KISS_TOGGLE}`)}
3787
+ ${renderFreshRegion(run, view)}
3788
+ ${renderResultBody(run, "header")}
3789
+ ${renderRunDialog(view.canRun, run.audience)}`;
3790
+ }
3791
+ /**
3792
+ * Whether the answer on the page still describes the branch, for the run the page presents.
3793
+ *
3794
+ * Drawn wherever a result is shown, including the earlier answer under a failed rerun: that answer
3795
+ * is the one being decided and handed off, and without this region the client has nowhere to repaint
3796
+ * a push it notices later.
3797
+ */
3798
+ function renderFreshRegion(run, view) {
3799
+ 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
3800
  dismissedSha: view.dismissedFreshnessSha,
3665
3801
  actions: view.canRun
3666
- })}</div>
3667
- ${renderResultBody(run, "header")}
3668
- ${renderRunControl(view.canRun, "Run KISS review again")}`;
3802
+ })}</div>`;
3803
+ }
3804
+ /**
3805
+ * Whose code the run was about, said as configuration rather than as part of the report.
3806
+ *
3807
+ * A compact line in the header rather than the question left standing under the result: the
3808
+ * choice was made, and what a finished report owes the reader is which choice it was.
3809
+ */
3810
+ function renderPerspective(audience) {
3811
+ const intent = INTENTS.find((candidate) => candidate.audience === audience);
3812
+ if (!intent) return "";
3813
+ return `<span class="prb-kiss-perspective" data-kiss-perspective="${audience}">Perspective: ${escapeHtml$1(intent.perspective)}</span>`;
3814
+ }
3815
+ /** Opens the run dialog. Beside Analysis details, because running again is a fact about the run too. */
3816
+ function renderRunAgain(canRun) {
3817
+ return canRun ? "<button type=\"button\" class=\"prb-kiss-run-again\" data-kiss-run-open>Run again</button>" : "";
3818
+ }
3819
+ /**
3820
+ * The run control once a result exists: the same question, asked in a dialog.
3821
+ *
3822
+ * The previous run's audience is preselected, and the choice is still shown and still has to be
3823
+ * confirmed by pressing Run. Preselecting carries yesterday's framing forward only as far as the
3824
+ * reader can see it, which is the difference from the remembered default the empty state refuses.
3825
+ */
3826
+ function renderRunDialog(canRun, audience) {
3827
+ if (!canRun) return "";
3828
+ return `<dialog class="prb-kiss-run-dialog" data-kiss-run-dialog aria-labelledby="prb-kiss-run-dialog-title">
3829
+ <h4 class="prb-kiss-run-dialog-title" id="prb-kiss-run-dialog-title">Run KISS review again</h4>
3830
+ ${renderRunControl(true, "Run KISS review", audience)}
3831
+ <button type="button" class="prb-kiss-run-cancel" data-kiss-run-cancel>Cancel</button>
3832
+ </dialog>`;
3669
3833
  }
3670
3834
  /**
3671
3835
  * A completed run's answer, from the outcome down to how it was made.
@@ -3692,12 +3856,15 @@ ${provenance === "header" ? "" : renderProvenance(run)}`;
3692
3856
  }
3693
3857
  function renderKissFreshness(reviewedSha, freshness, options = {}) {
3694
3858
  if (!freshness || freshness.state !== "changed") return "";
3695
- if (options.dismissedSha && options.dismissedSha === freshness.latestSha) return "";
3859
+ const runAgain = options.actions ? "<button type=\"button\" class=\"prb-kiss-fresh-run\" data-kiss-run-open>Run again</button>" : "";
3860
+ if (options.dismissedSha && options.dismissedSha === freshness.latestSha) return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" data-collapsed role="status">
3861
+ <p><strong>This KISS review describes commit ${escapeHtml$1(reviewedSha.slice(0, 7))}.</strong> The branch has moved on since. ${runAgain}</p>
3862
+ </div>`;
3696
3863
  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
3864
  return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" role="status">
3698
3865
  <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>` : ""}
3866
+ <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>
3867
+ <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
3868
  </div>`;
3702
3869
  }
3703
3870
  /**
@@ -3718,8 +3885,73 @@ function renderKissRecommendations(view) {
3718
3885
  <h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
3719
3886
  <p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
3720
3887
  </div>`;
3888
+ const decisions = decisionsFor(view, presented);
3889
+ const stale = view.freshness?.state === "changed";
3890
+ const card = (recommendation, rank) => renderRecommendation(recommendation, {
3891
+ runId: presented.id,
3892
+ audience: presented.audience,
3893
+ decision: decisions.get(recommendation.id) ?? "undecided",
3894
+ rank,
3895
+ stale,
3896
+ actions: view.canRun
3897
+ });
3898
+ const recommendations = recommendationsOf(result);
3899
+ const ranked = recommendations.map((recommendation, rank) => ({
3900
+ recommendation,
3901
+ rank
3902
+ }));
3903
+ const open = ranked.filter(({ recommendation }) => !decisions.has(recommendation.id));
3904
+ const decided = ranked.filter(({ recommendation }) => decisions.has(recommendation.id));
3905
+ const total = recommendations.length;
3721
3906
  return `${RECS_HEAD}${earlier}${renderWithheldNote(presented)}
3722
- ${result.recommendations.map((recommendation) => renderRecommendation(recommendation)).join("")}`;
3907
+ ${presented.audience === "reviewer" ? renderHandoff(recommendations, decisions, stale) : ""}
3908
+ <div class="pr-helper-questions-progress prb-kiss-progress" data-kiss-progress role="status">${kissProgressBody(total, open.length)}</div>
3909
+ <div class="prb-kiss-open-list" data-kiss-open-list>${open.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
3910
+ <details class="pr-helper-closed-findings prb-kiss-decided" data-kiss-decided${decided.length === 0 ? " hidden" : ""}${open.length === 0 ? " data-nothing-open" : ""}>
3911
+ <summary class="pr-helper-closed-findings-summary" data-kiss-decided-summary>${escapeHtml$1(decidedDrawerTitle(decided.length))}</summary>
3912
+ <div class="pr-helper-closed-findings-list" data-kiss-decided-list>${decided.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
3913
+ </details>`;
3914
+ }
3915
+ /**
3916
+ * What was decided about the run this pane presents, by recommendation id.
3917
+ *
3918
+ * Only decisions about recommendations the run still lists: a stored decision is never shown on a
3919
+ * card it was not made about, and a recommendation withheld as incomplete has no card to decide on.
3920
+ */
3921
+ function decisionsFor(view, run) {
3922
+ const listed = new Set(recommendationsOf(run.result).map((recommendation) => recommendation.id));
3923
+ return new Map((view.decisions ?? []).filter((entry) => entry.runId === run.id && listed.has(entry.recommendationId)).map((entry) => [entry.recommendationId, entry.decision]));
3924
+ }
3925
+ /** The decisions made about the run the panes present, for the rail. */
3926
+ function kissDecisionCounts(view) {
3927
+ const run = presentedKissRun(view);
3928
+ if (!run?.result) return null;
3929
+ const total = recommendationsOf(run.result).length;
3930
+ return {
3931
+ total,
3932
+ open: total - decisionsFor(view, run).size
3933
+ };
3934
+ }
3935
+ /**
3936
+ * The reviewer's handoff: what they chose to raise, as Markdown to paste.
3937
+ *
3938
+ * An export of the decisions on this page, not a report of its own. The Markdown comes from one
3939
+ * helper, over the model's own fields embedded beside the button, so what is copied is never read
3940
+ * back out of the rendered cards. The button's source is filled here for the decisions the page
3941
+ * loaded with, and refilled by the client after every decision.
3942
+ *
3943
+ * Reviewer runs only: an author was never going to hand these to anybody.
3944
+ */
3945
+ function renderHandoff(recommendations, decisions, stale) {
3946
+ const accepted = recommendations.filter((recommendation) => decisions.get(recommendation.id) === "accepted");
3947
+ const state = handoffState(accepted.length, stale);
3948
+ const sources = recommendations.map(handoffSource);
3949
+ const copy = renderLabelledCopySource(state.enabled ? handoffMarkdown(accepted.map(handoffSource)) : "", "Copy recommendations", `data-kiss-handoff-copy aria-describedby="prb-kiss-handoff-note"${state.enabled ? "" : " disabled"}`);
3950
+ return `<div class="prb-kiss-handoff" data-kiss-handoff${stale ? " data-stale" : ""}>
3951
+ ${copy}
3952
+ <p class="prb-kiss-handoff-note" id="prb-kiss-handoff-note" data-kiss-handoff-note>${escapeHtml$1(state.note)}</p>
3953
+ <script type="application/json" data-kiss-handoff-source>${embedHandoffSources(sources)}<\/script>
3954
+ </div>`;
3723
3955
  }
3724
3956
  /**
3725
3957
  * A run whose every recommendation was withheld: incomplete, and said to be.
@@ -3780,22 +4012,23 @@ ${renderRunControl(canRun, "Run KISS review")}`;
3780
4012
  * the reader that the page argues with them; one that is plainly waiting on the choice above it
3781
4013
  * teaches them what the choice is for.
3782
4014
  *
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.
4015
+ * Nothing is preselected on a first run: there is no earlier answer, and a default would be the
4016
+ * page guessing whose code this is. A rerun preselects the audience the run it replaces was asked
4017
+ * for, inside a dialog that shows both choices and waits for Run, so the framing carried forward is
4018
+ * one the reader can see and change rather than one remembered for them.
3786
4019
  */
3787
- function renderRunControl(canRun, label) {
4020
+ function renderRunControl(canRun, label, preselected) {
3788
4021
  if (!canRun) return "";
3789
4022
  return `<div class="prb-kiss-run" data-kiss-run-control>
3790
4023
  <fieldset class="prb-kiss-intents">
3791
4024
  <legend>Whose code is this?</legend>
3792
4025
  ${INTENTS.map((intent) => `<label class="prb-kiss-intent">
3793
- <input type="radio" name="kiss-audience" value="${intent.audience}" data-kiss-audience>
4026
+ <input type="radio" name="kiss-audience${preselected ? "-again" : ""}" value="${intent.audience}" data-kiss-audience${intent.audience === preselected ? " checked" : ""}>
3794
4027
  <span class="prb-kiss-intent-label">${escapeHtml$1(intent.label)}</span>
3795
4028
  <span class="prb-kiss-intent-note">${escapeHtml$1(intent.consequence)}</span>
3796
4029
  </label>`).join("")}
3797
4030
  </fieldset>
3798
- <button type="button" class="prb-kiss-start" data-kiss-run disabled>${escapeHtml$1(label)}</button>
4031
+ <button type="button" class="prb-kiss-start" data-kiss-run${preselected ? "" : " disabled"}>${escapeHtml$1(label)}</button>
3799
4032
  </div>`;
3800
4033
  }
3801
4034
  /**
@@ -3833,15 +4066,15 @@ function renderRunning(run, job) {
3833
4066
  */
3834
4067
  function renderFailed(run, view) {
3835
4068
  const standing = view.previous ?? null;
3836
- return `${renderDocHead("KISS review", KISS_TOGGLE)}
4069
+ return `${renderDocHead("KISS review", `${standing ? renderRunAgain(view.canRun) : ""}${KISS_TOGGLE}`)}
3837
4070
  <div class="prb-kiss-outcome" data-review-outcome="failed">
3838
4071
  <h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
3839
4072
  <p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
3840
4073
  ${standing ? "" : "<p class=\"prb-kiss-outcome-note\">Nothing was recorded from this run.</p>"}
3841
4074
  ${renderRejectedAnswer(run)}
3842
4075
  </div>
3843
- ${standing ? renderEarlier(standing) : ""}
3844
- ${renderRunControl(view.canRun, "Run KISS review again")}`;
4076
+ ${standing ? `${renderFreshRegion(standing, view)}${renderEarlier(standing)}` : ""}
4077
+ ${standing ? renderRunDialog(view.canRun, run.audience) : renderRunControl(view.canRun, "Run KISS review again", run.audience)}`;
3845
4078
  }
3846
4079
  /**
3847
4080
  * What the agent actually replied, folded away.
@@ -3935,14 +4168,14 @@ function renderProvenance(run) {
3935
4168
  */
3936
4169
  function renderJustified(items) {
3937
4170
  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>
4171
+ return `<details class="prb-kiss-justified" data-kiss-justified>
4172
+ <summary class="prb-kiss-justified-summary">Complexity worth keeping (${items.length})</summary>
4173
+ ${items.map((item) => `<details class="prb-kiss-kept" data-kiss-kept>
4174
+ <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
4175
  <div class="pr-helper-markdown">${renderMarkdown(item.rationale)}</div>
3943
4176
  ${renderLocations(item.locations)}
3944
- </div>`).join("")}
3945
- </section>`;
4177
+ </details>`).join("")}
4178
+ </details>`;
3946
4179
  }
3947
4180
  /**
3948
4181
  * What validation had to withdraw, disclosed rather than swallowed.
@@ -3964,26 +4197,20 @@ function renderAudit(run) {
3964
4197
  ${notes.map((note) => `<p>${escapeHtml$1(note)}</p>`).join("")}
3965
4198
  </div>`;
3966
4199
  }
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) {
4200
+ function renderRecommendation(recommendation, context) {
3980
4201
  const worthiness = recommendation.commentWorthiness;
3981
4202
  const lede = recommendation.gist || recommendation.complexity || "";
3982
4203
  const [start, ...rest] = orderedLocations(recommendation.locations);
3983
- return `<article class="prb-kiss-rec" data-payoff="${recommendation.payoff}"${worthiness ? ` data-worthiness="${worthiness}"` : ""}>
4204
+ 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
4205
  <header class="prb-kiss-rec-head">
3985
- <h4 class="prb-kiss-rec-title">${escapeHtml$1(recommendation.title)}</h4>
4206
+ <div class="prb-kiss-rec-title-row">
4207
+ <h4 class="prb-kiss-rec-title" tabindex="-1">${escapeHtml$1(recommendation.title)}</h4>
4208
+ ${renderFindingCopySource(recommendationMarkdown(handoffSource(recommendation)), "recommendation")}
4209
+ ${context.actions ? renderAskBuddyMenu("recommendation") : ""}
4210
+ </div>
3986
4211
  <div class="prb-kiss-rec-pills">
4212
+ <span class="pr-helper-finding-status prb-kiss-decision-status" data-kiss-status="${context.decision}">${escapeHtml$1(decisionStatusLabel(context.audience, context.decision))}</span>
4213
+ <span class="prb-kiss-stale" data-kiss-stale${context.stale ? "" : " hidden"}>Stale</span>
3987
4214
  <span class="prb-kiss-payoff" data-payoff="${recommendation.payoff}">${escapeHtml$1(PAYOFF[recommendation.payoff])}</span>
3988
4215
  ${worthiness ? `<span class="prb-kiss-worthiness" data-worthiness="${worthiness}">${escapeHtml$1(WORTHINESS[worthiness])}</span>` : ""}
3989
4216
  </div>
@@ -4000,11 +4227,33 @@ function renderRecommendation(recommendation) {
4000
4227
  ${recommendation.draftComment ? `<details class="prb-kiss-draft">
4001
4228
  <summary class="prb-kiss-draft-title">Draft comment</summary>
4002
4229
  <blockquote class="pr-helper-markdown">${renderMarkdown(recommendation.draftComment)}</blockquote>
4003
- ${renderMessageCopySource(recommendation.draftComment)}
4004
4230
  </details>` : ""}
4231
+ ${renderCardActions(recommendation, context, Boolean(start))}
4005
4232
  </article>`;
4006
4233
  }
4007
4234
  /**
4235
+ * The card's actions: open the file, copy the draft, and the decision.
4236
+ *
4237
+ * The decision is two toggles rather than a menu, because both answers are equally ordinary and
4238
+ * the reader should see both before choosing. Pressing the pressed one again takes the decision
4239
+ * back, which is how a decision is reversed; nothing is deleted either way.
4240
+ *
4241
+ * Open file presses the card's own Start here row, so there is exactly one way a KISS location
4242
+ * opens. The draft comment copies the Markdown the model wrote, which is what a forge needs.
4243
+ */
4244
+ function renderCardActions(recommendation, context, hasStart) {
4245
+ const labels = DECISION_LABELS[context.audience];
4246
+ const toggle = (decision) => `<button type="button" class="prb-kiss-decide" data-kiss-decide="${decision}" aria-pressed="${context.decision === decision}">${escapeHtml$1(labels[decision])}</button>`;
4247
+ const openFile = hasStart ? "<button type=\"button\" class=\"prb-kiss-open-file\" data-kiss-open-primary>Open file</button>" : "";
4248
+ const draft = recommendation.draftComment ? renderLabelledCopySource(recommendation.draftComment, "Copy draft comment") : "";
4249
+ 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>` : "";
4250
+ if (!openFile && !draft && !decide) return "";
4251
+ return `<div class="prb-kiss-rec-actions" data-kiss-actions>
4252
+ <div class="prb-kiss-rec-actions-aside">${openFile}${draft}</div>
4253
+ ${decide}
4254
+ </div>`;
4255
+ }
4256
+ /**
4008
4257
  * What acting on it gains, on one line: the estimate first, then the gains.
4009
4258
  *
4010
4259
  * The estimate says it is one in words beside it, so a phrase like "~35–50 lines" cannot be taken
@@ -4927,7 +5176,8 @@ function kissViewOf(view) {
4927
5176
  job: view.kissJob ?? null,
4928
5177
  freshness: view.kissFreshness ?? null,
4929
5178
  dismissedFreshnessSha: view.dismissedKissFreshnessSha,
4930
- previous: view.previousKissRun ?? null
5179
+ previous: view.previousKissRun ?? null,
5180
+ decisions: view.kissDecisions ?? []
4931
5181
  };
4932
5182
  }
4933
5183
  function renderKissViews(view) {
@@ -5434,16 +5684,16 @@ function renderRunningRail(view) {
5434
5684
  * item that opens a page saying "nothing has run yet" is a promise the rail cannot keep.
5435
5685
  */
5436
5686
  function renderKissRail(view) {
5437
- const result = presentedKissRun(kissViewOf(view))?.result;
5687
+ const counts = kissDecisionCounts(kissViewOf(view));
5438
5688
  return railGroup({
5439
5689
  id: "prb-rail-kiss",
5440
5690
  title: "KISS",
5441
5691
  icon: "kiss",
5442
5692
  view: "kiss",
5443
5693
  current: view.session.result === null && !showsProgress(view),
5444
- badge: result ? recommendationsBadge(result.recommendations.length) : null,
5694
+ badge: counts ? recommendationsBadge(counts) : null,
5445
5695
  collapses: "the KISS review",
5446
- body: result ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(result.recommendations.length)) : ""
5696
+ body: counts ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(counts)) : ""
5447
5697
  });
5448
5698
  }
5449
5699
  /** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
@@ -5473,17 +5723,20 @@ function railGroup(group) {
5473
5723
  </div>`;
5474
5724
  }
5475
5725
  /**
5476
- * How many recommendations a KISS run made, in KISS's own noun.
5726
+ * How many recommendations are still waiting on a decision, in KISS's own noun.
5477
5727
  *
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.
5728
+ * The same shape as the Issues and Questions badges: the open count, a tick once every one has been
5729
+ * decided, and the history in the label. "Decided" rather than "resolved", because accepting or
5730
+ * declining a suggestion fixes nothing in the code. A zero total keeps its `0`: a run that
5731
+ * recommended nothing has had nothing decided.
5481
5732
  */
5482
- function recommendationsBadge(count) {
5483
- return {
5484
- text: String(count),
5485
- label: count === 1 ? "1 recommendation" : `${count} recommendations`
5486
- };
5733
+ function recommendationsBadge(counts) {
5734
+ return remainingBadge({
5735
+ total: counts.total,
5736
+ open: counts.open,
5737
+ done: counts.total - counts.open,
5738
+ reviewerClosed: counts.total - counts.open
5739
+ }, "recommendation", "decided");
5487
5740
  }
5488
5741
  /** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
5489
5742
  /** The map and the implementation diagrams together: everything behind the one rail item. */
@@ -10434,7 +10687,8 @@ async function sendWorkspacePage(res, workspace) {
10434
10687
  kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
10435
10688
  kissFreshness,
10436
10689
  dismissedKissFreshnessSha: workspace.dismissedKissFreshnessSha,
10437
- previousKissRun: previousKissResult(workspace)
10690
+ previousKissRun: previousKissResult(workspace),
10691
+ kissDecisions: workspace.kissDecisions ?? []
10438
10692
  });
10439
10693
  recordWorkspaceOpened(workspace);
10440
10694
  if (freshness) record({
@@ -10578,7 +10832,7 @@ async function handleFreshness(res, workspace, quiet) {
10578
10832
  * an answer would be wrong.
10579
10833
  */
10580
10834
  async function kissFreshnessOrNull(workspace) {
10581
- const run = latestKissRun(workspace);
10835
+ const run = presentedKissRunOf(workspace);
10582
10836
  if (!run) return null;
10583
10837
  try {
10584
10838
  return await checkCodeFreshness(workspace, run.reviewedSha);
@@ -11019,6 +11273,7 @@ async function handlePrefs(req, res) {
11019
11273
  }
11020
11274
  async function handleState(req, res, workspace) {
11021
11275
  const body = await readJson(req);
11276
+ if (body !== null && "kissDecision" in body) return handleKissDecision(res, workspace, body.kissDecision);
11022
11277
  const saved = STATE_FIELDS.some((field) => body !== null && field in body) ? mutateWorkspace(workspace.id, (current) => applyState(current, body)) : workspace;
11023
11278
  if (!saved) return send(res, 404, { error: "No such review" });
11024
11279
  send(res, 200, {
@@ -11028,6 +11283,51 @@ async function handleState(req, res, workspace) {
11028
11283
  queued: readQueue().includes(saved.id)
11029
11284
  });
11030
11285
  }
11286
+ var KISS_DECISION_VALUES = [
11287
+ "accepted",
11288
+ "declined",
11289
+ "undecided"
11290
+ ];
11291
+ function kissDecisionInput(value) {
11292
+ const input = value;
11293
+ if (typeof input?.runId !== "string" || input.runId === "") return null;
11294
+ if (typeof input.recommendationId !== "string" || input.recommendationId === "") return null;
11295
+ if (!KISS_DECISION_VALUES.includes(input.decision)) return null;
11296
+ return {
11297
+ runId: input.runId,
11298
+ recommendationId: input.recommendationId,
11299
+ decision: input.decision
11300
+ };
11301
+ }
11302
+ var KISS_DECISION_REFUSED = {
11303
+ "unknown-run": "This review has no completed KISS run with that id.",
11304
+ "unknown-recommendation": "That KISS run has no recommendation with that id."
11305
+ };
11306
+ /**
11307
+ * One decision, written onto the review as it is now and answered with what was stored.
11308
+ *
11309
+ * Checked against a fresh read before writing, so a refused decision leaves the file alone, and
11310
+ * checked again inside the write, where the answer is final. The reply carries the stored decision
11311
+ * so the page confirms from the server rather than from what it asked for.
11312
+ */
11313
+ function handleKissDecision(res, workspace, value) {
11314
+ const input = kissDecisionInput(value);
11315
+ if (!input) return send(res, 400, { error: "A KISS decision names a run, a recommendation, and accepted, declined or undecided." });
11316
+ const before = loadWorkspace(workspace.id);
11317
+ if (!before) return send(res, 404, { error: "No such review" });
11318
+ const early = kissDecisionRefusal(before, input);
11319
+ if (early) return send(res, 400, { error: KISS_DECISION_REFUSED[early] });
11320
+ let refused = null;
11321
+ const saved = mutateWorkspace(workspace.id, (current) => {
11322
+ refused = decideKissRecommendation(current, input);
11323
+ });
11324
+ if (!saved) return send(res, 404, { error: "No such review" });
11325
+ if (refused) return send(res, 400, { error: KISS_DECISION_REFUSED[refused] });
11326
+ send(res, 200, {
11327
+ ok: true,
11328
+ decision: kissDecisionsOf(saved, input.runId).find((entry) => entry.recommendationId === input.recommendationId)?.decision ?? "undecided"
11329
+ });
11330
+ }
11031
11331
  /** What `/state` can change. A body naming none of these writes nothing. */
11032
11332
  var STATE_FIELDS = [
11033
11333
  "answeredQuestionIds",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
4
4
  "description": "Review a branch or pull request in an isolated checkout, from your terminal, with the coding agent you already have installed.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",