prreviewbuddy 0.53.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.
@@ -1643,7 +1643,8 @@ function completeReviewSession(session, result, context, options = {}) {
1643
1643
  ...session.metadata,
1644
1644
  ...context ? reviewFacts(result, context) : {
1645
1645
  fileCoverage: void 0,
1646
- discussionStats: void 0
1646
+ discussionStats: void 0,
1647
+ authorDescription: void 0
1647
1648
  },
1648
1649
  commitSha: options.commitSha ?? session.metadata.commitSha,
1649
1650
  durationMs: Math.max(0, now - session.metadata.generatedAt)
@@ -1664,7 +1665,8 @@ function reviewFacts(result, context) {
1664
1665
  reviewedFiles: sectionedFiles(result, context),
1665
1666
  totalChangedFiles: context.fileCoverage.totalChangedFiles
1666
1667
  },
1667
- ...context.prContext ? { discussionStats: countDiscussion(context) } : {}
1668
+ ...context.prContext ? { discussionStats: countDiscussion(context) } : {},
1669
+ authorDescription: context.prContext?.pullRequest.body
1668
1670
  };
1669
1671
  }
1670
1672
  /**
@@ -2237,6 +2239,7 @@ async function buildChangeSet(options) {
2237
2239
  number: 0,
2238
2240
  title: changeTitle(subjects, headRef),
2239
2241
  body: subjects.length > 1 ? subjects.map((s) => `- ${s}`).join("\n") : null,
2242
+ bodySource: "commit-subjects",
2240
2243
  base: {
2241
2244
  ref: base.display,
2242
2245
  sha: mergeBase
@@ -6296,6 +6299,7 @@ function sanitisePullRequest(value) {
6296
6299
  };
6297
6300
  const title = typeof value.title === "string" ? value.title.trim() : "";
6298
6301
  if (title) pr.title = title.slice(0, 200);
6302
+ if (typeof value.body === "string" && value.body.trim()) pr.body = value.body;
6299
6303
  const headSha = label(value.headSha);
6300
6304
  if (headSha) pr.headSha = headSha;
6301
6305
  const headRef = label(value.headRef);
@@ -6851,7 +6855,7 @@ function slug(repo) {
6851
6855
  * `baseRefOid` is a diff taken against the wrong *commit*, which is worse because the branch name
6852
6856
  * is right and everything reads as normal. A list nobody asserts is a list somebody tidies.
6853
6857
  */
6854
- var PR_FIELDS = "number,url,title,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
6858
+ var PR_FIELDS = "number,url,title,body,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
6855
6859
  async function viewRequest(repo, reference, cwd, env) {
6856
6860
  return await ghJson([
6857
6861
  "pr",
@@ -6863,7 +6867,7 @@ async function viewRequest(repo, reference, cwd, env) {
6863
6867
  PR_FIELDS
6864
6868
  ], cwd, env);
6865
6869
  }
6866
- function text(value) {
6870
+ function text$1(value) {
6867
6871
  return typeof value === "string" ? value.trim() : "";
6868
6872
  }
6869
6873
  /**
@@ -6884,30 +6888,31 @@ function fileUrlTemplate(prUrl) {
6884
6888
  * email address all live in `pr_context.ts` and in nothing else.
6885
6889
  */
6886
6890
  function identity(raw) {
6887
- const url = text(raw.url);
6891
+ const url = text$1(raw.url);
6888
6892
  const author = raw.author ?? null;
6889
6893
  return {
6890
6894
  provider: GITHUB_FORGE_ID,
6891
6895
  number: typeof raw.number === "number" ? raw.number : 0,
6892
6896
  url,
6893
- title: text(raw.title),
6894
- headSha: text(raw.headRefOid),
6895
- headRef: text(raw.headRefName),
6896
- baseRef: text(raw.baseRefName),
6897
+ title: text$1(raw.title),
6898
+ body: raw.body,
6899
+ headSha: text$1(raw.headRefOid),
6900
+ headRef: text$1(raw.headRefName),
6901
+ baseRef: text$1(raw.baseRefName),
6897
6902
  filesUrl: url ? `${url}/files` : "",
6898
6903
  fileUrlTemplate: fileUrlTemplate(url),
6899
6904
  author: author ? {
6900
- name: text(author.name),
6901
- handle: text(author.login)
6905
+ name: text$1(author.name),
6906
+ handle: text$1(author.login)
6902
6907
  } : null
6903
6908
  };
6904
6909
  }
6905
6910
  /**
6906
6911
  * The watcher's query: where the branch is, and whether the request is still open.
6907
6912
  *
6908
- * Deliberately not `PR_FIELDS`. That list exists because a review needs all nine of them, and its
6913
+ * Deliberately not `PR_FIELDS`. That list exists because a review needs all ten of them, and its
6909
6914
  * docblock warns that trimming it fails silently rather than loudly. Sharing it here would put a
6910
- * nine-field query on a five-minute timer across the whole working set, and would put the watcher
6915
+ * ten-field query on a five-minute timer across the whole working set, and would put the watcher
6911
6916
  * in a position to "optimise" a list that must never shrink.
6912
6917
  *
6913
6918
  * `state` rides on the same call rather than being a second one, which keeps the watcher's rate
@@ -6930,7 +6935,7 @@ var STATES = /* @__PURE__ */ new Map([
6930
6935
  * about a request GitHub declined to describe, once per cycle, indefinitely.
6931
6936
  */
6932
6937
  function readHeadSha(raw) {
6933
- const sha = text(raw?.headRefOid);
6938
+ const sha = text$1(raw?.headRefOid);
6934
6939
  if (!sha) return {
6935
6940
  kind: "failed",
6936
6941
  detail: "GitHub reported no head commit for this request."
@@ -6938,7 +6943,7 @@ function readHeadSha(raw) {
6938
6943
  return {
6939
6944
  kind: "head",
6940
6945
  sha,
6941
- state: STATES.get(text(raw?.state)) ?? null
6946
+ state: STATES.get(text$1(raw?.state)) ?? null
6942
6947
  };
6943
6948
  }
6944
6949
  async function headOf(repo, number, env, cwd) {
@@ -6973,8 +6978,8 @@ async function resolve$1(repo, reference, env, cwd) {
6973
6978
  } catch (error) {
6974
6979
  throw new TargetError(`${reference} could not be looked up on GitHub: ${error.message}`);
6975
6980
  }
6976
- const headRef = text(raw.headRefName);
6977
- const headSha = text(raw.headRefOid);
6981
+ const headRef = text$1(raw.headRefName);
6982
+ const headSha = text$1(raw.headRefOid);
6978
6983
  if (!headRef || !headSha) throw new TargetError(`${reference} was found on GitHub but reported no branch and commit.`);
6979
6984
  const resolved = {
6980
6985
  reference,
@@ -6982,12 +6987,12 @@ async function resolve$1(repo, reference, env, cwd) {
6982
6987
  headSha,
6983
6988
  provider: GITHUB_FORGE_ID
6984
6989
  };
6985
- const baseRef = text(raw.baseRefName);
6990
+ const baseRef = text$1(raw.baseRefName);
6986
6991
  if (baseRef) resolved.baseRef = baseRef;
6987
- const baseSha = text(raw.baseRefOid);
6992
+ const baseSha = text$1(raw.baseRefOid);
6988
6993
  if (baseSha) resolved.baseSha = baseSha;
6989
6994
  if (typeof raw.changedFiles === "number") resolved.changedFiles = raw.changedFiles;
6990
- const url = text(raw.url);
6995
+ const url = text$1(raw.url);
6991
6996
  if (url) resolved.url = url;
6992
6997
  if (typeof raw.number === "number") resolved.number = raw.number;
6993
6998
  return resolved;
@@ -9081,7 +9086,7 @@ Follow-up Prompts:
9081
9086
  ${buildFollowUpPromptsSpec()}
9082
9087
 
9083
9088
  PR Title: ${raw.pullRequest.title}
9084
- ${raw.pullRequest.body ? `\nPR Description:\n${raw.pullRequest.body}\n` : ""}
9089
+ ${raw.pullRequest.body ? `\n${raw.pullRequest.bodySource === "commit-subjects" ? "Commit subjects (there is no PR Description)" : "PR Description"}:\n${raw.pullRequest.body}\n` : ""}
9085
9090
  ${diff}`;
9086
9091
  const overheadChars = assemble({
9087
9092
  diff: "",
@@ -9390,10 +9395,688 @@ function normalizeSections(value) {
9390
9395
  return sections;
9391
9396
  }
9392
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
9635
+ //#region ../../packages/review-harness/src/domain/ai_review/author_description.ts
9636
+ /**
9637
+ * Reading a pull request's description for whether it says anything, without ever changing it.
9638
+ *
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.
9644
+ */
9645
+ /**
9646
+ * Lines that are a pull request template's scaffolding when they stand alone.
9647
+ *
9648
+ * Matched against a whole line only, after its heading marker, bold and trailing colon are gone.
9649
+ * A heading that says something of its own, `## Fix startup crashes`, is not a label and survives.
9650
+ */
9651
+ var TEMPLATE_LABELS = /* @__PURE__ */ new Set([
9652
+ "description",
9653
+ "summary",
9654
+ "changes",
9655
+ "what",
9656
+ "why",
9657
+ "how",
9658
+ "context",
9659
+ "motivation",
9660
+ "motivation and context",
9661
+ "what changed",
9662
+ "what does this pr do",
9663
+ "why is this needed",
9664
+ "type of change",
9665
+ "testing",
9666
+ "tests",
9667
+ "test plan",
9668
+ "how to test",
9669
+ "how has this been tested",
9670
+ "screenshots",
9671
+ "notes",
9672
+ "checklist",
9673
+ "related issues",
9674
+ "related issue",
9675
+ "links"
9676
+ ]);
9677
+ var HEADING_MARKER = /^#{1,6}\s*/;
9678
+ var UNCHECKED_TASK = /^[-*+]\s+\[ \]/;
9679
+ var EMPTY_LIST_ITEM = /^(?:[-*+]|\d+[.)])\s*$/;
9680
+ function asLabel(line) {
9681
+ return line.replace(HEADING_MARKER, "").replace(/^\*\*(.*)\*\*$/, "$1").replace(/^__(.*)__$/, "$1").replace(/:\s*$/, "").trim().toLowerCase();
9682
+ }
9683
+ /** One line, reduced to whatever in it is the author's own words. Empty when none are. */
9684
+ function substance(line) {
9685
+ const trimmed = line.trim();
9686
+ if (!trimmed || UNCHECKED_TASK.test(trimmed) || EMPTY_LIST_ITEM.test(trimmed)) return "";
9687
+ if (TEMPLATE_LABELS.has(asLabel(trimmed))) return "";
9688
+ return trimmed.replace(HEADING_MARKER, "");
9689
+ }
9690
+ /**
9691
+ * The parts of a description the author wrote, one line each, with the template's scaffolding gone.
9692
+ *
9693
+ * Only HTML comments, unticked checklist items, empty list items and bare template labels are
9694
+ * discounted. A ticked item is the author asserting something, so it stays.
9695
+ */
9696
+ function authorText(description) {
9697
+ if (!description) return "";
9698
+ return visibleLines(description).map((line) => substance(line.visible)).filter((line) => line !== "").join("\n");
9699
+ }
9700
+ /**
9701
+ * Whether a description has anything in it beyond an unfilled template.
9702
+ *
9703
+ * Conservative by design: any line that survives counts, so a sparse description is shown rather
9704
+ * than hidden.
9705
+ */
9706
+ function hasAuthorContent(description) {
9707
+ return authorText(description) !== "";
9708
+ }
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
+ };
9841
+ }
9842
+ //#endregion
9843
+ //#region ../../packages/review-harness/src/domain/ai_review/motivation.ts
9844
+ var MOTIVATION_STATUSES = [
9845
+ "stated",
9846
+ "inferred",
9847
+ "unclear"
9848
+ ];
9849
+ var MOTIVATION_EVIDENCE_KINDS = [
9850
+ "test",
9851
+ "behaviour",
9852
+ "issue",
9853
+ "commit",
9854
+ "docs",
9855
+ "code"
9856
+ ];
9857
+ /** Ours, not the model's: what an unsupported claim is replaced with when a question remains. */
9858
+ var UNESTABLISHED_MOTIVATION = "It isn't clear why this change is needed.";
9859
+ /** Stable, so "Mark answered" on it survives a reload like any other question's. */
9860
+ var INTENT_QUESTION_ID = "intent-motivation";
9861
+ /**
9862
+ * Fewer words than this cannot attribute a reason to anyone: "the cache" is in most descriptions
9863
+ * that mention a cache. An initial setting, not a measured one.
9864
+ */
9865
+ var MIN_QUOTE_WORDS = 3;
9866
+ function text(value) {
9867
+ if (typeof value !== "string") return void 0;
9868
+ return value.trim() || void 0;
9869
+ }
9870
+ function normalizeEvidence(value) {
9871
+ if (!Array.isArray(value)) return [];
9872
+ return value.flatMap((raw) => {
9873
+ const item = raw ?? {};
9874
+ const kind = item.kind;
9875
+ const detail = text(item.detail);
9876
+ if (!MOTIVATION_EVIDENCE_KINDS.includes(kind) || !detail) return [];
9877
+ const file = text(item.file);
9878
+ return [{
9879
+ kind,
9880
+ detail,
9881
+ ...file ? { file } : {}
9882
+ }];
9883
+ });
9884
+ }
9885
+ /** The model's `motivation`, if it is one at all. Absent, or the wrong shape, is no motivation. */
9886
+ function normalizeMotivation(value) {
9887
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
9888
+ const item = value;
9889
+ const status = item.status;
9890
+ const summary = text(item.summary);
9891
+ if (!MOTIVATION_STATUSES.includes(status) || !summary) return void 0;
9892
+ const sourceQuote = text(item.sourceQuote);
9893
+ const authorQuestion = text(item.authorQuestion);
9894
+ return {
9895
+ status,
9896
+ summary,
9897
+ evidence: normalizeEvidence(item.evidence),
9898
+ ...sourceQuote ? { sourceQuote } : {},
9899
+ ...authorQuestion ? { authorQuestion } : {}
9900
+ };
9901
+ }
9902
+ /** Whitespace is the one thing a faithful quote may differ in: line wrapping is not wording. */
9903
+ function collapse(value) {
9904
+ return value.replace(/\s+/g, " ").trim();
9905
+ }
9906
+ /**
9907
+ * Whether the author wrote this, in these words, in the description the analysis read.
9908
+ *
9909
+ * Searched in the author's own text only. A template's instructions are in the description too, and
9910
+ * "Explain why this change is needed" would otherwise pass as the author explaining why.
9911
+ */
9912
+ function isAuthorQuote(quote, description) {
9913
+ if (!quote) return false;
9914
+ const needle = collapse(quote);
9915
+ if (needle.split(" ").length < MIN_QUOTE_WORDS) return false;
9916
+ return collapse(authorText(description)).includes(needle);
9917
+ }
9918
+ /**
9919
+ * The checks, applied to one candidate.
9920
+ *
9921
+ * Each status has one thing it cannot stand without: a verified quote, evidence, a question. The
9922
+ * fields that belong to the other statuses are dropped rather than carried, so a `stated` motive
9923
+ * cannot also show a guess and an `unclear` one cannot also show a quote.
9924
+ *
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.
9928
+ */
9929
+ function verifyMotivation(candidate, authorDescription) {
9930
+ if (!candidate) return {};
9931
+ const base = {
9932
+ contractVersion: 1,
9933
+ summary: candidate.summary
9934
+ };
9935
+ if (candidate.status === "stated" && isAuthorQuote(candidate.sourceQuote, authorDescription)) return { motivation: {
9936
+ ...base,
9937
+ status: "stated",
9938
+ sourceQuote: candidate.sourceQuote
9939
+ } };
9940
+ if (candidate.status === "inferred" && candidate.evidence.length > 0) return { motivation: {
9941
+ ...base,
9942
+ status: "inferred",
9943
+ evidence: candidate.evidence
9944
+ } };
9945
+ if (candidate.status === "unclear" && candidate.authorQuestion) return { motivation: {
9946
+ ...base,
9947
+ status: "unclear",
9948
+ authorQuestion: candidate.authorQuestion
9949
+ } };
9950
+ const reason = candidate.status === "stated" ? candidate.sourceQuote ? "unverified-quote" : "missing-quote" : candidate.status === "inferred" ? "no-evidence" : "no-question";
9951
+ const correction = {
9952
+ status: candidate.status,
9953
+ summary: candidate.summary,
9954
+ reason,
9955
+ ...rejectedQuote(candidate, reason),
9956
+ ...candidate.evidence.length > 0 ? { discardedEvidence: candidate.evidence } : {}
9957
+ };
9958
+ return {
9959
+ motivation: {
9960
+ contractVersion: 1,
9961
+ status: "unclear",
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?"
9964
+ },
9965
+ correction: {
9966
+ ...correction,
9967
+ outcome: "downgraded"
9968
+ }
9969
+ };
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 };
9978
+ }
9979
+ /**
9980
+ * The one evidence-link policy, for both hosts.
9981
+ *
9982
+ * A changed file is a diff link and a real unchanged file a source link. A path the host can show
9983
+ * does not exist is removed and returned for the audit. A path the host cannot check keeps its
9984
+ * text and gets no state, so it is never presented as a link the reader can trust.
9985
+ */
9986
+ function placeEvidenceFiles(motivation, paths) {
9987
+ if (!motivation?.evidence) return {
9988
+ motivation,
9989
+ removed: []
9990
+ };
9991
+ const removed = [];
9992
+ const evidence = motivation.evidence.map(({ fileState: _ignored, ...item }) => {
9993
+ if (!item.file) return item;
9994
+ if (paths.changed(item.file)) return {
9995
+ ...item,
9996
+ fileState: "changed"
9997
+ };
9998
+ if (!paths.exists) return item;
9999
+ if (paths.exists(item.file)) return {
10000
+ ...item,
10001
+ fileState: "unchanged"
10002
+ };
10003
+ removed.push(item.file);
10004
+ const { file: _gone, ...rest } = item;
10005
+ return rest;
10006
+ });
10007
+ return {
10008
+ motivation: {
10009
+ ...motivation,
10010
+ evidence
10011
+ },
10012
+ removed
10013
+ };
10014
+ }
10015
+ /** Two questions are the same question when they differ only in case, spacing or end punctuation. */
10016
+ function sameQuestion(a, b) {
10017
+ const key = (value) => collapse(value).toLowerCase().replace(/[?.!\s]+$/, "");
10018
+ return key(a) === key(b);
10019
+ }
10020
+ /**
10021
+ * File an unclear motive's question with the rest, as the one Intent question.
10022
+ *
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.
10027
+ */
10028
+ function withIntentQuestion(questions, motivation) {
10029
+ if (motivation?.status !== "unclear" || !motivation.authorQuestion) return questions;
10030
+ const asked = motivation.authorQuestion;
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}`;
10035
+ if (existing >= 0) return questions.map((q, index) => index === existing ? {
10036
+ ...q,
10037
+ id,
10038
+ category: "intent"
10039
+ } : q);
10040
+ return [...questions, {
10041
+ id,
10042
+ question: asked,
10043
+ category: "intent"
10044
+ }];
10045
+ }
10046
+ /** The candidate checked and, when it ends unclear, its question filed. */
10047
+ function applyMotivation(result, candidate, authorDescription) {
10048
+ const { motivation, correction } = verifyMotivation(candidate, authorDescription);
10049
+ const { motivation: _unverified, ...rest } = result;
10050
+ return {
10051
+ result: {
10052
+ ...rest,
10053
+ ...motivation ? { motivation } : {},
10054
+ questions: withIntentQuestion(result.questions, motivation)
10055
+ },
10056
+ ...correction ? { correction } : {}
10057
+ };
10058
+ }
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
9393
10069
  //#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
9394
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:
9395
10071
  {
9396
10072
  "summary": string,
10073
+ "motivation"?: {
10074
+ "status": "stated" | "inferred" | "unclear",
10075
+ "summary": string,
10076
+ "sourceQuote"?: string,
10077
+ "evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
10078
+ "authorQuestion"?: string
10079
+ },
9397
10080
  "overallRisk": "low" | "medium" | "high",
9398
10081
  "sections": [{
9399
10082
  "id": string,
@@ -9416,6 +10099,12 @@ var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above
9416
10099
  }
9417
10100
 
9418
10101
  Field guidance:
10102
+ - ${SUMMARY_GUIDANCE}
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":
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".
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...".
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".
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.
9419
10108
  - "sections": partition the pull request into logical implementation sections (e.g. "Authentication", "Database Migration", "API Changes"), grouped by responsibility/purpose, not by directory or file type. Identify a sensible number of sections — most PRs will naturally have between 2 and 8, but do not invent or split sections simply to reach a target; let the actual structure of the change decide the count. For each section: "oneLineSummary" is a single sentence; "purpose" explains why this part of the change exists; "whyReview" is a short note aimed at the reviewer about what to focus on; "checklist" is a short list of things to verify (review prompts, not findings — never report bugs, code smells, or recommendations inside a section, that's what "issues" is for). Assign "order" starting at 1 in the sequence a reviewer should walk through the sections (narrative order, not file count or alphabetical). "reviewWeight" is a relative time-budget indicator from 1 (skim) to 5 (spend the most time here), independent of "priority" and "reviewAttention". Set "recommendedStartingPoint": true on exactly one section — whichever one a reviewer should actually read first for the rest of the review to make sense. This is not necessarily the section with "order": 1 (e.g. a foundational database migration might naturally come first while a reviewer should still start with the authentication logic it enables). Set it to false on every other section. Every file in the diff must appear in exactly one section — the sections are the only file list the developer sees, so an omitted file is a file they never review. Files with no natural home (lockfiles, dependency bumps, generated output, formatting-only changes) go in a single final catch-all section — title it for what it contains, e.g. "Supporting changes" or "Dependency and config updates" — with "priority": "supporting" and "reviewAttention": "low". Never drop a file just because it is uninteresting; say that it is uninteresting instead.
9420
10109
  - "files" inside a section: "path" is the file path exactly as it appears in the diff. "focus" is REQUIRED on every file — if a file is worth listing in a section, it is worth explaining why the reviewer has been brought to it. Keep it to one or two short sentences, roughly 20-35 words, never more than two sentences. Make it actionable rather than descriptive: name the role this specific file plays in the section, then say what the reviewer should verify there. For example: "Introduces the new association reader. Verify it preserves the existing lookup behaviour." / "Implements the retry path. Confirm error handling matches the existing gateway implementation." / "Registers the generated behaviour with Active Record. Check the hook fires for subclasses too." Do not restate the section's summary or checklist, do not use bullet points, and do not write filler such as "Review this file carefully."
9421
10110
  - "file" on an issue or question: prefer anchoring a finding to the changed file responsible for the concern, named exactly as it appears in the diff. If an unchanged repository file is necessary context and no changed-file location accurately represents the finding, it may be referenced as an unchanged related file. Never list an unchanged file in a section's "files" array, and never describe an unchanged file as part of the pull request.
@@ -9426,7 +10115,7 @@ Field guidance:
9426
10115
  - "existingDiscussion" on an issue: set this only when a thread in the PR DISCUSSION CONTEXT block already raises this same concern, and you have independently confirmed from the diff that it still applies. "threadId" must be copied verbatim from that thread's [T_...] marker — never invent an id, never guess, and omit the field entirely if no thread matches. Set "relation" to "same" when the thread already makes this exact point, or "related" when it discusses this area without covering the finding — a reviewer acts differently on "someone said this already" than on "someone was looking near here". Still write the issue normally; this field only records that the point was raised before, so the reviewer is not shown it as a fresh discovery. Do not use it to pad the review with issues you would otherwise have left out.
9427
10116
  - "confidence" on an issue: how sure you are the issue is real, on the same high/medium/low scale as a question's "importance". This is a separate axis from "severity", which is how much it would matter if real — a critical issue you are unsure about and a nit you are certain of are different things to act on, so do not let one field stand in for the other. Use LOW when you are reasoning from a diff excerpt without the surrounding code, and say what would confirm it in the description. Omit the field rather than guessing a middle value.
9428
10117
  - "questions": each one is a genuine open item the reviewer needs answered before they can sign off, not a rhetorical prompt. "importance" is how much attention it deserves, on the same high/medium/low scale as a section's "reviewAttention" (e.g. a question about a possibly-exposed secret is "high"; a naming preference is "low"). "whyThisMatters" is 1-2 sentences explaining the concrete consequence of the question going unanswered — not a restatement of the question itself.
9429
- - Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus", and each question's "whyThisMatters" are rendered as plain prose paragraphs, not markdown — write them as normal sentences. Do not use "- " bullet points or numbered lists inside these fields; if you're enumerating several points, join them into flowing sentences instead. Inline code/identifiers may use backtick spans (e.g. \`ErrorSeverity\`) — those do render.
10118
+ - Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus", each question's "whyThisMatters", and every text field inside "motivation" are rendered as plain prose paragraphs, not markdown — write them as normal sentences. Do not use "- " bullet points or numbered lists inside these fields; if you're enumerating several points, join them into flowing sentences instead. Inline code/identifiers may use backtick spans (e.g. \`ErrorSeverity\`) — those do render.
9430
10119
  - "summary", "recommendation" and each "nextSteps" entry are rendered as Markdown, so use it where it carries meaning: backtick spans for identifiers, a fenced block for a snippet worth reading as code, a short bullet list where the content genuinely is a list. Prose is still the default and these fields are still short. Do not add headings, and do not turn a paragraph into bullets to look organised.
9431
10120
 
9432
10121
  This JSON object is the only output the developer sees. The free-text sections above (Overview, Guide, Issues, Questions, Follow-up Prompts) are for your own internal reasoning — do not copy them verbatim into any JSON field.`;
@@ -9444,7 +10133,22 @@ function buildStructuredReviewPrompt(pr, options = {}) {
9444
10133
  partialFiles: partialFiles ?? []
9445
10134
  };
9446
10135
  }
9447
- function parseReviewResult(raw) {
10136
+ /**
10137
+ * A reply, parsed, with the motivation checked against the description the analysis read.
10138
+ *
10139
+ * The check happens here rather than after, so there is no parsed result anywhere whose motivation
10140
+ * has not been through it. `correction` says what the check refused, for a caller with an audit to
10141
+ * put it in.
10142
+ */
10143
+ function parseReviewResponse(raw, authorDescription) {
10144
+ const { result, motivation } = parseUnchecked(raw);
10145
+ const applied = applyMotivation(result, motivation, authorDescription);
10146
+ return {
10147
+ result: applied.result,
10148
+ ...applied.correction ? { motivationCorrection: applied.correction } : {}
10149
+ };
10150
+ }
10151
+ function parseUnchecked(raw) {
9448
10152
  const jsonText = extractJsonText(raw);
9449
10153
  let parsed;
9450
10154
  try {
@@ -9458,14 +10162,17 @@ function parseReviewResult(raw) {
9458
10162
  const recommendation = asString(obj.recommendation);
9459
10163
  if (!summary && !recommendation && !Array.isArray(obj.issues) && !Array.isArray(obj.nextSteps)) throw new Error("The AI returned valid JSON, but not in the expected analysis shape (missing summary/recommendation/issues/nextSteps). This usually means the model ignored the requested schema.");
9460
10164
  return {
9461
- schemaVersion: 1,
9462
- summary,
9463
- overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
9464
- sections: normalizeSections(obj.sections),
9465
- issues: normalizeIssues(obj.issues),
9466
- questions: normalizeQuestions(obj.questions),
9467
- recommendation,
9468
- nextSteps: asStringArray(obj.nextSteps)
10165
+ result: {
10166
+ schemaVersion: 1,
10167
+ summary,
10168
+ overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
10169
+ sections: normalizeSections(obj.sections),
10170
+ issues: normalizeIssues(obj.issues),
10171
+ questions: normalizeQuestions(obj.questions),
10172
+ recommendation,
10173
+ nextSteps: asStringArray(obj.nextSteps)
10174
+ },
10175
+ motivation: normalizeMotivation(obj.motivation)
9469
10176
  };
9470
10177
  }
9471
10178
  var EXPLAIN_SIMPLY_PROMPT = "Explain this review in plain English for someone unfamiliar with this codebase. Avoid jargon, use a simple everyday analogy if it helps, and keep it skippable — a couple of short paragraphs is enough.";
@@ -9836,6 +10543,7 @@ async function assemble(changeSet, budget, lens, prContext) {
9836
10543
  ...changeSet.data,
9837
10544
  files: selection.inline,
9838
10545
  changedFileCount: allFiles.length,
10546
+ ...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
9839
10547
  discussion: prContext?.discussion
9840
10548
  }, selection.inline.length, null);
9841
10549
  const shared = {
@@ -9865,6 +10573,21 @@ async function assemble(changeSet, budget, lens, prContext) {
9865
10573
  };
9866
10574
  }
9867
10575
  /**
10576
+ * The change's pull request, as the prompt should present it once a forge request is attached.
10577
+ *
10578
+ * With no description, the commit subjects stay: they are still the best account of the change
10579
+ * there is, and still labelled as commits rather than as the author's words.
10580
+ */
10581
+ function pullRequestFor(local, prContext) {
10582
+ const body = prContext.pullRequest.body;
10583
+ if (!body) return local;
10584
+ const { bodySource: _commits, ...rest } = local;
10585
+ return {
10586
+ ...rest,
10587
+ body
10588
+ };
10589
+ }
10590
+ /**
9868
10591
  * The inline patches as one blob, for grounding the assistant.
9869
10592
  *
9870
10593
  * No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
@@ -10199,13 +10922,14 @@ async function existingPaths(repoPath, paths, commit = "HEAD") {
10199
10922
  * from the change set, and it costs one git call, or none when the guide cites nothing off-diff.
10200
10923
  */
10201
10924
  async function validateGuide(rawResponse, context) {
10202
- const result = parseReviewResult(rawResponse);
10925
+ const { result, motivationCorrection } = parseReviewResponse(rawResponse, context.prContext?.pullRequest.body);
10203
10926
  const changedPaths = new Set(context.changeSet.data.files.map((file) => file.name));
10204
10927
  const sectionIds = new Set(result.sections.map((section) => section.id));
10205
10928
  const findings = [...result.issues, ...result.questions];
10206
10929
  const offDiff = /* @__PURE__ */ new Set();
10207
10930
  for (const section of result.sections) for (const file of section.files) if (!changedPaths.has(file.path)) offDiff.add(file.path);
10208
10931
  for (const finding of findings) if (finding.file && !changedPaths.has(finding.file)) offDiff.add(finding.file);
10932
+ for (const evidence of result.motivation?.evidence ?? []) if (evidence.file && !changedPaths.has(evidence.file)) offDiff.add(evidence.file);
10209
10933
  const realButUnchanged = await existingPaths(context.changeSet.repoPath, [...offDiff]);
10210
10934
  const unknownPaths = /* @__PURE__ */ new Set();
10211
10935
  const sectioned = /* @__PURE__ */ new Set();
@@ -10246,6 +10970,13 @@ async function validateGuide(rawResponse, context) {
10246
10970
  orphanedSectionRefs += 1;
10247
10971
  }
10248
10972
  }
10973
+ const placed = placeEvidenceFiles(result.motivation, {
10974
+ changed: (path) => changedPaths.has(path),
10975
+ exists: (path) => realButUnchanged.has(path)
10976
+ });
10977
+ if (placed.motivation) result.motivation = placed.motivation;
10978
+ for (const path of placed.removed) unknownPaths.add(path);
10979
+ for (const evidence of placed.motivation?.evidence ?? []) if (evidence.fileState === "unchanged" && evidence.file) noteReferenced(evidence.file);
10249
10980
  const attribution = attributeIssuesToDiscussion(result, context.processed.discussion);
10250
10981
  return {
10251
10982
  result: attribution.result,
@@ -10253,7 +10984,8 @@ async function validateGuide(rawResponse, context) {
10253
10984
  unknownPaths: [...unknownPaths].sort(),
10254
10985
  relocatedFindings,
10255
10986
  orphanedSectionRefs,
10256
- rejectedAttributions: attribution.rejected
10987
+ rejectedAttributions: attribution.rejected,
10988
+ ...motivationCorrection ? { motivationCorrection } : {}
10257
10989
  },
10258
10990
  referencedFiles: [...referenced].map(([path, sections]) => ({
10259
10991
  path,
@@ -11760,7 +12492,7 @@ function buildReconciliationInstructions(input, options = {}) {
11760
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.`] : [],
11761
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.`,
11762
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.`,
11763
- `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}`
11764
12496
  ].map((task, index) => `${index + 1}. ${task}`).join("\n");
11765
12497
  return `You are RECONCILING an earlier analysis of this pull request against the changes pushed since that analysis was made.
11766
12498
 
@@ -12317,6 +13049,7 @@ function movable(fetched) {
12317
13049
  };
12318
13050
  copy("headSha");
12319
13051
  copy("title");
13052
+ copy("body");
12320
13053
  copy("filesUrl");
12321
13054
  copy("fileUrlTemplate");
12322
13055
  copy("author");
@@ -12526,6 +13259,7 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
12526
13259
  const result = {
12527
13260
  schemaVersion: 1,
12528
13261
  summary: parsed.summary || previous.summary,
13262
+ ...previous.motivation ? { motivation: previous.motivation } : {},
12529
13263
  overallRisk: parsed.overallRisk ?? previous.overallRisk,
12530
13264
  recommendation: parsed.recommendation || previous.recommendation,
12531
13265
  nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
@@ -13084,4 +13818,4 @@ function lineageIds(id) {
13084
13818
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
13085
13819
  }
13086
13820
  //#endregion
13087
- export { compactQueue as $, agentById as $n, latestKissRun as $t, createPrState as A, isIssueOutstanding as An, followedBranch as At, toneOf as B, resolveTarget as Bn, phasesFor as Bt, readEvents as C, sectionOf as Cn, claimRequestUrl as Ct, announceJob as D, queueStamp as Dn, fillFileUrlTemplate as Dt, telemetryUploadConsent as E, queueRemove as En, remoteHost as Et, markAllRead as F, processDiscussion as Fn, isTerminal as Ft, extractJsonText as G, resolveModel as Gn, isClaimed as Gt, withUsageRecorded as H, isCommitReview as Hn, purposeOf as Ht, markRead as I, recommendationsOf as In, loadJob as It, readMarker as J, writeAgentPreference as Jn, groupByLineage as Jt, ReviewBeingDeletedError as K, clearAgentPreference as Kn, decideKissRecommendation as Kt, pruneNotifications as L, describeAuthorship as Ln, saveJob as Lt, eventName as M, issuesOutstanding as Mn, allJobs as Mt, headline as N, questionsOutstanding as Nn, endedAt as Nt, branchOf as O, readQueue as On, checkCodeFreshness as Ot, listNotifications as P, reviewerDispositions as Pn, fail as Pt, activateQueued as Q, DEFAULT_AGENT_ID as Qn, kissDecisionsOf as Qt, readNotification as R, WITHHELD_REASON as Rn, PHASES as Rt, startJob as S, parseLifecycle as Sn, writePortPreference as St, recordTelemetryUploadConsent as T, queueAddMany as Tn, forgeFor as Tt, EXPLAIN_SIMPLY_PROMPT as U, git as Un, clearClaim as Ut, unreadCount as V, displayRef as Vn, progressSteps as Vt, asString as W, resolveAgent as Wn, driverGone as Wt, modelHelpLines as X, STORE_ROOT as Xn, indexReviewGroups as Xt, removeWorktree as Y, CONFIG_PATH as Yn, indexKeptIds as Yt, relativeTime as Z, AGENT_IDS as Zn, kissDecisionRefusal as Zt, agentEnvOf as _, isLifecycle as _n, MAX_WORKSPACE_PORT as _t, wasBlocked as a, positionInLineage as an, ensureServer as at, reanalyseReview as b, lifecyclePill as bn, resolveWorkspacePort as bt, RefreshUnavailableError as c, recentWorkspaces as cn, serverEntryExists as ct, refreshPrContext as d, summarise as dn, workspaceUrl as dt, lineageKeyFor as en, agentFor as er, queueForReview as et, isUnchanged as f, touchWorkspace as fn, writeServerRecord as ft, runningJobs as g, activityLabel as gn, DEFAULT_WORKSPACE_PORT as gt, liveUpdateFor as h, LIFECYCLES as hn, feedbackUrl as ht, liveJobsFor as i, mutateWorkspace as in, clearServerRecord as it, createPrUpdate as j, isQuestionOutstanding as jn, reviewedShaOf as jt, subjectOf as k, doneVerb as kn, checkReview as kt, mergeUpdateInto as l, reviewedCommit as ln, spawnServer as lt, UpdateAlreadyRunningError as m, workspaceStamps as mn, PACKAGE_NAME as mt, deleteReview as n, loadWorkspace as nn, AgentCancelledError as nr, readIndexToken as nt, discardJob as o, presentedKissRunOf as on, readServerRecord as ot, askCheckout as p, workspaceRevision as pn, BUILD_VERSION as pt, MANAGED_ROOT as q, readAgentPreference as qn, followedRefName as qt, lineageIds as r, matchingWorkspaceIds as rn, AgentUnavailableError as rr, bootstrapUrl as rt, FETCH_TIMEOUT_MS as s, previousKissResult as sn, reviewsUrl as st, deleteLineage as t, lineagePosition as tn, detectAgents as tr, settleQueue as tt, updateReview as u, reviewedRepositories as un, stopServer as ut, recordKissRun as v, lifecycleAction as vn, MIN_WORKSPACE_PORT as vt, record as w, queueAdd as wn, forgeResolver as wt, runJob as x, offersLifecycle as xn, statedWorkspacePort as xt, startKissJob as y, lifecycleChip as yn, parseWorkspacePort as yt, removeNotification as z, kissOutcome as zn, isLiveJob 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 };