prreviewbuddy 0.55.1 → 0.55.6

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,79 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.55.6
4
+
5
+ **File paths are shown whole.** A deep directory above a file name was cut short with an ellipsis,
6
+ and the full path appeared only in a browser tooltip on hover. The directory now wraps onto a
7
+ second line where it needs one, so the whole path is always on screen, and the tooltips are gone.
8
+ This covers file locations on issues and questions, related files, the Guide's file rows, the Files
9
+ tab and KISS locations, in the workspace and the extension. The extension's file list, which had no
10
+ tooltip, no longer hides the end of a long directory either. (Extension 0.25.4.)
11
+
12
+ ## 0.55.5
13
+
14
+ **Why this change? says what is known, plainly.** Each state now has a label anyone can read and a
15
+ colour that says how sure it is: **Author's reason** (green), the author's own words in the PR
16
+ description; **Possible reason** (gold), "The PR description doesn't give a clear reason. Based on
17
+ the change itself, this appears to…"; and **Reason unclear** (grey), "It isn't clear why this
18
+ change is needed.", with **Ask the author** and the question to ask. Colour is only a thin rule
19
+ beside the reason and the label's own colour; the text stays dark, and the label says it all
20
+ without colour. Once that question is marked answered the section reads **Answer available**: "This
21
+ question has been marked answered. Run the review again after adding the answer to the PR
22
+ description."
23
+
24
+ **A refused reason is never shown as nothing.** When the analysis says the author gave a reason but
25
+ the words it quotes can't be confirmed in the PR description, the section used to show nothing at
26
+ all. It now shows Reason unclear, asking the analysis's own question when it gave one, or "What
27
+ problem does this change solve, or what goal does it serve?". That question has the same id on
28
+ every analysis, so marking it answered survives a rerun, even when the analysis happens to ask it
29
+ in the same words. The refused wording is still never shown, and the evidence the refused reason
30
+ came with is not presented as a reason.
31
+
32
+ **What the harness corrected moves into Analysis details.** The amber box above the review, which
33
+ showed on every tab, is gone; its notes are now listed under Analysis details on the Overview. The
34
+ note for a refused reason says only what the check found, since a quote also fails when it is two
35
+ words or is the template's text. The refused quote (cut at 500 characters) and its evidence are
36
+ kept in the review's audit record, or the extension's dev trace.
37
+
38
+ The prompt asks for the author's words exactly as written, backticks and punctuation included, and
39
+ treats a reason found only in commits, review comments or a linked issue as inferred. Under
40
+ Possible reason, the kind of each piece of evidence ("Test", "Code") is now the size of the text
41
+ beside it. (Extension 0.25.3.)
42
+
43
+ ## 0.55.4
44
+
45
+ **Empty pages use the full width.** On the PR context page, the note saying no pull request
46
+ discussion was brought in, and on Diagrams, the card offering to draw a change map or an
47
+ implementation diagram, were held to a narrow column. They now fill the page's width like the
48
+ rest of the workspace.
49
+
50
+ ## 0.55.3
51
+
52
+ **Template comments stay hidden.** HTML comments in a PR description, such as a template's
53
+ `<!-- Describe your change -->`, showed as literal text in Author's context, in both the full view
54
+ and the preview. They are now hidden, as on GitHub. The same goes for comments in review comments
55
+ on the PR context page. A comment inside a code block or written as inline code is still shown,
56
+ since there it is code, and a code fence inside a comment no longer turns the rest of the
57
+ description into code. The preview never shows part of a comment. Model output is unchanged:
58
+ nothing it writes is hidden.
59
+
60
+ ## 0.55.2
61
+
62
+ **A lighter Overview.** Author's context, Why this change? and What's changing no longer read as
63
+ one block:
64
+
65
+ - **Author's context** opens on a short preview of the first thing the author wrote, past any
66
+ template heading such as "Summary": a paragraph's first two sentences (or about 400 characters,
67
+ never cut mid-word), a list's first three items, or "Code block included" for a long code block.
68
+ **View full PR description** shows the whole description, as written. A description that fits
69
+ the preview is shown whole, with no button.
70
+ - **Why this change?** shows the label and the motive. The author's quote behind a stated motive
71
+ is under **Author's words**, one click away, since the motive usually says the same thing.
72
+ - **What's changing** is asked for in about 80 to 120 words, and says what the change does without
73
+ repeating why it exists or judging it. Analyses made before this version keep their summary.
74
+ - More space between the three sections, and more again before the risk verdict and the
75
+ suggested review path.
76
+
3
77
  ## 0.55.1
4
78
 
5
79
  **Load more on the Notifications page.** The list shows the newest fifteen, and Load more reveals
@@ -9395,13 +9395,252 @@ function normalizeSections(value) {
9395
9395
  return sections;
9396
9396
  }
9397
9397
  //#endregion
9398
+ //#region ../../packages/review-harness/src/domain/utils/html.ts
9399
+ function escapeHtml(value) {
9400
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9401
+ }
9402
+ function renderInlineText(value) {
9403
+ return escapeHtml(value).replace(/`([^`\n]+)`/g, "<code>$1</code>");
9404
+ }
9405
+ //#endregion
9406
+ //#region ../../packages/review-harness/src/domain/utils/code_block.ts
9407
+ var CODE_BLOCK_CLASS = "pr-helper-code-block";
9408
+ var CODE_COPY_CLASS = "pr-helper-code-copy";
9409
+ //#endregion
9410
+ //#region ../../packages/review-harness/src/domain/utils/markdown.ts
9411
+ var BLOCK_MARKER = "\0";
9412
+ var INLINE_MARKER = "";
9413
+ var BLOCK_TOKEN = /\u0000(\d+)\u0000/g;
9414
+ var BLOCK_TOKEN_LINE = /^\u0000\d+\u0000$/;
9415
+ var INLINE_TOKEN = /\u0001(\d+)\u0001/g;
9416
+ var SAFE_HREF = /^(?:https?:\/\/|#)/i;
9417
+ function renderMarkdown(value, options = {}) {
9418
+ const { text, blocks } = extractFencedBlocks(scanMarkdownLines(value.replace(/[\u0000\u0001]/g, ""), options));
9419
+ return restoreFencedBlocks(renderBlocks(text), blocks);
9420
+ }
9421
+ /**
9422
+ * The fence character a line opens a code block with, or null when it opens none. Shared with the
9423
+ * Author's context preview, which must find the same code blocks this renderer does.
9424
+ */
9425
+ function fenceOpening(line) {
9426
+ return line.match(/^\s*(`{3,}|~{3,})\s*[\w+#.-]*\s*$/)?.[1][0] ?? null;
9427
+ }
9428
+ /** Whether a line closes a code block opened with `fence`: only a fence of the same character does. */
9429
+ function closesFence(line, fence) {
9430
+ return new RegExp(`^\\s*${fence}{3,}\\s*$`).test(line);
9431
+ }
9432
+ var COMMENT_START_OR_CODE_SPAN = /`[^`\n]+`|<!--/g;
9433
+ function commentStart(line, from) {
9434
+ COMMENT_START_OR_CODE_SPAN.lastIndex = from;
9435
+ for (let match; match = COMMENT_START_OR_CODE_SPAN.exec(line);) if (match[0] === "<!--") return match.index;
9436
+ return -1;
9437
+ }
9438
+ /**
9439
+ * Each line's part in a code block and, when comments are hidden, what of it is outside one.
9440
+ *
9441
+ * Code blocks and comments are read in one pass, in the order they open, as GitHub reads them: a
9442
+ * comment inside a code block is code, and a fence inside a comment is not a fence. The renderer and
9443
+ * the Author's context preview both read Markdown through this, so they agree on what is code and
9444
+ * what is hidden. A comment never closed is not a comment: its text stays, rather than hiding the
9445
+ * rest of what was said.
9446
+ */
9447
+ function scanMarkdownLines(value, options = {}) {
9448
+ const result = [];
9449
+ let fence = null;
9450
+ let comment = false;
9451
+ let opener = null;
9452
+ let offset = 0;
9453
+ for (const raw of value.split("\n")) {
9454
+ const lineStart = offset;
9455
+ offset += raw.length + 1;
9456
+ if (fence) {
9457
+ const close = closesFence(raw, fence);
9458
+ if (close) fence = null;
9459
+ result.push({
9460
+ raw,
9461
+ visible: raw,
9462
+ code: close ? "close" : "body"
9463
+ });
9464
+ continue;
9465
+ }
9466
+ if (!comment) {
9467
+ fence = fenceOpening(raw);
9468
+ if (fence) {
9469
+ result.push({
9470
+ raw,
9471
+ visible: raw,
9472
+ code: "open"
9473
+ });
9474
+ continue;
9475
+ }
9476
+ }
9477
+ if (!options.hideHtmlComments) {
9478
+ result.push({
9479
+ raw,
9480
+ visible: raw
9481
+ });
9482
+ continue;
9483
+ }
9484
+ const joining = comment ? opener : null;
9485
+ let visible = "";
9486
+ let pos = 0;
9487
+ while (pos < raw.length) {
9488
+ if (comment) {
9489
+ const end = raw.indexOf("-->", pos);
9490
+ if (end === -1) break;
9491
+ pos = end + 3;
9492
+ comment = false;
9493
+ continue;
9494
+ }
9495
+ const start = commentStart(raw, pos);
9496
+ if (start === -1 || value.indexOf("-->", lineStart + start + 4) === -1) {
9497
+ visible += raw.slice(pos);
9498
+ break;
9499
+ }
9500
+ visible += raw.slice(pos, start);
9501
+ pos = start + 4;
9502
+ comment = true;
9503
+ }
9504
+ if (joining) {
9505
+ joining.visible += visible;
9506
+ result.push({
9507
+ raw,
9508
+ visible: "",
9509
+ absent: true
9510
+ });
9511
+ continue;
9512
+ }
9513
+ const line = {
9514
+ raw,
9515
+ visible
9516
+ };
9517
+ if (comment) opener = visible.trim() === "" ? null : line;
9518
+ result.push(line);
9519
+ }
9520
+ return result;
9521
+ }
9522
+ function extractFencedBlocks(lines) {
9523
+ const blocks = [];
9524
+ const out = [];
9525
+ let body = null;
9526
+ for (const line of lines) {
9527
+ if (line.absent) continue;
9528
+ if (line.code === "open") body = [];
9529
+ else if (line.code === "body") body.push(line.raw);
9530
+ else if (line.code === "close") {
9531
+ out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
9532
+ blocks.push(body.join("\n"));
9533
+ body = null;
9534
+ } else out.push(line.visible);
9535
+ }
9536
+ if (body) {
9537
+ out.push(`${BLOCK_MARKER}${blocks.length}${BLOCK_MARKER}`);
9538
+ blocks.push(body.join("\n"));
9539
+ }
9540
+ return {
9541
+ text: out.join("\n"),
9542
+ blocks
9543
+ };
9544
+ }
9545
+ function restoreFencedBlocks(html, blocks) {
9546
+ return html.replace(BLOCK_TOKEN, (_match, index) => `<div class="${CODE_BLOCK_CLASS}"><pre><code>${escapeHtml(blocks[Number(index)])}</code></pre><button type="button" class="${CODE_COPY_CLASS}" aria-label="Copy code">Copy</button></div>`);
9547
+ }
9548
+ function renderBlocks(text) {
9549
+ const out = [];
9550
+ let paragraph = [];
9551
+ let quote = [];
9552
+ let list = null;
9553
+ const flush = () => {
9554
+ if (paragraph.length) out.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
9555
+ if (quote.length) out.push(`<blockquote>${renderInline(quote.join(" "))}</blockquote>`);
9556
+ if (list) out.push(`<${list.tag}>${list.items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</${list.tag}>`);
9557
+ paragraph = [];
9558
+ quote = [];
9559
+ list = null;
9560
+ };
9561
+ for (const raw of text.split("\n")) {
9562
+ const line = raw.trim();
9563
+ if (!line) {
9564
+ flush();
9565
+ continue;
9566
+ }
9567
+ if (BLOCK_TOKEN_LINE.test(line)) {
9568
+ flush();
9569
+ out.push(line);
9570
+ continue;
9571
+ }
9572
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
9573
+ flush();
9574
+ out.push("<hr>");
9575
+ continue;
9576
+ }
9577
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
9578
+ if (heading) {
9579
+ flush();
9580
+ const level = Math.min(heading[1].length, 3) + 3;
9581
+ out.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
9582
+ continue;
9583
+ }
9584
+ const bullet = line.match(/^[-*+]\s+(.+)$/);
9585
+ if (bullet) {
9586
+ if (list?.tag !== "ul") {
9587
+ flush();
9588
+ list = {
9589
+ tag: "ul",
9590
+ items: []
9591
+ };
9592
+ }
9593
+ list.items.push(bullet[1]);
9594
+ continue;
9595
+ }
9596
+ const ordered = line.match(/^\d{1,9}[.)]\s+(.+)$/);
9597
+ if (ordered) {
9598
+ if (list?.tag !== "ol") {
9599
+ flush();
9600
+ list = {
9601
+ tag: "ol",
9602
+ items: []
9603
+ };
9604
+ }
9605
+ list.items.push(ordered[1]);
9606
+ continue;
9607
+ }
9608
+ const quoted = line.match(/^>\s?(.*)$/);
9609
+ if (quoted) {
9610
+ if (!quote.length) flush();
9611
+ quote.push(quoted[1]);
9612
+ continue;
9613
+ }
9614
+ if (list && list.items.length) {
9615
+ list.items[list.items.length - 1] += ` ${line}`;
9616
+ continue;
9617
+ }
9618
+ if (quote.length) {
9619
+ quote[quote.length - 1] += ` ${line}`;
9620
+ continue;
9621
+ }
9622
+ paragraph.push(line);
9623
+ }
9624
+ flush();
9625
+ return out.join("");
9626
+ }
9627
+ function renderInline(value) {
9628
+ const codeSpans = [];
9629
+ return escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, code) => {
9630
+ codeSpans.push(code);
9631
+ return `${INLINE_MARKER}${codeSpans.length - 1}${INLINE_MARKER}`;
9632
+ }).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
9633
+ }
9634
+ //#endregion
9398
9635
  //#region ../../packages/review-harness/src/domain/ai_review/author_description.ts
9399
9636
  /**
9400
9637
  * Reading a pull request's description for whether it says anything, without ever changing it.
9401
9638
  *
9402
- * Nothing here returns a copy to store or show. The description is shown verbatim, so a cleaned
9403
- * copy is only ever a means of asking a question of it: whether it says anything, and whether a
9404
- * quote is something the author said.
9639
+ * Nothing here returns a copy to store, or a reworded one to show. The description is shown
9640
+ * verbatim, so a cleaned copy is only ever a means of asking a question of it: whether it says
9641
+ * anything, whether a quote is something the author said, and where its preview ends. The preview
9642
+ * itself is the author's own lines, cut short, never edited, less the HTML comments the full view
9643
+ * hides too.
9405
9644
  */
9406
9645
  /**
9407
9646
  * Lines that are a pull request template's scaffolding when they stand alone.
@@ -9435,7 +9674,6 @@ var TEMPLATE_LABELS = /* @__PURE__ */ new Set([
9435
9674
  "related issue",
9436
9675
  "links"
9437
9676
  ]);
9438
- var HTML_COMMENT = /<!--[\s\S]*?-->/g;
9439
9677
  var HEADING_MARKER = /^#{1,6}\s*/;
9440
9678
  var UNCHECKED_TASK = /^[-*+]\s+\[ \]/;
9441
9679
  var EMPTY_LIST_ITEM = /^(?:[-*+]|\d+[.)])\s*$/;
@@ -9457,7 +9695,7 @@ function substance(line) {
9457
9695
  */
9458
9696
  function authorText(description) {
9459
9697
  if (!description) return "";
9460
- return description.replace(HTML_COMMENT, "").split("\n").map(substance).filter((line) => line !== "").join("\n");
9698
+ return visibleLines(description).map((line) => substance(line.visible)).filter((line) => line !== "").join("\n");
9461
9699
  }
9462
9700
  /**
9463
9701
  * Whether a description has anything in it beyond an unfilled template.
@@ -9468,9 +9706,138 @@ function authorText(description) {
9468
9706
  function hasAuthorContent(description) {
9469
9707
  return authorText(description) !== "";
9470
9708
  }
9471
- function isLongDescription(description) {
9472
- const trimmed = description.trim();
9473
- return trimmed.split("\n").length > 10 || trimmed.length > 700;
9709
+ var LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/;
9710
+ var ATX_HEADING = /^#{1,6}(?:\s|$)/;
9711
+ /**
9712
+ * The description as the full view reads it, by the renderer's own scan, so the two always agree
9713
+ * on what is code and what is a hidden comment.
9714
+ */
9715
+ function visibleLines(description) {
9716
+ return scanMarkdownLines(description, { hideHtmlComments: true }).filter((line) => !line.absent);
9717
+ }
9718
+ /**
9719
+ * The description's Markdown blocks, with a code block kept whole and always a block of its own.
9720
+ *
9721
+ * Split where Markdown itself would end one: a blank line, a heading (always a block of its own)
9722
+ * and a list that starts straight after a paragraph. Descriptions are often written with no blank
9723
+ * lines at all, and on blank lines alone those would be one block. A line that is only a comment
9724
+ * ends a block like a blank one.
9725
+ */
9726
+ function blocks(description) {
9727
+ const result = [];
9728
+ let current = [];
9729
+ const end = () => {
9730
+ if (current.length) result.push(current);
9731
+ current = [];
9732
+ };
9733
+ for (const line of visibleLines(description)) if (line.code === "open") {
9734
+ end();
9735
+ current.push(line);
9736
+ } else if (line.code === "body") current.push(line);
9737
+ else if (line.code === "close") {
9738
+ current.push(line);
9739
+ end();
9740
+ } else if (line.visible.trim() === "") end();
9741
+ else if (ATX_HEADING.test(line.visible.trim())) {
9742
+ end();
9743
+ result.push([line]);
9744
+ } else if (LIST_ITEM.test(line.visible) && current.length && !LIST_ITEM.test(current[0].visible)) {
9745
+ end();
9746
+ current.push(line);
9747
+ } else current.push(line);
9748
+ end();
9749
+ return result;
9750
+ }
9751
+ var ELLIPSIS = "…";
9752
+ /**
9753
+ * Whether cutting a paragraph after `text` leaves valid Markdown: no code span, link or bold left
9754
+ * open. Approximate by design, and erring towards an earlier cut or none.
9755
+ */
9756
+ function closesCleanly(text) {
9757
+ if ((text.match(/`/g) ?? []).length % 2) return false;
9758
+ if ((text.match(/\*\*/g) ?? []).length % 2) return false;
9759
+ if ((text.replace(/\*\*/g, "").match(/\*/g) ?? []).length % 2) return false;
9760
+ if ((text.match(/__/g) ?? []).length % 2) return false;
9761
+ if ((text.match(/\[/g) ?? []).length !== (text.match(/\]/g) ?? []).length) return false;
9762
+ if ((text.match(/\]\(/g) ?? []).length !== (text.match(/\]\([^)]*\)/g) ?? []).length) return false;
9763
+ return true;
9764
+ }
9765
+ /**
9766
+ * A paragraph cut to its first sentences, or to a word boundary near the character limit, at a
9767
+ * point that leaves no Markdown open. Null when it fits whole.
9768
+ */
9769
+ function boundParagraph(paragraph) {
9770
+ const text = paragraph.trimEnd();
9771
+ const cuts = [];
9772
+ for (const match of text.matchAll(/\s+/g)) {
9773
+ const at = match.index;
9774
+ if (!closesCleanly(text.slice(0, at))) continue;
9775
+ cuts.push({
9776
+ at,
9777
+ sentence: /[.!?]["')\]]?$/.test(text.slice(0, at))
9778
+ });
9779
+ }
9780
+ const bySentence = cuts.filter((cut) => cut.sentence)[1];
9781
+ if (text.length <= 400 && !bySentence) return null;
9782
+ if (bySentence && bySentence.at <= 400) return `${text.slice(0, bySentence.at)} ${ELLIPSIS}`;
9783
+ if (text.length <= 400) return null;
9784
+ const within = cuts.filter((cut) => cut.at <= 400).pop() ?? cuts.find((cut) => cut.at > 400);
9785
+ return within ? `${text.slice(0, within.at)} ${ELLIPSIS}` : null;
9786
+ }
9787
+ /** A list cut to its first items, each whole with any lines that continue it. Null when it fits. */
9788
+ function boundList(lines) {
9789
+ const indent = (line) => line.length - line.trimStart().length;
9790
+ const base = indent(lines[0]);
9791
+ const starts = lines.flatMap((line, i) => LIST_ITEM.test(line) && indent(line) <= base ? [i] : []);
9792
+ if (starts.length <= 3) return null;
9793
+ return `${lines.slice(0, starts[3]).join("\n")}\n\n${ELLIPSIS}`;
9794
+ }
9795
+ /**
9796
+ * What the Overview shows of a description before the reader asks for all of it.
9797
+ *
9798
+ * The first block the author wrote decides what to show; the bounds decide how much of it. A
9799
+ * template's labels, comments and unticked items ahead of it are passed over, so "## Summary" opens
9800
+ * on the summary itself. A heading of the author's own is kept, with the block it introduces.
9801
+ * A paragraph is cut after its first sentences or near a character limit, a list after its first
9802
+ * items, and a long code block is left out; nothing is cut mid-word or left as open Markdown.
9803
+ *
9804
+ * Null when the whole description fits: there is nothing more to open.
9805
+ */
9806
+ function authorPreview(description) {
9807
+ if (!hasAuthorContent(description)) return null;
9808
+ const all = blocks(description);
9809
+ const shown = [];
9810
+ let codeOmitted = false;
9811
+ let cut = false;
9812
+ let i = 0;
9813
+ for (; i < all.length; i++) {
9814
+ const lines = all[i];
9815
+ const first = lines.findIndex((line) => substance(line.visible) !== "");
9816
+ if (first === -1) continue;
9817
+ const raw = lines.slice(first).map((line) => line.visible);
9818
+ if (raw.length === 1 && ATX_HEADING.test(lines[first].visible.trim())) {
9819
+ shown.push(raw[0]);
9820
+ if (shown.length > 1) break;
9821
+ continue;
9822
+ }
9823
+ if (lines[first].code === "open") {
9824
+ if (lines.filter((line) => line.code === "body").length > 6) {
9825
+ codeOmitted = true;
9826
+ cut = true;
9827
+ } else shown.push(raw.join("\n"));
9828
+ } else {
9829
+ const bounded = LIST_ITEM.test(raw[0]) ? boundList(raw) : boundParagraph(raw.join("\n"));
9830
+ cut = bounded !== null;
9831
+ shown.push(bounded ?? raw.join("\n"));
9832
+ }
9833
+ break;
9834
+ }
9835
+ const rest = all.slice(i + 1).flat().map((line) => line.visible);
9836
+ if (!cut && !rest.some((line) => substance(line) !== "")) return null;
9837
+ return {
9838
+ markdown: shown.join("\n\n"),
9839
+ codeOmitted
9840
+ };
9474
9841
  }
9475
9842
  //#endregion
9476
9843
  //#region ../../packages/review-harness/src/domain/ai_review/motivation.ts
@@ -9488,7 +9855,7 @@ var MOTIVATION_EVIDENCE_KINDS = [
9488
9855
  "code"
9489
9856
  ];
9490
9857
  /** Ours, not the model's: what an unsupported claim is replaced with when a question remains. */
9491
- var UNESTABLISHED_MOTIVATION = "The reason for this change isn't established by the description or the code.";
9858
+ var UNESTABLISHED_MOTIVATION = "It isn't clear why this change is needed.";
9492
9859
  /** Stable, so "Mark answered" on it survives a reload like any other question's. */
9493
9860
  var INTENT_QUESTION_ID = "intent-motivation";
9494
9861
  /**
@@ -9555,9 +9922,9 @@ function isAuthorQuote(quote, description) {
9555
9922
  * fields that belong to the other statuses are dropped rather than carried, so a `stated` motive
9556
9923
  * cannot also show a guess and an `unclear` one cannot also show a quote.
9557
9924
  *
9558
- * A claim that fails becomes unclear only if the model also asked the question that would settle
9559
- * it, and then in our words rather than its own. Otherwise there is nothing to show that the
9560
- * reviewer could act on, and nothing is shown.
9925
+ * A claim that fails keeps none of its summary and becomes unclear, in our words, asking the
9926
+ * model's question or ours. Its evidence goes to the audit. An unclear motive that asked nothing
9927
+ * keeps its own summary, which claims nothing, and gets our question.
9561
9928
  */
9562
9929
  function verifyMotivation(candidate, authorDescription) {
9563
9930
  if (!candidate) return {};
@@ -9584,24 +9951,30 @@ function verifyMotivation(candidate, authorDescription) {
9584
9951
  const correction = {
9585
9952
  status: candidate.status,
9586
9953
  summary: candidate.summary,
9587
- reason
9954
+ reason,
9955
+ ...rejectedQuote(candidate, reason),
9956
+ ...candidate.evidence.length > 0 ? { discardedEvidence: candidate.evidence } : {}
9588
9957
  };
9589
- if (candidate.status !== "unclear" && candidate.authorQuestion) return {
9958
+ return {
9590
9959
  motivation: {
9591
9960
  contractVersion: 1,
9592
9961
  status: "unclear",
9593
- summary: UNESTABLISHED_MOTIVATION,
9594
- authorQuestion: candidate.authorQuestion
9962
+ summary: candidate.status === "unclear" ? candidate.summary : UNESTABLISHED_MOTIVATION,
9963
+ authorQuestion: candidate.authorQuestion ?? "What problem does this change solve, or what goal does it serve?"
9595
9964
  },
9596
9965
  correction: {
9597
9966
  ...correction,
9598
9967
  outcome: "downgraded"
9599
9968
  }
9600
9969
  };
9601
- return { correction: {
9602
- ...correction,
9603
- outcome: "withheld"
9604
- } };
9970
+ }
9971
+ function rejectedQuote(candidate, reason) {
9972
+ if (reason !== "unverified-quote" || !candidate.sourceQuote) return {};
9973
+ const quote = candidate.sourceQuote;
9974
+ return quote.length > 500 ? {
9975
+ rejectedQuote: quote.slice(0, 500),
9976
+ rejectedQuoteTruncated: true
9977
+ } : { rejectedQuote: quote };
9605
9978
  }
9606
9979
  /**
9607
9980
  * The one evidence-link policy, for both hosts.
@@ -9647,21 +10020,23 @@ function sameQuestion(a, b) {
9647
10020
  /**
9648
10021
  * File an unclear motive's question with the rest, as the one Intent question.
9649
10022
  *
9650
- * A question the model already asked in the same words is tagged rather than repeated, so the
9651
- * reader meets it once, in the Intent group. Telling two differently worded questions apart is
9652
- * judgement and is left to the prompt.
10023
+ * A question the model already asked in the same words becomes the Intent question rather than a
10024
+ * second copy, so the reader meets it once. It takes the stable id either way: a model's ids are
10025
+ * new on every analysis, and "Mark answered" is stored by id. Telling two differently worded
10026
+ * questions apart is judgement and is left to the prompt.
9653
10027
  */
9654
10028
  function withIntentQuestion(questions, motivation) {
9655
10029
  if (motivation?.status !== "unclear" || !motivation.authorQuestion) return questions;
9656
10030
  const asked = motivation.authorQuestion;
9657
10031
  const existing = questions.findIndex((q) => sameQuestion(q.question, asked));
10032
+ const taken = new Set(questions.filter((_, index) => index !== existing).map((q) => q.id));
10033
+ let id = INTENT_QUESTION_ID;
10034
+ for (let n = 2; taken.has(id); n += 1) id = `${INTENT_QUESTION_ID}-${n}`;
9658
10035
  if (existing >= 0) return questions.map((q, index) => index === existing ? {
9659
10036
  ...q,
10037
+ id,
9660
10038
  category: "intent"
9661
10039
  } : q);
9662
- const taken = new Set(questions.map((q) => q.id));
9663
- let id = INTENT_QUESTION_ID;
9664
- for (let n = 2; taken.has(id); n += 1) id = `${INTENT_QUESTION_ID}-${n}`;
9665
10040
  return [...questions, {
9666
10041
  id,
9667
10042
  question: asked,
@@ -9682,6 +10057,15 @@ function applyMotivation(result, candidate, authorDescription) {
9682
10057
  };
9683
10058
  }
9684
10059
  //#endregion
10060
+ //#region ../../packages/review-harness/src/domain/ai_review/summary_guidance.ts
10061
+ /**
10062
+ * What the "summary" field is for, as both the first analysis and a reconciliation ask for it.
10063
+ *
10064
+ * One string so the two prompts cannot drift. It renders as "What's changing" on the Overview,
10065
+ * between "Why this change?" and the risk card, so it says neither why nor how good.
10066
+ */
10067
+ var SUMMARY_GUIDANCE = `"summary": WHAT the change does, for a reviewer about to read it. Roughly 80-120 words, and fewer for a small change. Name the parts that change and what each now does. Do not explain why the change exists, and do not judge whether it is correct or safe: other fields carry both.`;
10068
+ //#endregion
9685
10069
  //#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
9686
10070
  var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above, first understand the implementation, then respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:
9687
10071
  {
@@ -9715,8 +10099,9 @@ var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above
9715
10099
  }
9716
10100
 
9717
10101
  Field guidance:
10102
+ - ${SUMMARY_GUIDANCE}
9718
10103
  - "motivation": WHY this change exists, which is a different question from what it does ("summary" covers that). Name the problem being solved or the goal being served, never the mechanics: "adds a retry loop" is what the code does, "the sync job was failing on transient network errors" is why. Weigh sources in this order: the PR Description and any issue it links; then tests and user-visible behaviour; then commit messages and docs; then the implementation itself. Choose exactly one "status":
9719
- - "stated": the author's own words in the PR Description say why. "sourceQuote" is REQUIRED: a short passage (one sentence or less, at least three words) copied character for character from the PR Description, which says why. Only the PR Description counts as the author stating it. Never use "stated" when the description only restates what the code does.
10104
+ - "stated": the author's own words in the PR Description say why. "sourceQuote" is REQUIRED: a short passage (one sentence or less, at least three words) copied character for character from the PR Description, which says why. Copy it exactly as written, keeping backticks, punctuation, hyphenation and spelling; never paraphrase, shorten words, join two sentences or correct a typo. Only the PR Description counts as the author stating it: a reason that appears only in commit messages, review comments, or an issue the description merely links to is not "stated" (use "inferred" and cite it as evidence). Never use "stated" when the description only restates what the code does. If the change itself also shows the reason, include it as "evidence".
9720
10105
  - "inferred": nothing states it, but the change gives real evidence of it. "evidence" is REQUIRED and non-empty: each entry names what points to the motive, such as a test asserting the fixed behaviour, an issue reference, or a commit message, with "file" set to the changed file it is in where there is one. Never infer a motive from names alone (a branch, a function or a file being called "fix-x" is not evidence). Word the summary as a likely reading, not a fact: "This appears to..." rather than "This fixes...".
9721
10106
  - "unclear": neither the description nor the change establishes why, AND knowing why would change how this change should be reviewed (for example, whether a behaviour change is intended). "authorQuestion" is REQUIRED: one focused question for the author that would settle it. Do not also ask that question, in any wording, in "questions".
9722
10107
  If the motive is unclear and knowing it would not change the review, omit "motivation" entirely. Do not add it just to fill the field.
@@ -12107,7 +12492,7 @@ function buildReconciliationInstructions(input, options = {}) {
12107
12492
  The completeness rule applies here too: one entry per numbered question, and "still-open" with a note rather than an omission when you are unsure. Do not manufacture an answer out of intent you cannot see. A question about why something was done the way it was is often not answerable from code at all, and "still-open" is the correct answer to it.`] : [],
12108
12493
  `Read the changed code above as a reviewer, not only as an auditor of the numbered list. These commits have not been reviewed by anyone. Report every genuinely NEW problem you find in them in "newIssues", whether or not it relates to anything on the list — code written to fix a finding is exactly where a regression gets introduced, and reassessing the list and stopping there is half the job. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.`,
12109
12494
  `If the new changes introduce an area the earlier analysis had no section for, add it to "newSections". Otherwise return an empty array. Do not restate or restructure the earlier analysis's existing sections.`,
12110
- `Give a fresh "summary", "overallRisk", "recommendation" and "nextSteps" for the pull request AS IT NOW STANDS, taking both the earlier findings that survive and the new changes into account. Unlike a partial review, you have been shown the earlier analysis in full and are expected to revise its verdict where the new changes justify it.`
12495
+ `Give a fresh "summary", "overallRisk", "recommendation" and "nextSteps" for the pull request AS IT NOW STANDS, taking both the earlier findings that survive and the new changes into account. Unlike a partial review, you have been shown the earlier analysis in full and are expected to revise its verdict where the new changes justify it. ${SUMMARY_GUIDANCE}`
12111
12496
  ].map((task, index) => `${index + 1}. ${task}`).join("\n");
12112
12497
  return `You are RECONCILING an earlier analysis of this pull request against the changes pushed since that analysis was made.
12113
12498
 
@@ -13433,4 +13818,4 @@ function lineageIds(id) {
13433
13818
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
13434
13819
  }
13435
13820
  //#endregion
13436
- export { relativeTime as $, AGENT_IDS as $n, kissDecisionRefusal as $t, createPrState as A, readQueue as An, checkCodeFreshness as At, toneOf as B, WITHHELD_REASON as Bn, PHASES as Bt, readEvents as C, offersLifecycle as Cn, statedWorkspacePort as Ct, announceJob as D, queueAddMany as Dn, forgeFor as Dt, telemetryUploadConsent as E, queueAdd as En, forgeResolver as Et, markAllRead as F, questionsOutstanding as Fn, endedAt as Ft, isLongDescription as G, git as Gn, clearClaim as Gt, withUsageRecorded as H, resolveTarget as Hn, phasesFor as Ht, markRead as I, reviewerDispositions as In, fail as It, ReviewBeingDeletedError as J, clearAgentPreference as Jn, decideKissRecommendation as Jt, asString as K, resolveAgent as Kn, driverGone as Kt, pruneNotifications as L, processDiscussion as Ln, isTerminal as Lt, eventName as M, isIssueOutstanding as Mn, followedBranch as Mt, headline as N, isQuestionOutstanding as Nn, reviewedShaOf as Nt, branchOf as O, queueRemove as On, remoteHost as Ot, listNotifications as P, issuesOutstanding as Pn, allJobs as Pt, modelHelpLines as Q, STORE_ROOT as Qn, indexReviewGroups as Qt, readNotification as R, recommendationsOf as Rn, loadJob as Rt, startJob as S, lifecyclePill as Sn, resolveWorkspacePort as St, recordTelemetryUploadConsent as T, sectionOf as Tn, claimRequestUrl as Tt, EXPLAIN_SIMPLY_PROMPT as U, displayRef as Un, progressSteps as Ut, unreadCount as V, kissOutcome as Vn, isLiveJob as Vt, hasAuthorContent as W, isCommitReview as Wn, purposeOf as Wt, readMarker as X, writeAgentPreference as Xn, groupByLineage as Xt, MANAGED_ROOT as Y, readAgentPreference as Yn, followedRefName as Yt, removeWorktree as Z, CONFIG_PATH as Zn, indexKeptIds as Zt, agentEnvOf as _, LIFECYCLES as _n, feedbackUrl as _t, wasBlocked as a, matchingWorkspaceIds as an, AgentUnavailableError as ar, bootstrapUrl as at, reanalyseReview as b, lifecycleAction as bn, MIN_WORKSPACE_PORT as bt, RefreshUnavailableError as c, presentedKissRunOf as cn, readServerRecord as ct, refreshPrContext as d, reviewedCommit as dn, spawnServer as dt, kissDecisionsOf as en, DEFAULT_AGENT_ID as er, activateQueued as et, isUnchanged as f, reviewedRepositories as fn, stopServer as ft, runningJobs as g, workspaceStamps as gn, PACKAGE_NAME as gt, liveUpdateFor as h, workspaceRevision as hn, BUILD_VERSION as ht, liveJobsFor as i, loadWorkspace as in, AgentCancelledError as ir, readIndexToken as it, createPrUpdate as j, doneVerb as jn, checkReview as jt, subjectOf as k, queueStamp as kn, fillFileUrlTemplate as kt, mergeUpdateInto as l, previousKissResult as ln, reviewsUrl as lt, UpdateAlreadyRunningError as m, touchWorkspace as mn, writeServerRecord as mt, deleteReview as n, lineageKeyFor as nn, agentFor as nr, queueForReview as nt, discardJob as o, mutateWorkspace as on, clearServerRecord as ot, askCheckout as p, summarise as pn, workspaceUrl as pt, extractJsonText as q, resolveModel as qn, isClaimed as qt, lineageIds as r, lineagePosition as rn, detectAgents as rr, settleQueue as rt, FETCH_TIMEOUT_MS as s, positionInLineage as sn, ensureServer as st, deleteLineage as t, latestKissRun as tn, agentById as tr, compactQueue as tt, updateReview as u, recentWorkspaces as un, serverEntryExists as ut, recordKissRun as v, activityLabel as vn, DEFAULT_WORKSPACE_PORT as vt, record as w, parseLifecycle as wn, writePortPreference as wt, runJob as x, lifecycleChip as xn, parseWorkspacePort as xt, startKissJob as y, isLifecycle as yn, MAX_WORKSPACE_PORT as yt, removeNotification as z, describeAuthorship as zn, saveJob as zt };
13821
+ export { extractJsonText as $, resolveModel as $n, isClaimed as $t, createPrState as A, parseLifecycle as An, writePortPreference as At, toneOf as B, issuesOutstanding as Bn, allJobs as Bt, readEvents as C, LIFECYCLES as Cn, feedbackUrl as Ct, announceJob as D, lifecycleChip as Dn, parseWorkspacePort as Dt, telemetryUploadConsent as E, lifecycleAction as En, MIN_WORKSPACE_PORT as Et, markAllRead as F, queueStamp as Fn, fillFileUrlTemplate as Ft, authorPreview as G, describeAuthorship as Gn, saveJob as Gt, withUsageRecorded as H, reviewerDispositions as Hn, fail as Ht, markRead as I, readQueue as In, checkCodeFreshness as It, CODE_BLOCK_CLASS as J, resolveTarget as Jn, phasesFor as Jt, hasAuthorContent as K, WITHHELD_REASON as Kn, PHASES as Kt, pruneNotifications as L, doneVerb as Ln, checkReview as Lt, eventName as M, queueAdd as Mn, forgeResolver as Mt, headline as N, queueAddMany as Nn, forgeFor as Nt, branchOf as O, lifecyclePill as On, resolveWorkspacePort as Ot, listNotifications as P, queueRemove as Pn, remoteHost as Pt, asString as Q, resolveAgent as Qn, driverGone as Qt, readNotification as R, isIssueOutstanding as Rn, followedBranch as Rt, startJob as S, workspaceStamps as Sn, PACKAGE_NAME as St, recordTelemetryUploadConsent as T, isLifecycle as Tn, MAX_WORKSPACE_PORT as Tt, EXPLAIN_SIMPLY_PROMPT as U, processDiscussion as Un, isTerminal as Ut, unreadCount as V, questionsOutstanding as Vn, endedAt as Vt, UNESTABLISHED_MOTIVATION as W, recommendationsOf as Wn, loadJob as Wt, escapeHtml as X, isCommitReview as Xn, purposeOf as Xt, CODE_COPY_CLASS as Y, displayRef as Yn, progressSteps as Yt, renderInlineText as Z, git as Zn, clearClaim as Zt, agentEnvOf as _, reviewedCommit as _n, spawnServer as _t, wasBlocked as a, kissDecisionRefusal as an, AGENT_IDS as ar, relativeTime as at, reanalyseReview as b, touchWorkspace as bn, writeServerRecord as bt, RefreshUnavailableError as c, lineageKeyFor as cn, agentFor as cr, queueForReview as ct, refreshPrContext as d, matchingWorkspaceIds as dn, AgentUnavailableError as dr, bootstrapUrl as dt, decideKissRecommendation as en, clearAgentPreference as er, ReviewBeingDeletedError as et, isUnchanged as f, mutateWorkspace as fn, clearServerRecord as ft, runningJobs as g, recentWorkspaces as gn, serverEntryExists as gt, liveUpdateFor as h, previousKissResult as hn, reviewsUrl as ht, liveJobsFor as i, indexReviewGroups as in, STORE_ROOT as ir, modelHelpLines as it, createPrUpdate as j, sectionOf as jn, claimRequestUrl as jt, subjectOf as k, offersLifecycle as kn, statedWorkspacePort as kt, mergeUpdateInto as l, lineagePosition as ln, detectAgents as lr, settleQueue as lt, UpdateAlreadyRunningError as m, presentedKissRunOf as mn, readServerRecord as mt, deleteReview as n, groupByLineage as nn, writeAgentPreference as nr, readMarker as nt, discardJob as o, kissDecisionsOf as on, DEFAULT_AGENT_ID as or, activateQueued as ot, askCheckout as p, positionInLineage as pn, ensureServer as pt, renderMarkdown as q, kissOutcome as qn, isLiveJob as qt, lineageIds as r, indexKeptIds as rn, CONFIG_PATH as rr, removeWorktree as rt, FETCH_TIMEOUT_MS as s, latestKissRun as sn, agentById as sr, compactQueue as st, deleteLineage as t, followedRefName as tn, readAgentPreference as tr, MANAGED_ROOT as tt, updateReview as u, loadWorkspace as un, AgentCancelledError as ur, readIndexToken as ut, recordKissRun as v, reviewedRepositories as vn, stopServer as vt, record as w, activityLabel as wn, DEFAULT_WORKSPACE_PORT as wt, runJob as x, workspaceRevision as xn, BUILD_VERSION as xt, startKissJob as y, summarise as yn, workspaceUrl as yt, removeNotification as z, isQuestionOutstanding as zn, reviewedShaOf as zt };
package/dist/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { $ as relativeTime, $n as AGENT_IDS, Bn as WITHHELD_REASON, Bt as PHASES, Ct as statedWorkspacePort, E as telemetryUploadConsent, Et as forgeResolver, Gn as git, Gt as clearClaim, Hn as resolveTarget, Kn as resolveAgent, Kt as driverGone, Lt as isTerminal, Pt as allJobs, Q as modelHelpLines, Qn as STORE_ROOT, Rt as loadJob, S as startJob, T as recordTelemetryUploadConsent, Un as displayRef, Ut as progressSteps, Vn as kissOutcome, Wt as purposeOf, X as readMarker, Xn as writeAgentPreference, Xt as groupByLineage, Y as MANAGED_ROOT, Yn as readAgentPreference, Yt as followedRefName, Z as removeWorktree, Zn as CONFIG_PATH, a as wasBlocked, an as matchingWorkspaceIds, at as bootstrapUrl, b as reanalyseReview, bt as MIN_WORKSPACE_PORT, er as DEFAULT_AGENT_ID, fn as reviewedRepositories, ft as stopServer, g as runningJobs, gt as PACKAGE_NAME, h as liveUpdateFor, ht as BUILD_VERSION, i as liveJobsFor, in as loadWorkspace, it as readIndexToken, jt as checkReview, l as mergeUpdateInto, lt as reviewsUrl, m as UpdateAlreadyRunningError, n as deleteReview$1, nn as lineageKeyFor, nt as queueForReview, on as mutateWorkspace, pn as summarise, pt as workspaceUrl, qn as resolveModel, qt as isClaimed, rr as detectAgents, sn as positionInLineage, st as ensureServer, tr as agentById, u as updateReview, un as recentWorkspaces, v as recordKissRun, vt as DEFAULT_WORKSPACE_PORT, w as record$1, wt as writePortPreference, x as runJob, xt as parseWorkspacePort, yt as MAX_WORKSPACE_PORT } from "./delete_review-B2bqhgtx.js";
2
+ import { $n as resolveModel, $t as isClaimed, At as writePortPreference, Bt as allJobs, Dt as parseWorkspacePort, E as telemetryUploadConsent, Et as MIN_WORKSPACE_PORT, Jn as resolveTarget, Kn as WITHHELD_REASON, Kt as PHASES, Lt as checkReview, Mt as forgeResolver, Qn as resolveAgent, Qt as driverGone, S as startJob, St as PACKAGE_NAME, T as recordTelemetryUploadConsent, Tt as MAX_WORKSPACE_PORT, Ut as isTerminal, Wt as loadJob, Xt as purposeOf, Yn as displayRef, Yt as progressSteps, Zn as git, Zt as clearClaim, a as wasBlocked, ar as AGENT_IDS, at as relativeTime, b as reanalyseReview, cn as lineageKeyFor, ct as queueForReview, dn as matchingWorkspaceIds, dt as bootstrapUrl, fn as mutateWorkspace, g as runningJobs, gn as recentWorkspaces, h as liveUpdateFor, ht as reviewsUrl, i as liveJobsFor, ir as STORE_ROOT, it as modelHelpLines, kt as statedWorkspacePort, l as mergeUpdateInto, lr as detectAgents, m as UpdateAlreadyRunningError, n as deleteReview$1, nn as groupByLineage, nr as writeAgentPreference, nt as readMarker, or as DEFAULT_AGENT_ID, pn as positionInLineage, pt as ensureServer, qn as kissOutcome, rr as CONFIG_PATH, rt as removeWorktree, sr as agentById, tn as followedRefName, tr as readAgentPreference, tt as MANAGED_ROOT, u as updateReview, un as loadWorkspace, ut as readIndexToken, v as recordKissRun, vn as reviewedRepositories, vt as stopServer, w as record$1, wt as DEFAULT_WORKSPACE_PORT, x as runJob, xt as BUILD_VERSION, yn as summarise, yt as workspaceUrl } from "./delete_review-DEvRanb6.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";