prreviewbuddy 0.71.2 → 0.73.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.73.1
4
+
5
+ **A review's first update says how long updates take.** While an update runs, the page now
6
+ compares it with this review's earlier updates, and on its first update with other updates in the
7
+ same repository: "Previous update took 51s", "Updates to this review usually take about 1 minute",
8
+ or "Updates in this repository usually take about 1 minute". An update is never compared with a
9
+ fresh review, which does much more work. With no finished update in the repository, nothing is
10
+ said.
11
+
12
+ **The running time is the footer of the progress page.** Under the steps, the running time counts
13
+ to the second ("Running for 1m 08s"), with how long earlier runs took beneath it and Cancel beside
14
+ them as a proper button. The same footer appears while a review runs and while an update runs.
15
+
16
+ ## 0.73.0
17
+
18
+ **KISS runs now count as reviews in usage telemetry.** With telemetry on, a KISS run sends the same
19
+ two events a standard review does, `review_started` and `review_finished`, with the same agent,
20
+ outcome and duration. Until now it sent neither, so somebody who only ran KISS looked as if they
21
+ had installed PR Review Buddy and never used it. Both review events now carry one more field,
22
+ `reviewType`, which is one of two words, `standard` or `kiss`: the kind of analysis, and nothing
23
+ about your code, your repository or what the analysis said. It is the only field added. Updating a
24
+ review with new commits still sends nothing. `prreviewbuddy config set telemetry-upload off` and
25
+ `PRB_TELEMETRY=off` stop these exactly as before.
26
+
27
+ ## 0.72.1
28
+
29
+ **Local usage logs count themes on the first read.** The `workspace_opened` log entry waited for
30
+ the review to be marked Reviewed rather than for the analysis to finish, so it rarely fired before
31
+ a reading session ended and the session summary recorded zero themes. It now waits only for the
32
+ analysis. Nothing about what is sent changes: neither event leaves the machine.
33
+
34
+ ## 0.72.0
35
+
36
+ **Both explanations stay on the card.** Ask Buddy on an issue or question now offers
37
+ **Explain (technical)** and **Explain (simple)**, and either one keeps its answer under the finding,
38
+ labelled **Explanation · Technical** or **Explanation · Simple**. A card holds one explanation: run
39
+ the other kind and it replaces the one there, while both answers stay in Buddy AI. If a new
40
+ explanation fails or is stopped, the card keeps the one it had. The same two names appear wherever
41
+ Ask Buddy offers them, including on a selection.
42
+
43
+ **Ask more follows up on the explanation you were reading.** If another tab replaces a card's
44
+ explanation after you press Ask more, your question still goes with the one you were reading.
45
+
46
+ **Simple explanations kept by 0.71 carry over.** They appear on their cards as
47
+ Explanation · Simple, and Ask more and updates use them as before.
48
+
3
49
  ## 0.71.2
4
50
 
5
51
  **Buddy AI's opening line reads plainly.** Before your first question, Buddy AI now says "Answers
@@ -3277,12 +3277,44 @@ function describeAuthorship(changeSet, pullRequest) {
3277
3277
  }
3278
3278
  //#endregion
3279
3279
  //#region ../../packages/review-harness/src/domain/ai_review/review_session.ts
3280
+ /**
3281
+ * The Ask Buddy actions whose answer is kept on the finding's card: `explain-detail` is
3282
+ * Explain (technical), `explain` is Explain (simple). The ids are the shared action ids in
3283
+ * selection_prompts.ts, which predate the labels.
3284
+ */
3285
+ var EXPLANATION_ACTIONS = ["explain-detail", "explain"];
3286
+ function isExplanationAction(value) {
3287
+ return EXPLANATION_ACTIONS.includes(value);
3288
+ }
3289
+ function explanationActionOf(explanation) {
3290
+ return explanation.action ?? "explain";
3291
+ }
3280
3292
  /** The explanation of this finding, if the reviewer has asked for one. */
3281
- function simpleExplanationOf(session, kind, id) {
3282
- return session.simpleExplanations?.find((entry) => entry.kind === kind && entry.id === id);
3293
+ function explanationOf(session, kind, id) {
3294
+ return session.findingExplanations?.find((entry) => entry.kind === kind && entry.id === id);
3283
3295
  }
3284
- /** The list with this finding's explanation replaced, never a second one beside it. */
3285
- function withSimpleExplanation(entries, explanation) {
3296
+ /**
3297
+ * A session as stored by 0.71.0 to 0.71.2, which kept explanations under `simpleExplanations`, read
3298
+ * as this version stores it. Those were all Explain (simple), which an entry without an action
3299
+ * already means, so they move across unchanged. Where both lists name a finding the newer list's
3300
+ * entry wins: a later version wrote it. A session without the old list is returned as it was.
3301
+ */
3302
+ function withLegacyExplanationsMoved(session) {
3303
+ const legacy = session.simpleExplanations;
3304
+ if (legacy === void 0) return session;
3305
+ const { simpleExplanations: _moved, ...rest } = session;
3306
+ const newer = session.findingExplanations ?? [];
3307
+ const merged = [...(Array.isArray(legacy) ? legacy : []).filter((entry) => !explanationOf(session, entry.kind, entry.id)), ...newer];
3308
+ return {
3309
+ ...rest,
3310
+ ...merged.length ? { findingExplanations: merged } : {}
3311
+ };
3312
+ }
3313
+ /**
3314
+ * The list with this finding's explanation replaced, never a second one beside it. One slot per
3315
+ * finding whichever action wrote it: the newest explanation is the card's.
3316
+ */
3317
+ function withExplanation(entries, explanation) {
3286
3318
  return [...(entries ?? []).filter((entry) => !(entry.kind === explanation.kind && entry.id === explanation.id)), explanation];
3287
3319
  }
3288
3320
  /**
@@ -4431,12 +4463,12 @@ var WORKSPACES_DIR = join(STORE_ROOT, "workspaces");
4431
4463
  * Named so that a long run that rewrites the session, an update above all, can put these back from
4432
4464
  * the copy on disk when it saves: the reviewer may have answered a question or dismissed a finding
4433
4465
  * in the minutes it took. `handleState` writes the first two and nothing else; `handleAsk` writes
4434
- * `simpleExplanations`, and only when the reviewer asked for one.
4466
+ * `findingExplanations`, and only when the reviewer asked for one.
4435
4467
  */
4436
4468
  var REVIEWER_SESSION_FIELDS = [
4437
4469
  "answeredQuestionIds",
4438
4470
  "dismissedIssues",
4439
- "simpleExplanations"
4471
+ "findingExplanations"
4440
4472
  ];
4441
4473
  /**
4442
4474
  * A session a long run is about to write, with the reviewer's own fields taken from disk instead.
@@ -4668,7 +4700,13 @@ function deleteWorkspace(id) {
4668
4700
  }
4669
4701
  function loadWorkspace(id) {
4670
4702
  try {
4671
- return JSON.parse(readFileSync(workspacePath(id), "utf8"));
4703
+ const stored = JSON.parse(readFileSync(workspacePath(id), "utf8"));
4704
+ if (!stored?.session || typeof stored.session !== "object") return stored;
4705
+ const session = withLegacyExplanationsMoved(stored.session);
4706
+ return session === stored.session ? stored : {
4707
+ ...stored,
4708
+ session
4709
+ };
4672
4710
  } catch {
4673
4711
  return null;
4674
4712
  }
@@ -12831,13 +12869,28 @@ var RUNTIME_WORDS = {
12831
12869
  last: "Last analysis of this review",
12832
12870
  recent: "Recent analyses of this review",
12833
12871
  usual: "Usually takes"
12834
- },
12835
- update: {
12836
- last: "Last update of this review",
12837
- recent: "Recent updates of this review",
12838
- usual: "Usually takes"
12839
12872
  }
12840
12873
  };
12874
+ /**
12875
+ * How long earlier updates took, said from the most specific history there is: this review's own
12876
+ * updates, else the repository's, else nothing. Never both, and never a fresh review's runtime,
12877
+ * which is a different operation. The rule above holds at both levels: one run is stated exactly
12878
+ * and only several earn "usually". Several are always one figure, the median, never a range.
12879
+ */
12880
+ function describeUpdateRuntimes(own, repository) {
12881
+ const [ownRuns, repoRuns] = [own.slice(0, 3), repository.slice(0, 3)];
12882
+ if (ownRuns.length === 1) return `Previous update took ${formatDuration(ownRuns[0])}`;
12883
+ if (ownRuns.length > 1) return `Updates to this review usually take ${aboutMinutes(median(ownRuns))}`;
12884
+ if (repoRuns.length === 1) return `Last update in this repository took ${formatDuration(repoRuns[0])}`;
12885
+ if (repoRuns.length > 1) return `Updates in this repository usually take ${aboutMinutes(median(repoRuns))}`;
12886
+ return null;
12887
+ }
12888
+ /** "less than a minute", "about 1 minute", "about 4 minutes". */
12889
+ function aboutMinutes(ms) {
12890
+ const minutes = toMinutes(ms);
12891
+ if (minutes === 0) return "less than a minute";
12892
+ return `about ${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
12893
+ }
12841
12894
  //#endregion
12842
12895
  //#region ../../packages/review-harness/src/runtime/store.ts
12843
12896
  var VERSION = 1;
@@ -12944,6 +12997,18 @@ function updateRuntimes(repoPath, reviewId) {
12944
12997
  reviewType: "standard"
12945
12998
  });
12946
12999
  }
13000
+ /**
13001
+ * Updates of any review in the repository, for a review's first update. No subject for the reason
13002
+ * `newReviewRuntimes` has none: a review not yet updated has no updates of its own. Still only
13003
+ * updates, because a fresh review of the same change is a different operation and much slower.
13004
+ */
13005
+ function repoUpdateRuntimes(repoPath) {
13006
+ return durationsOf({
13007
+ operations: ["update"],
13008
+ repoPath,
13009
+ reviewType: "standard"
13010
+ });
13011
+ }
12947
13012
  //#endregion
12948
13013
  //#region ../../packages/review-harness/src/workspace/runtime_history.ts
12949
13014
  /**
@@ -12977,10 +13042,15 @@ function analysisHistory(workspace, job) {
12977
13042
  function waitingHistory(workspace, runs) {
12978
13043
  return {
12979
13044
  job: isLiveJob(runs.job) ? analysisHistory(workspace, runs.job) : null,
12980
- update: isLiveJob(runs.updateJob) ? describeRuntimes(updateRuntimes(workspace.repoPath, workspace.id), RUNTIME_WORDS.update) : null,
13045
+ update: isLiveJob(runs.updateJob) ? updateHistory(workspace) : null,
12981
13046
  kiss: isLiveJob(runs.kissJob) ? analysisHistory(workspace, runs.kissJob) : null
12982
13047
  };
12983
13048
  }
13049
+ /** This review's own updates first; the repository's only for a review never updated before. */
13050
+ function updateHistory(workspace) {
13051
+ const own = updateRuntimes(workspace.repoPath, workspace.id);
13052
+ return describeUpdateRuntimes(own, own.length > 0 ? [] : repoUpdateRuntimes(workspace.repoPath));
13053
+ }
12984
13054
  //#endregion
12985
13055
  //#region ../../packages/review-harness/src/domain/feedback/feedback_url.ts
12986
13056
  /** How each client names itself in the prefilled message, which is the half a screenshot carries. */
@@ -13273,6 +13343,7 @@ function project(record) {
13273
13343
  cliVersion: record.cliVersion,
13274
13344
  platform: record.platform,
13275
13345
  agent: event.agent,
13346
+ reviewType: event.reviewType,
13276
13347
  occurredAt: record.at
13277
13348
  };
13278
13349
  return event.event === "review_finished" ? {
@@ -13351,7 +13422,7 @@ var LOG_PATH = join(STORE_ROOT, "events.jsonl");
13351
13422
  * disable is a different act from sending to a server, and the consent that suits one does not
13352
13423
  * carry to the other, so `events.jsonl` keeps recording the full event union either way and the
13353
13424
  * question asked on first run is about the wire. Twenty events are written here; three of them,
13354
- * carrying eight fields between them, are the most that is ever sent.
13425
+ * carrying nine fields between them, are the most that is ever sent.
13355
13426
  */
13356
13427
  function record(event) {
13357
13428
  try {
@@ -13693,10 +13764,11 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
13693
13764
  contextId: context.contextId
13694
13765
  };
13695
13766
  saveJob(job);
13696
- if (job.reviewType !== "kiss") record({
13767
+ record({
13697
13768
  event: "review_started",
13698
13769
  reviewId: workspaceId,
13699
13770
  agent: agentName(job.env.agentId),
13771
+ reviewType: reviewKindOf(job),
13700
13772
  changedFiles: totalChangedFiles(context.changeSet.data),
13701
13773
  linesChanged: context.changeSet.data.files.reduce((total, file) => total + (file.additions ?? 0) + (file.deletions ?? 0), 0),
13702
13774
  analysedFiles: Math.max(0, context.changeSet.data.files.length - context.inventoryOnlyFiles.length)
@@ -13777,11 +13849,11 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
13777
13849
  * finished it. That is the number worth having: it is what somebody actually waited.
13778
13850
  */
13779
13851
  function finished(job, workspaceId, startedAt, ok, now) {
13780
- if (job.reviewType === "kiss") return;
13781
13852
  record({
13782
13853
  event: "review_finished",
13783
13854
  reviewId: workspaceId,
13784
13855
  agent: agentName(job.env.agentId),
13856
+ reviewType: reviewKindOf(job),
13785
13857
  ok,
13786
13858
  durationMs: now() - startedAt
13787
13859
  });
@@ -13831,6 +13903,10 @@ function recordAnalysisRuntime(job, workspaceId, from, durationMs, completedAt,
13831
13903
  * named ran under Claude. Recording those as unknown would invent a fourth agent out of the
13832
13904
  * oldest reviews on disk.
13833
13905
  */
13906
+ /** A job with no `reviewType` predates KISS and is a standard review. */
13907
+ function reviewKindOf(job) {
13908
+ return job.reviewType === "kiss" ? "kiss" : "standard";
13909
+ }
13834
13910
  function agentName(id) {
13835
13911
  const resolved = id ?? DEFAULT_AGENT_ID;
13836
13912
  return isAgentId(resolved) ? resolved : "other";
@@ -16708,4 +16784,4 @@ function lineageIds(id) {
16708
16784
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
16709
16785
  }
16710
16786
  //#endregion
16711
- export { MAX_WORKSPACE_PORT as $, isClaimed as $n, resolveBase as $r, markReadForReview as $t, relativeTime as A, isWorktreeHeld as An, parseLifecycle as Ar, partFiles as At, readIndexToken as B, reviewedShaOf as Bn, issuesOutstanding as Br, isArchitectureId as Bt, track as C, originRemote as Cn, activityAttributes as Cr, mapRuntimes as Ct, endRun as D, MANAGED_ROOT as Dn, lifecycleItemLabel as Dr, ARCHITECTURE_DIR as Dt, completeRun as E, ReviewBeingDeletedError as En, lifecycleChip as Er, describeRuntimes as Et, runBatch as F, describeSubject as Fn, queueStamp as Fr, trackedFiles as Ft, reviewsUrl as G, loadJob as Gn, simpleExplanationOf as Gr, createArchitectureNotification as Gt, clearServerRecordOwnedBy as H, endedAt as Hn, reviewerDispositions as Hr, announceJob as Ht, activateQueued as I, findExistingReview as In, readQueue as Ir, ownersOf as It, stopChild as J, isLiveJob as Jn, WITHHELD_REASON as Jr, eventName as Jt, serverEntryExists as K, saveJob as Kn, withSimpleExplanation as Kr, createPrState as Kt, compactQueue as L, checkCodeFreshness as Ln, doneVerb as Lr, QUOTE_LIMITS as Lt, extractPullRequestLinks as M, removeWorktree as Mn, queueAdd as Mr, repositoryKey as Mt, parsePullRequestLink as N, requestJobStop as Nn, queueAddMany as Nr, storeArchitecture as Nt, reportRun as O, createWorktree as On, lifecyclePill as Or, checkArchitecture as Ot, unsupportedReason as P, describeMovement as Pn, queueRemove as Pr, storedScopes as Pt, DEFAULT_WORKSPACE_PORT as Q, clearClaim as Qn, isCommitReview as Qr, markRead as Qt, queueForReview as R, checkReview as Rn, isIssueOutstanding as Rr, REPOSITORY_SCOPE as Rt, isDriving as S, remoteHost as Sn, LIFECYCLES as Sr, waitingHistory as St, beginRun as T, modelHelpLines as Tn, isLifecycle as Tr, RUNTIME_WORDS as Tt, ensureServer as U, fail as Un, processDiscussion as Ur, branchOf as Ut, bootstrapUrl as V, allJobs as Vn, questionsOutstanding as Vr, writeJsonAtomically as Vt, readServerRecord as W, isTerminal as Wn, recommendationsOf as Wr, subjectOf as Wt, workspaceUrl as X, progressSteps as Xn, resolveTarget as Xr, listNotifications as Xt, stopServer as Y, phasesFor as Yn, kissOutcome as Yr, headline as Yt, writeServerRecord as Z, purposeOf as Zn, DetachedHeadError as Zr, markAllRead as Zt, liveUpdateFor as _, extractJsonText as _n, reviewedRepositories as _r, telemetryUploadConsent as _t, wasBlocked as a, writeAgentPreference as ai, withUsageRecorded as an, kissDecisionRefusal as ar, failureOf as at, recordKissRun as b, forgeResolver as bn, workspaceRevision as br, PACKAGE_NAME as bt, RefreshUnavailableError as c, AGENT_IDS as ci, authorPreview as cn, lineagePosition as cr, reanalyseReview as ct, mergeUpdateInto as d, detectAgents as di, renderMarkdown as dn, mutateWorkspace as dr, append as dt, git as ei, pruneNotifications as en, decideKissRecommendation as er, MIN_WORKSPACE_PORT as et, updateReview as f, AgentCancelledError as fi, CODE_BLOCK_CLASS as fn, positionInLineage as fr, readEvents as ft, UpdateAlreadyRunningError as g, asString as gn, reviewedCommit as gr, recordTelemetryUploadConsent as gt, askCheckout as h, renderInlineText as hn, recentWorkspaces as hr, deliver as ht, liveJobsFor as i, readAgentPreference as ii, unreadCount as in, indexReviewGroups as ir, writePortPreference as it, batchIdentity as j, readMarker as jn, sectionOf as jr, readArchitecture as jt, stopRun as k, holdWorktree as kn, offersLifecycle as kr, loadArchitecture as kt, cancelUpdate as l, agentById as li, hasAuthorContent as ln, loadWorkspace as lr, runJob as lt, isUnchanged as m, escapeHtml as mn, previousKissResult as mr, stamp as mt, deleteReview as n, resolveModel as ni, removeNotification as nn, groupByLineage as nr, resolveWorkspacePort as nt, discardJob as o, CONFIG_PATH as oi, EXPLAIN_SIMPLY_PROMPT as on, kissDecisionsOf as or, prepareReview as ot, refreshPrContext as p, AgentUnavailableError as pi, CODE_COPY_CLASS as pn, presentedKissRunOf as pr, record as pt, spawnServer as q, PHASES as qn, describeAuthorship as qr, createPrUpdate as qt, lineageIds as r, clearAgentPreference as ri, toneOf as rn, indexKeptIds as rr, statedWorkspacePort as rt, FETCH_TIMEOUT_MS as s, STORE_ROOT as si, UNESTABLISHED_MOTIVATION as sn, latestKissRun as sr, settleReview as st, deleteLineage as t, resolveAgent as ti, readNotification as tn, followedRefName as tr, parseWorkspacePort as tt, cancellableHere as u, agentFor as ui, GITHUB_MARKDOWN as un, matchingWorkspaceIds as ur, startJob as ut, runningJobs as v, objectWithAnyOf as vn, summarise as vr, telemetryUploadEnabled as vt, RUN_KINDS as w, fillFileUrlTemplate as wn, activityLabel as wr, recordRuntimeBestEffort as wt, startKissJob as x, forgeFor as xn, workspaceStamps as xr, feedbackUrl as xt, agentEnvOf as y, claimRequestUrl as yn, touchWorkspace as yr, BUILD_VERSION as yt, settleQueue as z, followedBranch as zn, isQuestionOutstanding as zr, ancestorsOf as zt };
16787
+ export { MAX_WORKSPACE_PORT as $, isClaimed as $n, DetachedHeadError as $r, markReadForReview as $t, relativeTime as A, isWorktreeHeld as An, parseLifecycle as Ar, partFiles as At, readIndexToken as B, reviewedShaOf as Bn, issuesOutstanding as Br, isArchitectureId as Bt, track as C, originRemote as Cn, activityAttributes as Cr, mapRuntimes as Ct, endRun as D, MANAGED_ROOT as Dn, lifecycleItemLabel as Dr, ARCHITECTURE_DIR as Dt, completeRun as E, ReviewBeingDeletedError as En, lifecycleChip as Er, describeRuntimes as Et, runBatch as F, describeSubject as Fn, queueStamp as Fr, trackedFiles as Ft, reviewsUrl as G, loadJob as Gn, explanationActionOf as Gr, createArchitectureNotification as Gt, clearServerRecordOwnedBy as H, endedAt as Hn, reviewerDispositions as Hr, announceJob as Ht, activateQueued as I, findExistingReview as In, readQueue as Ir, ownersOf as It, stopChild as J, isLiveJob as Jn, withExplanation as Jr, eventName as Jt, serverEntryExists as K, saveJob as Kn, explanationOf as Kr, createPrState as Kt, compactQueue as L, checkCodeFreshness as Ln, doneVerb as Lr, QUOTE_LIMITS as Lt, extractPullRequestLinks as M, removeWorktree as Mn, queueAdd as Mr, repositoryKey as Mt, parsePullRequestLink as N, requestJobStop as Nn, queueAddMany as Nr, storeArchitecture as Nt, reportRun as O, createWorktree as On, lifecyclePill as Or, checkArchitecture as Ot, unsupportedReason as P, describeMovement as Pn, queueRemove as Pr, storedScopes as Pt, DEFAULT_WORKSPACE_PORT as Q, clearClaim as Qn, resolveTarget as Qr, markRead as Qt, queueForReview as R, checkReview as Rn, isIssueOutstanding as Rr, REPOSITORY_SCOPE as Rt, isDriving as S, remoteHost as Sn, LIFECYCLES as Sr, waitingHistory as St, beginRun as T, modelHelpLines as Tn, isLifecycle as Tr, RUNTIME_WORDS as Tt, ensureServer as U, fail as Un, processDiscussion as Ur, branchOf as Ut, bootstrapUrl as V, allJobs as Vn, questionsOutstanding as Vr, writeJsonAtomically as Vt, readServerRecord as W, isTerminal as Wn, recommendationsOf as Wr, subjectOf as Wt, workspaceUrl as X, progressSteps as Xn, WITHHELD_REASON as Xr, listNotifications as Xt, stopServer as Y, phasesFor as Yn, describeAuthorship as Yr, headline as Yt, writeServerRecord as Z, purposeOf as Zn, kissOutcome as Zr, markAllRead as Zt, liveUpdateFor as _, extractJsonText as _n, reviewedRepositories as _r, telemetryUploadConsent as _t, wasBlocked as a, clearAgentPreference as ai, withUsageRecorded as an, kissDecisionRefusal as ar, failureOf as at, recordKissRun as b, forgeResolver as bn, workspaceRevision as br, PACKAGE_NAME as bt, RefreshUnavailableError as c, CONFIG_PATH as ci, authorPreview as cn, lineagePosition as cr, reanalyseReview as ct, mergeUpdateInto as d, agentById as di, renderMarkdown as dn, mutateWorkspace as dr, append as dt, isCommitReview as ei, pruneNotifications as en, decideKissRecommendation as er, MIN_WORKSPACE_PORT as et, updateReview as f, agentFor as fi, CODE_BLOCK_CLASS as fn, positionInLineage as fr, readEvents as ft, UpdateAlreadyRunningError as g, asString as gn, reviewedCommit as gr, recordTelemetryUploadConsent as gt, askCheckout as h, AgentUnavailableError as hi, renderInlineText as hn, recentWorkspaces as hr, deliver as ht, liveJobsFor as i, resolveModel as ii, unreadCount as in, indexReviewGroups as ir, writePortPreference as it, batchIdentity as j, readMarker as jn, sectionOf as jr, readArchitecture as jt, stopRun as k, holdWorktree as kn, offersLifecycle as kr, loadArchitecture as kt, cancelUpdate as l, STORE_ROOT as li, hasAuthorContent as ln, loadWorkspace as lr, runJob as lt, isUnchanged as m, AgentCancelledError as mi, escapeHtml as mn, previousKissResult as mr, stamp as mt, deleteReview as n, git as ni, removeNotification as nn, groupByLineage as nr, resolveWorkspacePort as nt, discardJob as o, readAgentPreference as oi, EXPLAIN_SIMPLY_PROMPT as on, kissDecisionsOf as or, prepareReview as ot, refreshPrContext as p, detectAgents as pi, CODE_COPY_CLASS as pn, presentedKissRunOf as pr, record as pt, spawnServer as q, PHASES as qn, isExplanationAction as qr, createPrUpdate as qt, lineageIds as r, resolveAgent as ri, toneOf as rn, indexKeptIds as rr, statedWorkspacePort as rt, FETCH_TIMEOUT_MS as s, writeAgentPreference as si, UNESTABLISHED_MOTIVATION as sn, latestKissRun as sr, settleReview as st, deleteLineage as t, resolveBase as ti, readNotification as tn, followedRefName as tr, parseWorkspacePort as tt, cancellableHere as u, AGENT_IDS as ui, GITHUB_MARKDOWN as un, matchingWorkspaceIds as ur, startJob as ut, runningJobs as v, objectWithAnyOf as vn, summarise as vr, telemetryUploadEnabled as vt, RUN_KINDS as w, fillFileUrlTemplate as wn, activityLabel as wr, recordRuntimeBestEffort as wt, startKissJob as x, forgeFor as xn, workspaceStamps as xr, feedbackUrl as xt, agentEnvOf as y, claimRequestUrl as yn, touchWorkspace as yr, BUILD_VERSION as yt, settleQueue as z, followedBranch as zn, isQuestionOutstanding as zr, ancestorsOf as zt };
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as MAX_WORKSPACE_PORT, A as relativeTime, B as readIndexToken, Dn as MANAGED_ROOT, F as runBatch, Fn as describeSubject, G as reviewsUrl, Gn as loadJob, In as findExistingReview, Jr as WITHHELD_REASON, Mn as removeWorktree, Pn as describeMovement, Q as DEFAULT_WORKSPACE_PORT, Qn as clearClaim, R as queueForReview, Tn as modelHelpLines, U as ensureServer, V as bootstrapUrl, Vn as allJobs, X as workspaceUrl, Xn as progressSteps, Xr as resolveTarget, Y as stopServer, Yr as kissOutcome, Zn as purposeOf, _ as liveUpdateFor, _r as reviewedRepositories, _t as telemetryUploadConsent, a as wasBlocked, ai as writeAgentPreference, b as recordKissRun, bn as forgeResolver, bt as PACKAGE_NAME, ci as AGENT_IDS, ct as reanalyseReview, d as mergeUpdateInto, di as detectAgents, dr as mutateWorkspace, dt as append, ei as git, et as MIN_WORKSPACE_PORT, f as updateReview, fr as positionInLineage, g as UpdateAlreadyRunningError, gt as recordTelemetryUploadConsent, hr as recentWorkspaces, ht as deliver, i as liveJobsFor, ii as readAgentPreference, it as writePortPreference, j as batchIdentity, jn as readMarker, li as agentById, lr as loadWorkspace, mt as stamp, n as deleteReview$1, ni as resolveModel, oi as CONFIG_PATH, ot as prepareReview, pt as record$1, qn as PHASES, rt as statedWorkspacePort, si as STORE_ROOT, st as settleReview, ti as resolveAgent, tr as followedRefName, tt as parseWorkspacePort, ur as matchingWorkspaceIds, ut as startJob, v as runningJobs, vr as summarise, vt as telemetryUploadEnabled, yt as BUILD_VERSION } from "./delete_review-BgIZpcsP.js";
2
+ import { $ as MAX_WORKSPACE_PORT, A as relativeTime, B as readIndexToken, Dn as MANAGED_ROOT, F as runBatch, Fn as describeSubject, G as reviewsUrl, Gn as loadJob, In as findExistingReview, Mn as removeWorktree, Pn as describeMovement, Q as DEFAULT_WORKSPACE_PORT, Qn as clearClaim, Qr as resolveTarget, R as queueForReview, Tn as modelHelpLines, U as ensureServer, V as bootstrapUrl, Vn as allJobs, X as workspaceUrl, Xn as progressSteps, Xr as WITHHELD_REASON, Y as stopServer, Zn as purposeOf, Zr as kissOutcome, _ as liveUpdateFor, _r as reviewedRepositories, _t as telemetryUploadConsent, a as wasBlocked, b as recordKissRun, bn as forgeResolver, bt as PACKAGE_NAME, ci as CONFIG_PATH, ct as reanalyseReview, d as mergeUpdateInto, di as agentById, dr as mutateWorkspace, dt as append, et as MIN_WORKSPACE_PORT, f as updateReview, fr as positionInLineage, g as UpdateAlreadyRunningError, gt as recordTelemetryUploadConsent, hr as recentWorkspaces, ht as deliver, i as liveJobsFor, ii as resolveModel, it as writePortPreference, j as batchIdentity, jn as readMarker, li as STORE_ROOT, lr as loadWorkspace, mt as stamp, n as deleteReview$1, ni as git, oi as readAgentPreference, ot as prepareReview, pi as detectAgents, pt as record$1, qn as PHASES, ri as resolveAgent, rt as statedWorkspacePort, si as writeAgentPreference, st as settleReview, tr as followedRefName, tt as parseWorkspacePort, ui as AGENT_IDS, ur as matchingWorkspaceIds, ut as startJob, v as runningJobs, vr as summarise, vt as telemetryUploadEnabled, yt as BUILD_VERSION } from "./delete_review-B0f6rmy_.js";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as MAX_WORKSPACE_PORT, $n as isClaimed, $r as resolveBase, $t as markReadForReview, A as relativeTime$1, An as isWorktreeHeld, Ar as parseLifecycle, At as partFiles, B as readIndexToken, Bn as reviewedShaOf, Br as issuesOutstanding, Bt as isArchitectureId, C as track, Cn as originRemote, Cr as activityAttributes, Ct as mapRuntimes, D as endRun, Dn as MANAGED_ROOT, Dr as lifecycleItemLabel, Dt as ARCHITECTURE_DIR, E as completeRun, En as ReviewBeingDeletedError, Er as lifecycleChip, Et as describeRuntimes, F as runBatch, Fr as queueStamp, Ft as trackedFiles, Gn as loadJob, Gr as simpleExplanationOf, Gt as createArchitectureNotification, H as clearServerRecordOwnedBy, Hn as endedAt, Hr as reviewerDispositions, Ht as announceJob, I as activateQueued, In as findExistingReview, Ir as readQueue, It as ownersOf, J as stopChild, Jn as isLiveJob, Jr as WITHHELD_REASON, Jt as eventName, K as serverEntryExists, Kn as saveJob, Kr as withSimpleExplanation, Kt as createPrState, L as compactQueue, Ln as checkCodeFreshness, Lr as doneVerb, Lt as QUOTE_LIMITS, M as extractPullRequestLinks, Mn as removeWorktree, Mr as queueAdd, Mt as repositoryKey, N as parsePullRequestLink, Nn as requestJobStop, Nr as queueAddMany, Nt as storeArchitecture, O as reportRun, On as createWorktree, Or as lifecyclePill, Ot as checkArchitecture, P as unsupportedReason, Pr as queueRemove, Pt as storedScopes, Q as DEFAULT_WORKSPACE_PORT, Qr as isCommitReview, Qt as markRead, Rn as checkReview, Rr as isIssueOutstanding, Rt as REPOSITORY_SCOPE, S as isDriving, Sn as remoteHost, Sr as LIFECYCLES, St as waitingHistory, T as beginRun, Tr as isLifecycle, Tt as RUNTIME_WORDS, Un as fail, Ur as processDiscussion, Ut as branchOf, Vn as allJobs, Vr as questionsOutstanding, Vt as writeJsonAtomically, W as readServerRecord, Wn as isTerminal, Wr as recommendationsOf, Wt as subjectOf, Xn as progressSteps, Xr as resolveTarget, Xt as listNotifications, Yn as phasesFor, Yr as kissOutcome, Yt as headline, Z as writeServerRecord, Zn as purposeOf, Zr as DetachedHeadError, Zt as markAllRead, _ as liveUpdateFor, _n as extractJsonText, _t as telemetryUploadConsent, a as wasBlocked, ai as writeAgentPreference, an as withUsageRecorded, ar as kissDecisionRefusal, at as failureOf$1, bn as forgeResolver, br as workspaceRevision, c as RefreshUnavailableError, ci as AGENT_IDS, cn as authorPreview, cr as lineagePosition, ct as reanalyseReview, d as mergeUpdateInto, di as detectAgents, dn as renderMarkdown, dr as mutateWorkspace, ei as git, en as pruneNotifications, er as decideKissRecommendation, et as MIN_WORKSPACE_PORT, f as updateReview, fi as AgentCancelledError, fn as CODE_BLOCK_CLASS, ft as readEvents, g as UpdateAlreadyRunningError, gn as asString, gr as reviewedCommit, gt as recordTelemetryUploadConsent, h as askCheckout, hn as renderInlineText, hr as recentWorkspaces, i as liveJobsFor, ii as readAgentPreference, in as unreadCount$1, ir as indexReviewGroups, it as writePortPreference, j as batchIdentity, jn as readMarker, jr as sectionOf, jt as readArchitecture, k as stopRun, kn as holdWorktree, kr as offersLifecycle, kt as loadArchitecture, l as cancelUpdate, li as agentById, ln as hasAuthorContent, lr as loadWorkspace, lt as runJob, m as isUnchanged, mn as escapeHtml$1, mr as previousKissResult, n as deleteReview, ni as resolveModel, nn as removeNotification, nr as groupByLineage, nt as resolveWorkspacePort, o as discardJob, on as EXPLAIN_SIMPLY_PROMPT, or as kissDecisionsOf, ot as prepareReview, p as refreshPrContext, pi as AgentUnavailableError, pn as CODE_COPY_CLASS, pr as presentedKissRunOf, pt as record, q as spawnServer, qr as describeAuthorship, qt as createPrUpdate, r as lineageIds, ri as clearAgentPreference, rn as toneOf, rr as indexKeptIds, rt as statedWorkspacePort, s as FETCH_TIMEOUT_MS, si as STORE_ROOT, sn as UNESTABLISHED_MOTIVATION, sr as latestKissRun, st as settleReview, t as deleteLineage, ti as resolveAgent, tn as readNotification, tr as followedRefName, tt as parseWorkspacePort, u as cancellableHere, ui as agentFor$1, un as GITHUB_MARKDOWN, vn as objectWithAnyOf, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as activityLabel, wt as recordRuntimeBestEffort, x as startKissJob, xn as forgeFor, xr as workspaceStamps, xt as feedbackUrl, y as agentEnvOf, yn as claimRequestUrl, yr as touchWorkspace, yt as BUILD_VERSION, z as settleQueue, zn as followedBranch, zr as isQuestionOutstanding, zt as ancestorsOf } from "./delete_review-BgIZpcsP.js";
1
+ import { $ as MAX_WORKSPACE_PORT, $n as isClaimed, $r as DetachedHeadError, $t as markReadForReview, A as relativeTime$1, An as isWorktreeHeld, Ar as parseLifecycle, At as partFiles, B as readIndexToken, Bn as reviewedShaOf, Br as issuesOutstanding, Bt as isArchitectureId, C as track, Cn as originRemote, Cr as activityAttributes, Ct as mapRuntimes, D as endRun, Dn as MANAGED_ROOT, Dr as lifecycleItemLabel, Dt as ARCHITECTURE_DIR, E as completeRun, En as ReviewBeingDeletedError, Er as lifecycleChip, Et as describeRuntimes, F as runBatch, Fr as queueStamp, Ft as trackedFiles, Gn as loadJob, Gr as explanationActionOf, Gt as createArchitectureNotification, H as clearServerRecordOwnedBy, Hn as endedAt, Hr as reviewerDispositions, Ht as announceJob, I as activateQueued, In as findExistingReview, Ir as readQueue, It as ownersOf, J as stopChild, Jn as isLiveJob, Jr as withExplanation, Jt as eventName, K as serverEntryExists, Kn as saveJob, Kr as explanationOf, Kt as createPrState, L as compactQueue, Ln as checkCodeFreshness, Lr as doneVerb, Lt as QUOTE_LIMITS, M as extractPullRequestLinks, Mn as removeWorktree, Mr as queueAdd, Mt as repositoryKey, N as parsePullRequestLink, Nn as requestJobStop, Nr as queueAddMany, Nt as storeArchitecture, O as reportRun, On as createWorktree, Or as lifecyclePill, Ot as checkArchitecture, P as unsupportedReason, Pr as queueRemove, Pt as storedScopes, Q as DEFAULT_WORKSPACE_PORT, Qr as resolveTarget, Qt as markRead, Rn as checkReview, Rr as isIssueOutstanding, Rt as REPOSITORY_SCOPE, S as isDriving, Sn as remoteHost, Sr as LIFECYCLES, St as waitingHistory, T as beginRun, Tr as isLifecycle, Tt as RUNTIME_WORDS, Un as fail, Ur as processDiscussion, Ut as branchOf, Vn as allJobs, Vr as questionsOutstanding, Vt as writeJsonAtomically, W as readServerRecord, Wn as isTerminal, Wr as recommendationsOf, Wt as subjectOf, Xn as progressSteps, Xr as WITHHELD_REASON, Xt as listNotifications, Yn as phasesFor, Yr as describeAuthorship, Yt as headline, Z as writeServerRecord, Zn as purposeOf, Zr as kissOutcome, Zt as markAllRead, _ as liveUpdateFor, _n as extractJsonText, _t as telemetryUploadConsent, a as wasBlocked, ai as clearAgentPreference, an as withUsageRecorded, ar as kissDecisionRefusal, at as failureOf$1, bn as forgeResolver, br as workspaceRevision, c as RefreshUnavailableError, cn as authorPreview, cr as lineagePosition, ct as reanalyseReview, d as mergeUpdateInto, di as agentById, dn as renderMarkdown, dr as mutateWorkspace, ei as isCommitReview, en as pruneNotifications, er as decideKissRecommendation, et as MIN_WORKSPACE_PORT, f as updateReview, fi as agentFor$1, fn as CODE_BLOCK_CLASS, ft as readEvents, g as UpdateAlreadyRunningError, gn as asString, gr as reviewedCommit, gt as recordTelemetryUploadConsent, h as askCheckout, hi as AgentUnavailableError, hn as renderInlineText, hr as recentWorkspaces, i as liveJobsFor, ii as resolveModel, in as unreadCount$1, ir as indexReviewGroups, it as writePortPreference, j as batchIdentity, jn as readMarker, jr as sectionOf, jt as readArchitecture, k as stopRun, kn as holdWorktree, kr as offersLifecycle, kt as loadArchitecture, l as cancelUpdate, li as STORE_ROOT, ln as hasAuthorContent, lr as loadWorkspace, lt as runJob, m as isUnchanged, mi as AgentCancelledError, mn as escapeHtml$1, mr as previousKissResult, n as deleteReview, ni as git, nn as removeNotification, nr as groupByLineage, nt as resolveWorkspacePort, o as discardJob, oi as readAgentPreference, on as EXPLAIN_SIMPLY_PROMPT, or as kissDecisionsOf, ot as prepareReview, p as refreshPrContext, pi as detectAgents, pn as CODE_COPY_CLASS, pr as presentedKissRunOf, pt as record, q as spawnServer, qr as isExplanationAction, qt as createPrUpdate, r as lineageIds, ri as resolveAgent, rn as toneOf, rr as indexKeptIds, rt as statedWorkspacePort, s as FETCH_TIMEOUT_MS, si as writeAgentPreference, sn as UNESTABLISHED_MOTIVATION, sr as latestKissRun, st as settleReview, t as deleteLineage, ti as resolveBase, tn as readNotification, tr as followedRefName, tt as parseWorkspacePort, u as cancellableHere, ui as AGENT_IDS, un as GITHUB_MARKDOWN, vn as objectWithAnyOf, w as RUN_KINDS, wn as fillFileUrlTemplate, wr as activityLabel, wt as recordRuntimeBestEffort, x as startKissJob, xn as forgeFor, xr as workspaceStamps, xt as feedbackUrl, y as agentEnvOf, yn as claimRequestUrl, yr as touchWorkspace, yt as BUILD_VERSION, z as settleQueue, zn as followedBranch, zr as isQuestionOutstanding, zt as ancestorsOf } from "./delete_review-B0f6rmy_.js";
2
2
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -1097,25 +1097,22 @@ function renderAskBuddyMenu(kind) {
1097
1097
  </div>`;
1098
1098
  }
1099
1099
  //#endregion
1100
- //#region ../../packages/review-harness/src/domain/ui/components/simple_explanation.ts
1101
- /**
1102
- * A finding's simple explanation: Buddy AI's plain-English account of it, kept on the card because
1103
- * the reviewer asked for one.
1104
- *
1105
- * For reading the finding, not part of it. It carries no copy source, so Copy and Prepare feedback,
1106
- * which take the card's first one, can only ever find the finding's own.
1107
- *
1108
- * A native disclosure, closed when the page loads and open when it has just been written. Whether a
1109
- * reader left it open is not kept: the explanation is the durable part.
1110
- */
1111
- var SIMPLE_EXPLANATION_LABEL = "Simple explanation";
1100
+ //#region ../../packages/review-harness/src/domain/ui/components/finding_explanation.ts
1112
1101
  var ASK_MORE_LABEL = "Ask more →";
1113
- function renderSimpleExplanation(text, options = {}) {
1114
- const body = renderMarkdown(text);
1102
+ var EXPLANATION_LABELS = {
1103
+ "explain-detail": "Explanation · Technical",
1104
+ explain: "Explanation · Simple"
1105
+ };
1106
+ /** The disclosure's label: which kind of explanation the card holds, so the other is one choice away. */
1107
+ function explanationLabel(explanation) {
1108
+ return EXPLANATION_LABELS[explanationActionOf(explanation)];
1109
+ }
1110
+ function renderFindingExplanation(explanation, options = {}) {
1111
+ const body = renderMarkdown(explanation.text);
1115
1112
  const askMore = options.askMore ? `<button type="button" class="pr-helper-simple-explanation-more" data-simple-explanation-more>${ASK_MORE_LABEL}</button>` : "";
1116
1113
  const content = askMore && body.endsWith("</p>") ? `${body.slice(0, -4)} ${askMore}</p>` : body + askMore;
1117
- return `<details class="pr-helper-simple-explanation" data-simple-explanation${options.open ? " open" : ""}>
1118
- <summary class="pr-helper-simple-explanation-summary">${SIMPLE_EXPLANATION_LABEL}</summary>
1114
+ return `<details class="pr-helper-simple-explanation" data-simple-explanation${!Number.isFinite(explanation.writtenAt) ? "" : ` data-explanation-written-at="${explanation.writtenAt}"`}${options.open ? " open" : ""}>
1115
+ <summary class="pr-helper-simple-explanation-summary">${explanationLabel(explanation)}</summary>
1119
1116
  <div class="pr-helper-simple-explanation-body pr-helper-markdown">${content}</div>
1120
1117
  </details>`;
1121
1118
  }
@@ -2608,7 +2605,7 @@ function renderIssuesTab(session, investigatingId, options) {
2608
2605
  </div>
2609
2606
  ${renderStaleDismissal(stale)}
2610
2607
  ${renderSeverityRevision(issue.priorSeverity, issue.severity)}
2611
- <p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(simpleExplanationOf(session, "issue", issue.id)?.text, options)}
2608
+ <p class="pr-helper-issue-desc">${renderInlineText(issue.description)}</p>${renderCardExplanation(explanationOf(session, "issue", issue.id), options)}
2612
2609
  ${renderReconciliationNote(issue.reconciliationNote)}
2613
2610
  ${renderInvestigationNote(issue.investigationNote)}
2614
2611
  ${renderExistingDiscussion(issue.existingDiscussion)}
@@ -2642,12 +2639,12 @@ function closedFindingsSummary(counts) {
2642
2639
  return `Dealt with (${counts.done})`;
2643
2640
  }
2644
2641
  /**
2645
- * The finding's simple explanation, under what the finding says and above where it stands. Nothing
2646
- * at all when the reviewer has not asked for one, so such a card is the card it always was.
2642
+ * The finding's explanation, under what the finding says and above where it stands. Nothing at all
2643
+ * when the reviewer has not asked for one, so such a card is the card it always was.
2647
2644
  */
2648
- function renderCardExplanation(text, options) {
2649
- if (!text?.trim()) return "";
2650
- return renderSimpleExplanation(text, { askMore: options?.askBuddy });
2645
+ function renderCardExplanation(explanation, options) {
2646
+ if (!explanation?.text.trim()) return "";
2647
+ return renderFindingExplanation(explanation, { askMore: options?.askBuddy });
2651
2648
  }
2652
2649
  /** The reviewer's own decision, said on the card so the closed pile is never one undifferentiated heap. */
2653
2650
  function renderDismissedBadge(isDismissed) {
@@ -2787,7 +2784,7 @@ function renderQuestionsTab(session, investigatingId, options) {
2787
2784
  const progress = renderQuestionsProgress(result.questions, answered);
2788
2785
  const records = session.investigations;
2789
2786
  const now = Date.now();
2790
- const card = (q) => renderQuestionCard(q, sections, answered.has(q.id), records, now, investigatingId, options, simpleExplanationOf(session, "question", q.id)?.text);
2787
+ const card = (q) => renderQuestionCard(q, sections, answered.has(q.id), records, now, investigatingId, options, explanationOf(session, "question", q.id));
2791
2788
  const open = result.questions.filter((q) => isQuestionOutstanding(q, answered));
2792
2789
  const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered));
2793
2790
  const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0);
@@ -5435,20 +5432,6 @@ function conversationNote(report, actions) {
5435
5432
  * not exist in any form until the agent has finished reasoning, and drawing their outlines would
5436
5433
  * promise a shape nobody has produced.
5437
5434
  */
5438
- /**
5439
- * How long this has been going, to the second.
5440
- *
5441
- * Seconds rather than the strip's rounded minutes, and that is the whole point of a separate
5442
- * helper: "Running for under a minute" does not change for a full minute, so the one number on
5443
- * screen that could show the run is alive sits perfectly still during the wait most likely to be
5444
- * mistaken for a hang. Past a minute the seconds stop earning their precision and it rounds.
5445
- */
5446
- function elapsedWords(startedAt, now = Date.now()) {
5447
- const seconds = Math.max(0, Math.round((now - startedAt) / 1e3));
5448
- if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
5449
- const minutes = Math.round(seconds / 60);
5450
- return `${minutes} minute${minutes === 1 ? "" : "s"}`;
5451
- }
5452
5435
  /** Whether the change itself is known yet, which is what Files needs and nothing else does. */
5453
5436
  function changeIsRead(job) {
5454
5437
  return job.completed.includes("context");
@@ -5510,9 +5493,15 @@ function renderProgressView(job, fileCount, cancellable = false, history) {
5510
5493
  ${renderElapsed(job, cancellable ? "Cancel review" : null, history)}`;
5511
5494
  }
5512
5495
  /**
5513
- * How long it has run, with Cancel beside it when something can act on one.
5496
+ * The run's footer: how long it has run and how long earlier ones took, with Cancel beside them
5497
+ * when something can act on one. One block for a review and an update alike; only the button and
5498
+ * the history's words are the operation's own.
5499
+ *
5500
+ * Running time is the primary line, to the second (`48s`, `1m 08s`), so the one number on screen
5501
+ * that shows the run is alive keeps moving through the whole wait. History is secondary, under it,
5502
+ * and outside it, because the client rewrites that line's text every second. Cancel sits just
5503
+ * after the two rather than at the far edge: an action for this run, not a page control.
5514
5504
  *
5515
- * Beside the line rather than inside it, because the client rewrites that line's text every second.
5516
5505
  * Offered only while a driver holds the job: with nothing running there is nothing to stop, and a
5517
5506
  * button that could not do what it says would be worse than none. Secondary on purpose: it is a way
5518
5507
  * out, not the point. A review has nothing to fall back on, so a cancelled one stops where it was
@@ -5521,16 +5510,34 @@ function renderProgressView(job, fileCount, cancellable = false, history) {
5521
5510
  function renderElapsed(job, cancel, history) {
5522
5511
  const button = !cancel ? "" : cancel === "Cancel review" ? `<button type="button" class="prb-progress-cancel" data-review-cancel="${escapeHtml$1(job.id)}">Cancel review</button>` : `<button type="button" class="prb-progress-cancel" data-update-cancel="${escapeHtml$1(job.id)}">Cancel update</button>`;
5523
5512
  return `<div class="prb-progress-foot">
5524
- <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">Running for ${escapeHtml$1(elapsedWords(job.startedAt))}.</p>
5513
+ <div class="prb-progress-runtime">
5514
+ <p class="prb-progress-elapsed" data-started-at="${job.startedAt}">${escapeHtml$1(runningFor(job.startedAt))}</p>${renderHistory$1(history)}
5515
+ </div>
5525
5516
  ${button}
5526
- </div>${renderHistory$1(history)}`;
5517
+ </div>`;
5518
+ }
5519
+ /** The running time as the footer and the client's clock both say it. */
5520
+ function runningFor(startedAt, now = Date.now()) {
5521
+ return `Running for ${clockWords(now - startedAt)}`;
5527
5522
  }
5528
5523
  /**
5529
- * How long this took before, under the running time and outside it, because the client rewrites
5530
- * that line every second. Nothing when no earlier run succeeded.
5524
+ * To the second at every length: `48s`, `1m 08s`, `1h 00m 01s`. Not `formatDuration`, which drops
5525
+ * the seconds past an hour because a finished run's length does not need them; a live count does,
5526
+ * or it stands still for a minute at a time on exactly the runs most likely to look hung.
5527
+ */
5528
+ function clockWords(ms) {
5529
+ const seconds = Math.max(0, Math.round(ms / 1e3));
5530
+ const pad = (n) => String(n).padStart(2, "0");
5531
+ if (seconds < 60) return `${seconds}s`;
5532
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`;
5533
+ return `${Math.floor(seconds / 3600)}h ${pad(Math.floor(seconds / 60) % 60)}m ${pad(seconds % 60)}s`;
5534
+ }
5535
+ /**
5536
+ * How long this took before: a status line, so no full stop. Nothing when no earlier run
5537
+ * succeeded.
5531
5538
  */
5532
5539
  function renderHistory$1(history) {
5533
- return history ? `<p class="prb-progress-history" data-runtime-history>${escapeHtml$1(history)}.</p>` : "";
5540
+ return history ? `<p class="prb-progress-history" data-runtime-history>${escapeHtml$1(history)}</p>` : "";
5534
5541
  }
5535
5542
  /**
5536
5543
  * An update running, in place of the review it is updating.
@@ -11373,7 +11380,7 @@ function buildPrompt$2(workspace, question, sectionId, cwd, context = {}) {
11373
11380
  ].join("\n");
11374
11381
  }
11375
11382
  /**
11376
- * The simple explanation a follow-up was asked from, however long ago it was written.
11383
+ * The kept explanation a follow-up was asked from, however long ago it was written.
11377
11384
  *
11378
11385
  * Given in full rather than left to the recent turns above, which hold only the last four: a
11379
11386
  * reviewer who comes back a week later and presses Ask more is continuing from the explanation on
@@ -11383,22 +11390,24 @@ function continuingFrom(workspace, continuing) {
11383
11390
  if (!continuing) return [];
11384
11391
  return [
11385
11392
  "---",
11386
- `THE REVIEWER IS CONTINUING FROM THIS PLAIN-ENGLISH EXPLANATION YOU GAVE EARLIER OF ${findingName(workspace, continuing)}:`,
11393
+ `THE REVIEWER IS CONTINUING FROM THIS ${explanationActionOf(continuing) === "explain" ? "PLAIN-ENGLISH" : "TECHNICAL"} EXPLANATION YOU GAVE EARLIER OF ${findingName(workspace, continuing)}:`,
11387
11394
  continuing.text
11388
11395
  ];
11389
11396
  }
11390
11397
  /**
11391
- * Explain simply on a finding, whose answer is kept on the card and read again after the code has
11392
- * moved on. So it explains the claim, what the finding says and why it was raised, rather than
11393
- * asserting what the code does now: a later update or investigation says whether the claim still
11394
- * holds, and an explanation written as a present-tense fact about the code would contradict it.
11398
+ * Explain (technical) or Explain (simple) on a finding, whose answer is kept on the card and read
11399
+ * again after the code has moved on. So either explains the claim, what the finding says and why it
11400
+ * was raised, rather than asserting what the code does now: a later update or investigation says
11401
+ * whether the claim still holds, and an explanation written as a present-tense fact about the code
11402
+ * would contradict it. They differ only in register, as the visible instruction asks.
11395
11403
  */
11396
11404
  function explainingFinding(workspace, explaining) {
11397
11405
  if (!explaining) return [];
11406
+ const simple = explaining.action === "explain";
11398
11407
  return [
11399
11408
  "---",
11400
- `THE REVIEWER ASKED FOR A SIMPLE EXPLANATION OF ${findingName(workspace, explaining)}. It is kept beside that finding and read again later, possibly after the code has changed.`,
11401
- "Explain in plain English what the finding is saying and why it was raised: the concern as the review stated it, in terms of what was reviewed (for example \"this was raised because...\"), not as a statement about how the code behaves now. Do not judge whether it still holds; the review tracks that separately."
11409
+ `THE REVIEWER ASKED FOR ${simple ? "A SIMPLE" : "A TECHNICAL"} EXPLANATION OF ${findingName(workspace, explaining)}. It is kept beside that finding and read again later, possibly after the code has changed.`,
11410
+ `Explain ${simple ? "in plain English" : "with the technical detail kept"} what the finding is saying and why it was raised: the concern as the review stated it, in terms of what was reviewed (for example "this was raised because..."), not as a statement about how the code behaves now. Do not judge whether it still holds; the review tracks that separately.`
11402
11411
  ];
11403
11412
  }
11404
11413
  /** `THE ISSUE "<title>"` or `THE QUESTION "<question>"`, or just the kind when the finding is gone. */
@@ -13221,11 +13230,13 @@ var MAX_KEPT_BRANCHES = 50;
13221
13230
  *
13222
13231
  * Only once the analysis is complete. The page is served while a review is still being made, and
13223
13232
  * recording that would file a row of zeroes — no risk, no themes, no findings — against a review
13224
- * that has them, which is worse than recording nothing.
13233
+ * that has them, which is worse than recording nothing. `result` is that signal: a running or idle
13234
+ * session carries none. `completedAt` is not: it is the Reviewed status, and gating on it kept this
13235
+ * event off every first read.
13225
13236
  */
13226
13237
  function workspaceOpenedEvent(workspace) {
13227
13238
  const result = workspace.session.result;
13228
- if (workspace.completedAt === void 0 || !result) return null;
13239
+ if (!result) return null;
13229
13240
  const severities = {
13230
13241
  critical: 0,
13231
13242
  major: 0,
@@ -13694,9 +13705,8 @@ async function handleAsk(req, res, workspace) {
13694
13705
  const question = typeof body?.question === "string" ? body.question.trim() : "";
13695
13706
  if (!question) return send(res, 400, { error: "No question given." });
13696
13707
  const sectionId = typeof body?.sectionId === "string" ? body.sectionId : null;
13697
- const explains = findingNamed(workspace.session, body?.explains);
13698
- const continuesFrom = findingNamed(workspace.session, body?.continuesExplanation);
13699
- const continuing = continuesFrom ? simpleExplanationOf(workspace.session, continuesFrom.kind, continuesFrom.id) : void 0;
13708
+ const explains = explanationRequested(workspace.session, body?.explains);
13709
+ const continuing = explanationContinued(workspace, body?.continuesExplanation);
13700
13710
  if (liveUpdateFor(workspace.id)) return send(res, 409, {
13701
13711
  error: "This review is being updated. Ask again when the new review is ready.",
13702
13712
  updating: true
@@ -13749,18 +13759,22 @@ async function handleAsk(req, res, workspace) {
13749
13759
  });
13750
13760
  let explanation;
13751
13761
  mutateWorkspace(workspace.id, (fresh) => {
13762
+ const askedAt = Date.now();
13763
+ const kept = explains && findingNamed(fresh.session, explains) && answer.trim() ? explains : null;
13752
13764
  fresh.transcript.push({
13753
13765
  question,
13754
13766
  answer,
13755
13767
  sectionId,
13756
- askedAt: Date.now()
13768
+ askedAt,
13769
+ ...kept ? { explains: kept } : {}
13757
13770
  });
13758
- if (explains && findingNamed(fresh.session, explains) && answer.trim()) {
13771
+ if (kept) {
13759
13772
  explanation = {
13760
- ...explains,
13761
- text: answer.trim()
13773
+ ...kept,
13774
+ text: answer.trim(),
13775
+ writtenAt: askedAt
13762
13776
  };
13763
- fresh.session.simpleExplanations = withSimpleExplanation(fresh.session.simpleExplanations, explanation);
13777
+ fresh.session.findingExplanations = withExplanation(fresh.session.findingExplanations, explanation);
13764
13778
  }
13765
13779
  });
13766
13780
  sendIfOpen(res, 200, explanation ? {
@@ -13771,6 +13785,45 @@ async function handleAsk(req, res, workspace) {
13771
13785
  endRun(run);
13772
13786
  }
13773
13787
  }
13788
+ /**
13789
+ * The explanation a follow-up was staged from, named by its finding and `writtenAt`.
13790
+ *
13791
+ * The card's current one when it is still that explanation. When another has replaced it since, the
13792
+ * turn that wrote it, which the transcript keeps, so a question written against one explanation is
13793
+ * never sent with another's words. Nothing when neither is there. A follow-up without `writtenAt`
13794
+ * (an explanation kept before 0.72.0, or a page from then) continues from the card's current one.
13795
+ */
13796
+ function explanationContinued(workspace, value) {
13797
+ const finding = findingNamed(workspace.session, value);
13798
+ if (!finding) return void 0;
13799
+ const current = explanationOf(workspace.session, finding.kind, finding.id);
13800
+ const writtenAt = value.writtenAt;
13801
+ if (writtenAt === void 0 || current?.writtenAt === writtenAt) return current;
13802
+ const turn = workspace.transcript.find((exchange) => exchange.askedAt === writtenAt && exchange.explains?.kind === finding.kind && exchange.explains.id === finding.id);
13803
+ return turn?.explains && turn.answer.trim() ? {
13804
+ ...finding,
13805
+ action: turn.explains.action,
13806
+ text: turn.answer.trim(),
13807
+ writtenAt: turn.askedAt
13808
+ } : void 0;
13809
+ }
13810
+ /**
13811
+ * The finding a turn explains and the action explaining it, or null when its answer is not to be kept.
13812
+ *
13813
+ * A missing action is Explain (simple): the only one a page sent before there were two, and a tab
13814
+ * opened then still sends it that way. An action that is named but not an explanation is not
13815
+ * guessed at; the turn is answered like any other and nothing is kept.
13816
+ */
13817
+ function explanationRequested(session, value) {
13818
+ const finding = findingNamed(session, value);
13819
+ if (!finding) return null;
13820
+ const named = value.action;
13821
+ const action = named === void 0 ? "explain" : named;
13822
+ return isExplanationAction(action) ? {
13823
+ ...finding,
13824
+ action
13825
+ } : null;
13826
+ }
13774
13827
  /** An issue or question of this review, named by kind and id; null for anything else. */
13775
13828
  function findingNamed(session, value) {
13776
13829
  const named = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prreviewbuddy",
3
- "version": "0.71.2",
3
+ "version": "0.73.1",
4
4
  "description": "Understand code changes before you approve them. Local branch and pull request reviews with Claude Code, Codex or Gemini CLI.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://prreviewbuddy.com",