prreviewbuddy 0.75.0 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3304,7 +3304,7 @@ async function ensureCommitByPlainFetch(root, sha) {
3304
3304
  if (await hasCommit(root, sha)) return;
3305
3305
  throw new TargetError(`The commit this request is on (${sha.slice(0, 7)}) is not in this repository, even after fetching.`);
3306
3306
  }
3307
- function where(source, remote) {
3307
+ function where$1(source, remote) {
3308
3308
  const repo = `${source.repo.host}/${source.repo.owner}/${source.repo.repo}`;
3309
3309
  return remote ? `${remote} (${repo})` : source.url;
3310
3310
  }
@@ -3314,7 +3314,7 @@ function gitSaid(error) {
3314
3314
  }
3315
3315
  function couldNotFetch(source, remote, error) {
3316
3316
  const said = gitSaid(error);
3317
- return `Could not fetch the head commit for pull request #${source.number} from ${where(source, remote)}.` + (said ? ` Git said: ${said}` : "");
3317
+ return `Could not fetch the head commit for pull request #${source.number} from ${where$1(source, remote)}.` + (said ? ` Git said: ${said}` : "");
3318
3318
  }
3319
3319
  async function wrongCommit(root, source, internal, expected) {
3320
3320
  const found = await refSha(root, internal);
@@ -4751,8 +4751,6 @@ function emptyProcessedDiscussion() {
4751
4751
  }
4752
4752
  };
4753
4753
  }
4754
- //#endregion
4755
- //#region ../../packages/review-harness/src/domain/discussion/discussion_processor.ts
4756
4754
  var DEFAULTS = {
4757
4755
  excludeBots: true,
4758
4756
  maxCommentsPerThread: 6,
@@ -5177,7 +5175,16 @@ function tally(items, outstanding, byReviewer) {
5177
5175
  };
5178
5176
  }
5179
5177
  function issuesOutstanding(issues, dispositions) {
5180
- return tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && dispositions.isDismissed(issue));
5178
+ const counts = tally(issues, (issue) => isIssueOutstanding(issue, dispositions), (issue) => issue.reconciliationStatus !== "resolved" && dispositions.isDismissed(issue));
5179
+ const byConversation = issues.filter(resolvedByConversation).length;
5180
+ return byConversation > 0 ? {
5181
+ ...counts,
5182
+ byConversation
5183
+ } : counts;
5184
+ }
5185
+ /** A finding whose resolution, as it stands, rests on the pull request conversation rather than a commit. */
5186
+ function resolvedByConversation(issue) {
5187
+ return issue.reconciliationStatus === "resolved" && issue.statusHistory?.at(-1)?.basis === "conversation";
5181
5188
  }
5182
5189
  function questionsOutstanding(questions, answered) {
5183
5190
  return tally(questions, (question) => isQuestionOutstanding(question, answered), (question) => question.reconciliationStatus !== "answered" && answered.has(question.id));
@@ -6756,12 +6763,12 @@ var UPDATE_PHASES = {
6756
6763
  done: "Re-read pull request conversation"
6757
6764
  },
6758
6765
  context: {
6759
- doing: "Preparing a checkout of the new commits",
6760
- done: "Prepared a checkout of the new commits"
6766
+ doing: "Preparing a checkout of the latest commit",
6767
+ done: "Prepared a checkout of the latest commit"
6761
6768
  },
6762
6769
  analysing: {
6763
- doing: "Reassessing the review against the new commits",
6764
- done: "Reassessed the review against the new commits"
6770
+ doing: "Reassessing the review against what changed",
6771
+ done: "Reassessed the review against what changed"
6765
6772
  },
6766
6773
  done: {
6767
6774
  doing: "Finishing",
@@ -7055,14 +7062,19 @@ function sanitisePrContextReporting(value) {
7055
7062
  if (thread) discussions.push(thread);
7056
7063
  if (discussions.length === 400) break;
7057
7064
  }
7065
+ const coverage = sanitiseCoverage(value.coverage);
7066
+ if (coverage && discussions.length === 400) coverage.threads = "capped";
7058
7067
  return {
7059
7068
  discussion: {
7060
7069
  schemaVersion: 1,
7061
7070
  discussions,
7062
- decisions: Array.isArray(value.decisions) ? value.decisions.map(sanitiseDecision).filter((d) => d !== null) : [],
7071
+ decisions: sanitiseDecisions(value.decisions),
7072
+ ...Array.isArray(value.reviews) ? { reviews: sanitiseDecisions(value.reviews) } : {},
7063
7073
  overallDecision: DECISIONS$1.includes(value.overallDecision) ? value.overallDecision : null,
7064
7074
  truncated: value.truncated === true || discussions.length === 400,
7065
- fetchedAt: Date.now()
7075
+ ...coverage ? { coverage } : {},
7076
+ fetchedAt: Date.now(),
7077
+ ...typeof value.requestedAt === "number" && Number.isFinite(value.requestedAt) ? { requestedAt: value.requestedAt } : {}
7066
7078
  },
7067
7079
  droppedThreads: offered - discussions.length
7068
7080
  };
@@ -7095,6 +7107,7 @@ function sanitiseComment(value) {
7095
7107
  const id = label(value.id);
7096
7108
  const body = typeof value.body === "string" ? value.body.trim() : "";
7097
7109
  if (!id || !body) return null;
7110
+ const editedAt = timestamp(value.editedAt);
7098
7111
  return {
7099
7112
  id,
7100
7113
  author: label(value.author) || "unknown",
@@ -7102,7 +7115,8 @@ function sanitiseComment(value) {
7102
7115
  body,
7103
7116
  timestamp: timestamp(value.timestamp),
7104
7117
  replyToId: label(value.replyToId) || null,
7105
- url: href$1(value.url)
7118
+ url: href$1(value.url),
7119
+ ...editedAt ? { editedAt } : {}
7106
7120
  };
7107
7121
  }
7108
7122
  function sanitiseLocation(value) {
@@ -7117,11 +7131,16 @@ function sanitiseLocation(value) {
7117
7131
  commitSha: label(value.commitSha) || null
7118
7132
  };
7119
7133
  }
7134
+ function sanitiseDecisions(value) {
7135
+ return Array.isArray(value) ? value.map(sanitiseDecision).filter((d) => d !== null) : [];
7136
+ }
7120
7137
  function sanitiseDecision(value) {
7121
7138
  if (!isRecord$1(value)) return null;
7122
7139
  const reviewer = label(value.reviewer);
7123
7140
  if (!reviewer || !VERDICTS$1.includes(value.verdict)) return null;
7141
+ const id = label(value.id);
7124
7142
  return {
7143
+ ...id ? { id } : {},
7125
7144
  reviewer,
7126
7145
  reviewerIsBot: value.reviewerIsBot === true,
7127
7146
  verdict: value.verdict,
@@ -7129,6 +7148,24 @@ function sanitiseDecision(value) {
7129
7148
  timestamp: timestamp(value.timestamp)
7130
7149
  };
7131
7150
  }
7151
+ var COVERAGES = ["complete", "capped"];
7152
+ /**
7153
+ * All three collections or nothing. A coverage record with a collection missing or misspelled says
7154
+ * nothing provable about that collection, and "complete" is the one value that must be proved.
7155
+ */
7156
+ function sanitiseCoverage(value) {
7157
+ if (!isRecord$1(value)) return null;
7158
+ const read = (field) => COVERAGES.includes(field) ? field : null;
7159
+ const threads = read(value.threads);
7160
+ const conversation = read(value.conversation);
7161
+ const reviews = read(value.reviews);
7162
+ if (!threads || !conversation || !reviews) return null;
7163
+ return {
7164
+ threads,
7165
+ conversation,
7166
+ reviews
7167
+ };
7168
+ }
7132
7169
  function sanitisePullRequest(value) {
7133
7170
  if (!isRecord$1(value)) return null;
7134
7171
  const number = count$1(value.number);
@@ -7376,7 +7413,14 @@ async function ghGraphql(query, variables, cwd, env) {
7376
7413
  "-F",
7377
7414
  `number=${variables.number}`
7378
7415
  ];
7379
- if (variables.threadCursor !== void 0) args.push("-f", `threadCursor=${variables.threadCursor}`);
7416
+ for (const name of [
7417
+ "threadCursor",
7418
+ "commentCursor",
7419
+ "reviewCursor"
7420
+ ]) {
7421
+ const cursor = variables[name];
7422
+ if (cursor !== void 0) args.push("-f", `${name}=${cursor}`);
7423
+ }
7380
7424
  const body = await ghJson(args, cwd, env);
7381
7425
  if (typeof body !== "object" || body === null || !("data" in body)) throw new GhError("The GitHub API answered without a data payload.");
7382
7426
  return body.data;
@@ -7433,6 +7477,7 @@ function mapComment(raw) {
7433
7477
  const body = asString(node.body);
7434
7478
  if (!id || body === null) return null;
7435
7479
  const { login, isBot } = readAuthor(node.author);
7480
+ const editedAt = asString(node.lastEditedAt);
7436
7481
  return {
7437
7482
  id,
7438
7483
  author: login,
@@ -7440,11 +7485,36 @@ function mapComment(raw) {
7440
7485
  body,
7441
7486
  timestamp: asString(node.createdAt) ?? "",
7442
7487
  replyToId: asString(path(node, "replyTo", "id")),
7443
- url: asString(node.url) ?? ""
7488
+ url: asString(node.url) ?? "",
7489
+ ...editedAt ? { editedAt } : {}
7444
7490
  };
7445
7491
  }
7446
- function mapComments(connection) {
7447
- return nodesOf(connection).map(mapComment).filter((c) => c !== null);
7492
+ function mapComments(nodes) {
7493
+ return nodes.map(mapComment).filter((c) => c !== null);
7494
+ }
7495
+ /**
7496
+ * A thread's comment nodes in order: the opening comment, then the most recent replies, each once.
7497
+ *
7498
+ * Two connections because one cannot say "the first and the last few". When a thread is short the
7499
+ * two overlap completely, so the merge is by id. The single `comments` connection is what payloads
7500
+ * recorded before the split carry, and it is read the same way.
7501
+ */
7502
+ function threadCommentNodes(node) {
7503
+ const merged = [];
7504
+ const seen = /* @__PURE__ */ new Set();
7505
+ for (const raw of [
7506
+ ...nodesOf(node.opening),
7507
+ ...nodesOf(node.recent),
7508
+ ...nodesOf(node.comments)
7509
+ ]) {
7510
+ const id = asString(asRecord(raw)?.id);
7511
+ if (id !== null) {
7512
+ if (seen.has(id)) continue;
7513
+ seen.add(id);
7514
+ }
7515
+ merged.push(raw);
7516
+ }
7517
+ return merged;
7448
7518
  }
7449
7519
  function mapLocation(node, comments) {
7450
7520
  const file = asString(node.path);
@@ -7480,10 +7550,10 @@ function mapThread(raw) {
7480
7550
  if (!node) return null;
7481
7551
  const id = asString(node.id);
7482
7552
  if (!id) return null;
7483
- const rawComments = nodesOf(node.comments);
7484
- const comments = mapComments(node.comments);
7553
+ const rawComments = threadCommentNodes(node);
7554
+ const comments = mapComments(rawComments);
7485
7555
  if (comments.length === 0) return null;
7486
- const totalCount = asNumber(path(node, "comments", "totalCount")) ?? comments.length;
7556
+ const totalCount = asNumber(path(node, "opening", "totalCount")) ?? asNumber(path(node, "comments", "totalCount")) ?? comments.length;
7487
7557
  return {
7488
7558
  id,
7489
7559
  type: "review_thread",
@@ -7529,7 +7599,9 @@ function mapReview(raw) {
7529
7599
  const verdict = state ? VERDICTS[state] : void 0;
7530
7600
  if (!verdict) return null;
7531
7601
  const { login, isBot } = readAuthor(node.author);
7602
+ const id = asString(node.id);
7532
7603
  return {
7604
+ ...id ? { id } : {},
7533
7605
  reviewer: login,
7534
7606
  reviewerIsBot: isBot,
7535
7607
  verdict,
@@ -7537,15 +7609,73 @@ function mapReview(raw) {
7537
7609
  timestamp: asString(node.submittedAt) ?? ""
7538
7610
  };
7539
7611
  }
7612
+ /**
7613
+ * The standing verdict per reviewer: their newest submission. What `latestReviews` used to answer,
7614
+ * derived from the full history instead so both views come from one fetch and cannot disagree.
7615
+ * Ordered by each reviewer's first appearance, which keeps the rendered list stable across fetches.
7616
+ */
7617
+ function latestPerReviewer(reviews) {
7618
+ const latest = /* @__PURE__ */ new Map();
7619
+ for (const review of reviews) {
7620
+ const held = latest.get(review.reviewer);
7621
+ if (!held || review.timestamp >= held.timestamp) latest.set(review.reviewer, review);
7622
+ }
7623
+ return [...latest.values()];
7624
+ }
7625
+ /**
7626
+ * Whether the last page of a collection says it ended there.
7627
+ *
7628
+ * Complete only on the provider's own word. A collection with no `pageInfo` at all, which is every
7629
+ * payload recorded before it was requested, proves nothing and is therefore capped: coverage is
7630
+ * what lets a delta call a missing comment deleted, so guessing "complete" would manufacture
7631
+ * deletions out of a page boundary.
7632
+ */
7633
+ function coverageOf(lastConnection, more) {
7634
+ const info = asRecord(lastConnection?.pageInfo);
7635
+ if (!info || typeof info[more] !== "boolean") return {
7636
+ coverage: "capped",
7637
+ provenCapped: false
7638
+ };
7639
+ return info[more] ? {
7640
+ coverage: "capped",
7641
+ provenCapped: true
7642
+ } : {
7643
+ coverage: "complete",
7644
+ provenCapped: false
7645
+ };
7646
+ }
7540
7647
  function mapDiscussionResponseReporting(pages, options) {
7541
7648
  const discussions = [];
7542
- const decisions = [];
7649
+ const legacyDecisions = [];
7650
+ let sawLatest = false;
7651
+ const reviews = [];
7543
7652
  const seenThreadIds = /* @__PURE__ */ new Set();
7653
+ const seenReviewIds = /* @__PURE__ */ new Set();
7654
+ let sawReviews = false;
7655
+ let lastThreads = null;
7656
+ let lastConversation = null;
7657
+ let lastReviews = null;
7544
7658
  let refused = 0;
7545
7659
  let overallDecision = null;
7546
7660
  for (const page of pages) {
7547
7661
  const pr = path(page, "repository", "pullRequest");
7548
7662
  if (!asRecord(pr)) continue;
7663
+ lastThreads = asRecord(path(pr, "reviewThreads")) ?? lastThreads;
7664
+ lastConversation = asRecord(path(pr, "comments")) ?? lastConversation;
7665
+ const reviewConnection = asRecord(path(pr, "reviews"));
7666
+ if (reviewConnection) {
7667
+ sawReviews = true;
7668
+ lastReviews = reviewConnection;
7669
+ for (const raw of nodesOf(reviewConnection)) {
7670
+ const review = mapReview(raw);
7671
+ if (!review) continue;
7672
+ if (review.id) {
7673
+ if (seenReviewIds.has(review.id)) continue;
7674
+ seenReviewIds.add(review.id);
7675
+ }
7676
+ reviews.push(review);
7677
+ }
7678
+ }
7549
7679
  for (const raw of nodesOf(path(pr, "reviewThreads"))) {
7550
7680
  const thread = mapThread(raw);
7551
7681
  if (!thread) refused++;
@@ -7562,25 +7692,46 @@ function mapDiscussionResponseReporting(pages, options) {
7562
7692
  discussions.push(conversation);
7563
7693
  }
7564
7694
  }
7695
+ if (asRecord(path(pr, "latestReviews"))) sawLatest = true;
7565
7696
  for (const raw of nodesOf(path(pr, "latestReviews"))) {
7566
7697
  const review = mapReview(raw);
7567
- if (review) decisions.push(review);
7698
+ if (review) legacyDecisions.push(review);
7568
7699
  }
7569
7700
  const decision = asString(path(pr, "reviewDecision"));
7570
7701
  if (decision && DECISIONS[decision]) overallDecision = DECISIONS[decision];
7571
7702
  }
7703
+ const threads = coverageOf(lastThreads, "hasNextPage");
7704
+ const conversation = coverageOf(lastConversation, "hasPreviousPage");
7705
+ const reviewCoverage = coverageOf(lastReviews, "hasPreviousPage");
7706
+ reviews.sort((a, b) => a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0);
7707
+ const coverage = {
7708
+ threads: options.truncated ? "capped" : threads.coverage,
7709
+ conversation: conversation.coverage,
7710
+ reviews: sawReviews ? reviewCoverage.coverage : "capped"
7711
+ };
7572
7712
  return {
7573
7713
  discussion: {
7574
7714
  schemaVersion: 1,
7575
7715
  discussions,
7576
- decisions,
7716
+ decisions: sawLatest || !sawReviews ? legacyDecisions : latestPerReviewer(reviews),
7717
+ ...sawReviews ? { reviews } : {},
7577
7718
  overallDecision,
7578
- truncated: options.truncated,
7579
- fetchedAt: options.fetchedAt
7719
+ truncated: options.truncated || threads.provenCapped || conversation.provenCapped || reviewCoverage.provenCapped,
7720
+ coverage,
7721
+ fetchedAt: options.fetchedAt,
7722
+ ...options.requestedAt !== void 0 ? { requestedAt: options.requestedAt } : {}
7580
7723
  },
7581
7724
  refused
7582
7725
  };
7583
7726
  }
7727
+ var RECENT_COMMENTS_PER_THREAD = 9;
7728
+ var COMMENT_FIELDS = `
7729
+ id
7730
+ body
7731
+ createdAt
7732
+ lastEditedAt
7733
+ url
7734
+ author { login __typename }`;
7584
7735
  var THREAD_FIELDS = `
7585
7736
  pageInfo { hasNextPage endCursor }
7586
7737
  nodes {
@@ -7591,20 +7742,41 @@ var THREAD_FIELDS = `
7591
7742
  line
7592
7743
  originalLine
7593
7744
  diffSide
7594
- comments(first: 10) {
7745
+ opening: comments(first: 1) {
7595
7746
  totalCount
7596
- nodes {
7597
- id
7598
- body
7599
- createdAt
7600
- url
7601
- author { login __typename }
7747
+ nodes {${COMMENT_FIELDS}
7748
+ replyTo { id }
7749
+ originalCommit { oid }
7750
+ }
7751
+ }
7752
+ recent: comments(last: ${RECENT_COMMENTS_PER_THREAD}) {
7753
+ nodes {${COMMENT_FIELDS}
7602
7754
  replyTo { id }
7603
7755
  originalCommit { oid }
7604
7756
  }
7605
7757
  }
7606
7758
  }`;
7607
- /** First page: threads plus the connections that never need paginating. */
7759
+ var CONVERSATION_FIELDS = `
7760
+ pageInfo { hasPreviousPage startCursor }
7761
+ nodes {${COMMENT_FIELDS}
7762
+ }`;
7763
+ var REVIEW_NODE = `
7764
+ id
7765
+ state
7766
+ body
7767
+ submittedAt
7768
+ author { login __typename }`;
7769
+ var REVIEW_FIELDS = `
7770
+ pageInfo { hasPreviousPage startCursor }
7771
+ nodes {${REVIEW_NODE}
7772
+ }`;
7773
+ /**
7774
+ * First page: one page of each collection.
7775
+ *
7776
+ * Its variables are unchanged, so the extension's transport, which walks threads alone, keeps
7777
+ * sending this document as it always has. The mapper reads each collection's `pageInfo`, so a
7778
+ * collection nobody walked further is recorded as capped rather than presented as whole.
7779
+ */
7608
7780
  var PR_DISCUSSION_QUERY = `
7609
7781
  query PRDiscussion($owner: String!, $repo: String!, $number: Int!, $threadCursor: String) {
7610
7782
  repository(owner: $owner, name: $repo) {
@@ -7613,29 +7785,20 @@ query PRDiscussion($owner: String!, $repo: String!, $number: Int!, $threadCursor
7613
7785
  reviewThreads(first: 50, after: $threadCursor) {${THREAD_FIELDS}
7614
7786
  }
7615
7787
  latestReviews(first: 50) {
7616
- nodes {
7617
- state
7618
- body
7619
- submittedAt
7620
- author { login __typename }
7788
+ nodes {${REVIEW_NODE}
7621
7789
  }
7622
7790
  }
7623
- comments(last: 50) {
7624
- nodes {
7625
- id
7626
- body
7627
- createdAt
7628
- url
7629
- author { login __typename }
7630
- }
7791
+ reviews(last: 50) {${REVIEW_FIELDS}
7792
+ }
7793
+ comments(last: 50) {${CONVERSATION_FIELDS}
7631
7794
  }
7632
7795
  }
7633
7796
  }
7634
7797
  rateLimit { cost remaining }
7635
7798
  }`;
7636
7799
  /**
7637
- * Continuation pages: threads only. Re-requesting reviews and conversation on every page would
7638
- * burn ~100 nodes per page re-fetching data we already hold.
7800
+ * Continuation pages, one document per collection. Re-requesting the others on every page would
7801
+ * burn nodes re-fetching data we already hold.
7639
7802
  */
7640
7803
  var PR_DISCUSSION_THREADS_QUERY = `
7641
7804
  query PRDiscussionThreads($owner: String!, $repo: String!, $number: Int!, $threadCursor: String!) {
@@ -7647,6 +7810,35 @@ query PRDiscussionThreads($owner: String!, $repo: String!, $number: Int!, $threa
7647
7810
  }
7648
7811
  rateLimit { cost remaining }
7649
7812
  }`;
7813
+ /** Older conversation comments, walking backwards from the page before. */
7814
+ var PR_DISCUSSION_COMMENTS_QUERY = `
7815
+ query PRDiscussionComments($owner: String!, $repo: String!, $number: Int!, $commentCursor: String!) {
7816
+ repository(owner: $owner, name: $repo) {
7817
+ pullRequest(number: $number) {
7818
+ comments(last: 50, before: $commentCursor) {${CONVERSATION_FIELDS}
7819
+ }
7820
+ }
7821
+ }
7822
+ rateLimit { cost remaining }
7823
+ }`;
7824
+ /**
7825
+ * Earlier review submissions, walking backwards from the newest like conversation comments do.
7826
+ *
7827
+ * Newest first because a cap that binds should cost the oldest history, never the reviews a delta
7828
+ * exists to find. Measured, not supposed: a busy public request ran to more than 150 reviews and
7829
+ * the forward walk stopped before its latest ones. The standing verdicts do not depend on this
7830
+ * walk: `latestReviews` above answers them whole, one per reviewer.
7831
+ */
7832
+ var PR_DISCUSSION_REVIEWS_QUERY = `
7833
+ query PRDiscussionReviews($owner: String!, $repo: String!, $number: Int!, $reviewCursor: String!) {
7834
+ repository(owner: $owner, name: $repo) {
7835
+ pullRequest(number: $number) {
7836
+ reviews(last: 50, before: $reviewCursor) {${REVIEW_FIELDS}
7837
+ }
7838
+ }
7839
+ }
7840
+ rateLimit { cost remaining }
7841
+ }`;
7650
7842
  //#endregion
7651
7843
  //#region ../../packages/review-harness/src/forge/adapters/github/github_adapter.ts
7652
7844
  var GITHUB_FORGE_ID = "github";
@@ -7911,30 +8103,54 @@ async function findRequest(repo, branch, sha, env, cwd) {
7911
8103
  return null;
7912
8104
  }
7913
8105
  async function fetchConversation$1(repo, number, env, cwd) {
7914
- const availability = await ghAvailable(cwd, env, repo.host);
7915
- if (!availability.ok) return {
7916
- kind: availability.kind,
7917
- cli: availability.cli
7918
- };
8106
+ const requestedAt = Date.now();
7919
8107
  try {
7920
- const pages = [];
7921
- let cursor;
7922
- let truncated = false;
7923
- for (let page = 0; page < 3; page++) {
7924
- const data = await ghGraphql(cursor === void 0 ? PR_DISCUSSION_QUERY : PR_DISCUSSION_THREADS_QUERY, {
7925
- owner: repo.owner,
7926
- repo: repo.repo,
7927
- number,
7928
- ...cursor === void 0 ? {} : { threadCursor: cursor }
7929
- }, cwd, env);
7930
- pages.push(data);
7931
- const next = nextCursor(data);
7932
- if (!next) break;
7933
- cursor = next;
7934
- if (page === 2) truncated = true;
7935
- }
7936
- return assemble$1(pages, truncated, await viewRequest(repo, String(number), cwd, env));
8108
+ const base = {
8109
+ owner: repo.owner,
8110
+ repo: repo.repo,
8111
+ number
8112
+ };
8113
+ const identity = viewRequest(repo, String(number), cwd, env);
8114
+ identity.catch(() => void 0);
8115
+ const first = await ghGraphql(PR_DISCUSSION_QUERY, base, cwd, env);
8116
+ const walk = async (connection, query, cursorName, maxPages) => {
8117
+ const pages = [];
8118
+ let cursor = nextCursor(first, connection);
8119
+ for (let page = 1; cursor && page < maxPages; page++) {
8120
+ const data = await ghGraphql(query, {
8121
+ ...base,
8122
+ [cursorName]: cursor
8123
+ }, cwd, env);
8124
+ pages.push(data);
8125
+ cursor = nextCursor(data, connection);
8126
+ }
8127
+ return pages;
8128
+ };
8129
+ const [threads, comments, reviews] = await Promise.all([
8130
+ walk("reviewThreads", PR_DISCUSSION_THREADS_QUERY, "threadCursor", 3),
8131
+ walk("comments", PR_DISCUSSION_COMMENTS_QUERY, "commentCursor", 3),
8132
+ walk("reviews", PR_DISCUSSION_REVIEWS_QUERY, "reviewCursor", 3)
8133
+ ]);
8134
+ return assemble$1([
8135
+ first,
8136
+ ...threads,
8137
+ ...comments,
8138
+ ...reviews
8139
+ ], false, await identity, requestedAt);
7937
8140
  } catch (error) {
8141
+ let availability;
8142
+ try {
8143
+ availability = await ghAvailable(cwd, env, repo.host);
8144
+ } catch (missing) {
8145
+ return {
8146
+ kind: "failed",
8147
+ detail: missing.message
8148
+ };
8149
+ }
8150
+ if (!availability.ok) return {
8151
+ kind: availability.kind,
8152
+ cli: availability.cli
8153
+ };
7938
8154
  return {
7939
8155
  kind: "failed",
7940
8156
  detail: error.message
@@ -7948,10 +8164,11 @@ async function fetchConversation$1(repo, number, env, cwd) {
7948
8164
  * a real repository once; this is where every decision about what the reviewer ends up seeing is
7949
8165
  * actually made, and it should be reachable from a unit test.
7950
8166
  */
7951
- function assemble$1(pages, truncated, raw) {
8167
+ function assemble$1(pages, truncated, raw, requestedAt) {
7952
8168
  const mapped = mapDiscussionResponseReporting(pages, {
7953
8169
  truncated,
7954
- fetchedAt: Date.now()
8170
+ fetchedAt: Date.now(),
8171
+ ...requestedAt !== void 0 ? { requestedAt } : {}
7955
8172
  });
7956
8173
  const sanitised = sanitisePrContextReporting(mapped.discussion);
7957
8174
  const pullRequest = sanitisePullRequest(identity(raw));
@@ -7975,9 +8192,19 @@ function assemble$1(pages, truncated, raw) {
7975
8192
  }
7976
8193
  };
7977
8194
  }
7978
- function nextCursor(data) {
8195
+ /**
8196
+ * Where a collection's walk goes next, or undefined when the page says it was the last.
8197
+ *
8198
+ * Conversation comments and reviews walk backwards from the newest, so theirs is the previous
8199
+ * page's start; threads walk forwards.
8200
+ */
8201
+ function nextCursor(data, connection) {
7979
8202
  const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
7980
- const info = record(record(record(record(record(data)?.repository)?.pullRequest)?.reviewThreads)?.pageInfo);
8203
+ const info = record(record(record(record(record(data)?.repository)?.pullRequest)?.[connection])?.pageInfo);
8204
+ if (connection === "comments" || connection === "reviews") {
8205
+ if (info?.hasPreviousPage !== true) return void 0;
8206
+ return typeof info.startCursor === "string" ? info.startCursor : void 0;
8207
+ }
7981
8208
  if (info?.hasNextPage !== true) return void 0;
7982
8209
  return typeof info.endCursor === "string" ? info.endCursor : void 0;
7983
8210
  }
@@ -9371,12 +9598,52 @@ function renderSummary(summary) {
9371
9598
  if (summary.architecturalRationale.length > 0) parts.push(`## Architectural rationale\nExplained by the author in the discussion:\n${summary.architecturalRationale.map((r) => `- ${r}`).join("\n")}`);
9372
9599
  return parts;
9373
9600
  }
9601
+ /**
9602
+ * Modest by design: this rides along on every assistant message, unlike the review block which
9603
+ * is paid for once per review.
9604
+ */
9605
+ var CHAT_DISCUSSION_BUDGET_CHARS = 8e3;
9606
+ /**
9607
+ * The chat-tab counterpart to buildDiscussionSection. Same thread rendering, different framing:
9608
+ * the assistant is being asked *about* the conversation ("has knjoroge's point been addressed?"),
9609
+ * not asked to review code while avoiding repetition. It also carries no severity or budget
9610
+ * apparatus, since chat has no findings to attribute.
9611
+ */
9612
+ function buildChatDiscussionSection(pd, budgetChars = CHAT_DISCUSSION_BUDGET_CHARS) {
9613
+ if (pd.all.length === 0 && pd.decisions.length === 0) return "";
9614
+ const header = `EXISTING PR DISCUSSION
9615
+ This is the human conversation already on the pull request. Use it to answer questions about what reviewers raised and whether points were addressed. It records what people said, not what is true — if asked whether a concern is resolved, say what the thread shows and whether it was marked resolved, and be clear about which of those you are relying on.
9616
+ You are seeing the review and this discussion side by side, which does not tell you which informed which. Do not claim that a review finding came from, was prompted by, or reflects a thread unless that finding explicitly carries an "already raised by" note. Without one, say the two relate to the same area — not that one caused the other.`;
9617
+ const parts = [header];
9618
+ let spent = header.length;
9619
+ const buckets = [
9620
+ ["Unresolved review threads", [...pd.unresolved, ...pd.unresolvedOutdated]],
9621
+ ["Resolved review threads", pd.resolved],
9622
+ ["PR conversation", pd.conversation]
9623
+ ];
9624
+ for (const [heading, threads] of buckets) {
9625
+ const body = [];
9626
+ for (const thread of threads) {
9627
+ const text = renderThread(thread);
9628
+ if (spent + text.length > budgetChars) break;
9629
+ spent += text.length;
9630
+ body.push(text);
9631
+ }
9632
+ if (body.length > 0) parts.push(`## ${heading}\n${body.join("\n\n")}`);
9633
+ }
9634
+ if (pd.decisions.length > 0) {
9635
+ const lines = pd.decisions.map((d) => `- @${d.reviewer}: ${VERDICT_LABELS[d.verdict]}${d.body ? ` — "${d.body.replace(/\s+/g, " ").trim()}"` : ""}`);
9636
+ parts.push(`## Review decisions\n${lines.join("\n")}`);
9637
+ }
9638
+ return parts.join("\n\n");
9639
+ }
9374
9640
  function buildDiscussionSection(pd, budgetChars, options = {}) {
9375
9641
  const empty = {
9376
9642
  text: "",
9377
9643
  overflow: false,
9378
9644
  includedThreads: 0,
9379
- omittedThreads: 0
9645
+ omittedThreads: 0,
9646
+ includedThreadIds: []
9380
9647
  };
9381
9648
  if (!(pd.all.length > 0 || pd.decisions.length > 0 || pd.overallDecision !== null) || budgetChars <= 0) return empty;
9382
9649
  const perFile = options.maxThreadsPerFile ?? DEFAULT_MAX_THREADS_PER_FILE;
@@ -9411,6 +9678,7 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
9411
9678
  let omitted = 0;
9412
9679
  const perFileNotes = [];
9413
9680
  const rendered = [];
9681
+ const includedThreadIds = [];
9414
9682
  if (options.summary) {
9415
9683
  rendered.push(...renderSummary(options.summary));
9416
9684
  included = options.summary.unresolved.length;
@@ -9428,6 +9696,7 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
9428
9696
  }
9429
9697
  spent += text.length;
9430
9698
  body.push(text);
9699
+ includedThreadIds.push(thread.id);
9431
9700
  included++;
9432
9701
  }
9433
9702
  if (body.length > 0) rendered.push(`${bucket.heading}\n${body.join("\n\n")}`);
@@ -9450,7 +9719,8 @@ function buildDiscussionSection(pd, budgetChars, options = {}) {
9450
9719
  ].filter((part) => part !== "").join("\n\n"),
9451
9720
  overflow: omitted > 0,
9452
9721
  includedThreads: included,
9453
- omittedThreads: omitted
9722
+ omittedThreads: omitted,
9723
+ includedThreadIds
9454
9724
  };
9455
9725
  }
9456
9726
  //#endregion
@@ -9866,6 +10136,7 @@ ${diff}`;
9866
10136
  breakdown: buildPromptBudget(sections).breakdown
9867
10137
  },
9868
10138
  discussionOverflow: discussion.overflow,
10139
+ discussionThreadIds: discussion.includedThreadIds,
9869
10140
  partiallyReadFiles: partialFiles.length,
9870
10141
  partialFiles
9871
10142
  };
@@ -11159,123 +11430,874 @@ function applyMotivation(result, candidate, authorDescription) {
11159
11430
  */
11160
11431
  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.`;
11161
11432
  //#endregion
11162
- //#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
11163
- 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:
11164
- {
11165
- "summary": string,
11166
- "motivation"?: {
11167
- "status": "stated" | "inferred" | "unclear",
11168
- "summary": string,
11169
- "sourceQuote"?: string,
11170
- "evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
11171
- "authorQuestion"?: string
11172
- },
11173
- "overallRisk": "low" | "medium" | "high",
11174
- "sections": [{
11175
- "id": string,
11176
- "order": number,
11177
- "title": string,
11178
- "oneLineSummary": string,
11179
- "purpose": string,
11180
- "files": [{ "path": string, "focus": string }],
11181
- "whyReview": string,
11182
- "checklist": string[],
11183
- "priority": "implementation" | "supporting" | "tests" | "documentation",
11184
- "reviewAttention": "high" | "medium" | "low",
11185
- "reviewWeight": 1 | 2 | 3 | 4 | 5,
11186
- "recommendedStartingPoint": boolean
11187
- }],
11188
- "issues": [{ "id": string, "severity": "critical" | "major" | "minor" | "nit", "title": string, "description": string, "file"?: string, "line"?: number, "sectionId"?: string, "confidence"?: "high" | "medium" | "low", "existingDiscussion"?: { "threadId": string, "relation": "same" | "related" } }],
11189
- "questions": [{ "id": string, "question": string, "importance"?: "high" | "medium" | "low", "whyThisMatters"?: string, "file"?: string, "sectionId"?: string }],
11190
- "recommendation": string,
11191
- "nextSteps": string[]
11433
+ //#region ../../packages/review-harness/src/domain/discussion/discussion_delta.ts
11434
+ /**
11435
+ * Which collections of a snapshot were capped. A snapshot from before coverage was recorded can
11436
+ * say only whether its thread list was truncated, so that is all it is taken to say.
11437
+ */
11438
+ function partialOf(current) {
11439
+ if (!current.coverage) return current.truncated ? ["threads"] : [];
11440
+ return [
11441
+ "threads",
11442
+ "conversation",
11443
+ "reviews"
11444
+ ].filter((collection) => current.coverage?.[collection] === "capped");
11192
11445
  }
11193
-
11194
- Field guidance:
11195
- - ${SUMMARY_GUIDANCE}
11196
- - "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":
11197
- - "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".
11198
- - "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...".
11199
- - "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".
11200
- 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.
11201
- - "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.
11202
- - "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."
11203
- - "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.
11204
- - "sectionId" on issues/questions: reference the "id" of the section that item belongs to. Omit it rather than guessing if an item doesn't clearly belong to one section.
11205
- - "recommendation": a 1-2 sentence merge verdict aimed at the developer (e.g. "Approve" or "Request changes — fix the unhandled rejection before merging"). Do not just restate the risk level, and do not copy Guide section content verbatim into this field.
11206
- - "nextSteps": concrete actions to take before merging (specific tests to run, files to double-check), not a restatement of the Guide.
11207
- - "issues": report only issues worth the reviewer's attention — do not invent issues to fill out the array, and do not omit significant issues. Let the count reflect the actual PR (a trivial PR may have zero or one issue; a large or risky PR may have several).
11208
- - "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.
11209
- - "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.
11210
- - "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.
11211
- - 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.
11212
- - "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.
11213
-
11214
- 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.`;
11215
- function buildStructuredReviewPrompt(pr, options = {}) {
11216
- const { prompt, discussionOverflow, partiallyReadFiles, partialFiles } = buildChatReviewPrompt(pr, [], {
11217
- discussionSummary: options.discussionSummary,
11218
- repositoryAccess: options.repositoryAccess,
11219
- changeInventory: options.changeInventory,
11220
- guideCoverage: options.guideCoverage
11221
- });
11446
+ function refOf(thread) {
11222
11447
  return {
11223
- prompt: `${prompt}\n\n---\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}`,
11224
- discussionOverflow: discussionOverflow ?? false,
11225
- partiallyReadFiles: partiallyReadFiles ?? 0,
11226
- partialFiles: partialFiles ?? []
11448
+ id: thread.id,
11449
+ type: thread.type,
11450
+ location: thread.location
11227
11451
  };
11228
11452
  }
11453
+ function hasHuman(thread) {
11454
+ return thread.comments.some((comment) => !isBot(comment));
11455
+ }
11456
+ /** The review history if the snapshot has one, else its standing verdicts, else none. */
11457
+ function reviewsOf(discussion) {
11458
+ return discussion.reviews ?? discussion.decisions ?? [];
11459
+ }
11460
+ function reviewKey(review) {
11461
+ return `${review.reviewer}\u0000${review.verdict}\u0000${review.timestamp}`;
11462
+ }
11463
+ function after(timestamp, epochMs) {
11464
+ const at = Date.parse(timestamp);
11465
+ return !Number.isNaN(at) && at > epochMs;
11466
+ }
11467
+ /** Everything on the request, offered as new: for a baseline of `none` or `unknown`. */
11468
+ function everything(current, baseline) {
11469
+ const added = [];
11470
+ for (const thread of current.discussions) for (const comment of thread.comments) if (!isBot(comment)) added.push({
11471
+ thread: refOf(thread),
11472
+ comment,
11473
+ newThread: true
11474
+ });
11475
+ return {
11476
+ baseline,
11477
+ added,
11478
+ edited: [],
11479
+ removed: [],
11480
+ vanishedThreads: [],
11481
+ newReviews: reviewsOf(current).filter((review) => !review.reviewerIsBot),
11482
+ resolved: [],
11483
+ reopened: [],
11484
+ partial: partialOf(current)
11485
+ };
11486
+ }
11487
+ function diffDiscussion(baseline, current) {
11488
+ if (baseline === null) return everything(current, "none");
11489
+ if (baseline === "unknown") return everything(current, "unknown");
11490
+ const before = new Map(baseline.discussions.map((thread) => [thread.id, thread]));
11491
+ const nowIds = new Set(current.discussions.map((thread) => thread.id));
11492
+ const delta = {
11493
+ baseline: "recorded",
11494
+ added: [],
11495
+ edited: [],
11496
+ removed: [],
11497
+ vanishedThreads: [],
11498
+ newReviews: [],
11499
+ resolved: [],
11500
+ reopened: [],
11501
+ partial: partialOf(current)
11502
+ };
11503
+ for (const thread of current.discussions) {
11504
+ const previous = before.get(thread.id);
11505
+ const ref = refOf(thread);
11506
+ const previousComments = new Map((previous?.comments ?? []).map((comment) => [comment.id, comment]));
11507
+ for (const comment of thread.comments) {
11508
+ if (isBot(comment)) continue;
11509
+ const held = previousComments.get(comment.id);
11510
+ if (!held) delta.added.push({
11511
+ thread: ref,
11512
+ comment,
11513
+ newThread: !previous
11514
+ });
11515
+ else if (comment.editedAt && comment.editedAt !== held.editedAt) {
11516
+ if (held.editedAt || after(comment.editedAt, baseline.fetchedAt)) delta.edited.push({
11517
+ thread: ref,
11518
+ comment,
11519
+ newThread: false,
11520
+ previous: held
11521
+ });
11522
+ }
11523
+ }
11524
+ if (!previous) continue;
11525
+ if (hasHuman(thread)) {
11526
+ if (thread.resolved && !previous.resolved) delta.resolved.push(ref);
11527
+ if (!thread.resolved && previous.resolved) delta.reopened.push(ref);
11528
+ }
11529
+ if (previous.truncatedCommentCount === 0 && thread.truncatedCommentCount === 0) {
11530
+ const nowComments = new Set(thread.comments.map((comment) => comment.id));
11531
+ for (const comment of previous.comments) if (!isBot(comment) && !nowComments.has(comment.id)) delta.removed.push({
11532
+ thread: ref,
11533
+ comment,
11534
+ newThread: false
11535
+ });
11536
+ }
11537
+ }
11538
+ const complete = (collection) => baseline.coverage?.[collection] === "complete" && current.coverage?.[collection] === "complete";
11539
+ for (const previous of baseline.discussions) {
11540
+ if (nowIds.has(previous.id) || !hasHuman(previous)) continue;
11541
+ if (previous.type === "review_thread" && complete("threads")) delta.vanishedThreads.push(refOf(previous));
11542
+ else if (previous.type === "conversation" && complete("conversation")) {
11543
+ for (const comment of previous.comments) if (!isBot(comment)) delta.removed.push({
11544
+ thread: refOf(previous),
11545
+ comment,
11546
+ newThread: false
11547
+ });
11548
+ }
11549
+ }
11550
+ const history = baseline.reviews;
11551
+ const seenIds = new Set((history ?? []).flatMap((review) => review.id ? [review.id] : []));
11552
+ const seenKeys = new Set((history ?? []).map(reviewKey));
11553
+ for (const review of reviewsOf(current)) {
11554
+ if (review.reviewerIsBot) continue;
11555
+ if (history ? !(review.id && seenIds.has(review.id)) && !seenKeys.has(reviewKey(review)) : after(review.timestamp, baseline.fetchedAt)) delta.newReviews.push(review);
11556
+ }
11557
+ return delta;
11558
+ }
11229
11559
  /**
11230
- * A reply, parsed, with the motivation checked against the description the analysis read.
11560
+ * Whether anything moved that is worth an analysis.
11231
11561
  *
11232
- * The check happens here rather than after, so there is no parsed result anywhere whose motivation
11233
- * has not been through it. `correction` says what the check refused, for a caller with an audit to
11234
- * put it in.
11562
+ * Mechanical, by the processor's own rule: a comment that is only a pleasantry or an emoji is not a
11563
+ * change to reason about on its own, and a bot never was. A thread resolved, a review submitted or
11564
+ * a comment removed always is. Whether any of it matters to a finding is the model's question.
11565
+ *
11566
+ * An edit counts when either side of it says something. Rewriting a substantive comment into
11567
+ * "thanks!" withdraws what it said exactly as deleting it would, and deleting always counts.
11235
11568
  */
11236
- function parseReviewResponse(raw, authorDescription) {
11237
- const { result, motivation } = parseUnchecked(raw);
11238
- const applied = applyMotivation(result, motivation, authorDescription);
11239
- return {
11240
- result: applied.result,
11241
- ...applied.correction ? { motivationCorrection: applied.correction } : {}
11242
- };
11569
+ function hasDiscussionChanges(delta) {
11570
+ const substantive = (entry) => !isNoise(entry.comment);
11571
+ const substantiveEdit = (entry) => substantive(entry) || entry.previous !== void 0 && !isNoise(entry.previous);
11572
+ return delta.added.some(substantive) || delta.edited.some(substantiveEdit) || delta.removed.length > 0 || delta.vanishedThreads.length > 0 || delta.newReviews.length > 0 || delta.resolved.length > 0 || delta.reopened.length > 0;
11243
11573
  }
11244
- /** What tells this reply's answer from a JSON example quoted in the prose before it. */
11245
- var REVIEW_ANSWER = objectWithAnyOf("summary", "recommendation", "issues", "nextSteps");
11246
- function parseUnchecked(raw) {
11247
- const jsonText = extractJsonText(raw, REVIEW_ANSWER);
11248
- let parsed;
11249
- try {
11250
- parsed = JSON.parse(jsonText);
11251
- } catch {
11252
- throw new Error("Could not parse the AI response as JSON.");
11253
- }
11254
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("AI response JSON was not an object.");
11255
- const obj = parsed;
11256
- const summary = asString$1(obj.summary);
11257
- const recommendation = asString$1(obj.recommendation);
11258
- 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.");
11574
+ /**
11575
+ * The conversation as an analysis has now taken it into account: the current snapshot, with every
11576
+ * change it was not shown put back the way the baseline had it.
11577
+ *
11578
+ * What an update records as its baseline. Recording the whole current snapshot would mark a change
11579
+ * that was held back for length, or never shown at all, as accounted for, and the next update would
11580
+ * find nothing new where the model has in fact seen nothing. So each withheld change is undone:
11581
+ * an unseen comment is taken out, an unseen edit restored to what it said before, an unseen
11582
+ * deletion put back, an unseen resolution flipped back, an unseen review dropped from the history.
11583
+ * Each then reads as new to the next update, exactly as it did to this one.
11584
+ *
11585
+ * Everything not in the delta is taken from the current snapshot as it is (bots, coverage, the
11586
+ * standing verdicts), because none of it is evidence the model was or was not given.
11587
+ */
11588
+ /**
11589
+ * The conversation as a first analysis took it into account: the snapshot without the threads its
11590
+ * prompt offered but left out, for length or past the per-file cap.
11591
+ *
11592
+ * The first analysis's half of `deliveredSnapshot`. Recording the whole snapshot would mark those
11593
+ * threads as considered, and an update with nothing new since would find nothing to show. Taken
11594
+ * away, they read to that update as new, which to the model they are.
11595
+ *
11596
+ * Only threads offered and not rendered are taken away. A thread the processor set aside, a bot's or
11597
+ * one of pleasantries only, stays, because no update shows it either and nothing was held back.
11598
+ */
11599
+ function withoutOmittedThreads(discussion, offered, rendered) {
11600
+ const shown = new Set(rendered);
11601
+ const omitted = new Set(offered.filter((id) => !shown.has(id)));
11602
+ if (omitted.size === 0) return discussion;
11259
11603
  return {
11260
- result: {
11261
- schemaVersion: 1,
11262
- summary,
11263
- overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
11264
- sections: normalizeSections(obj.sections),
11265
- issues: normalizeIssues(obj.issues),
11266
- questions: normalizeQuestions(obj.questions),
11267
- recommendation,
11268
- nextSteps: asStringArray(obj.nextSteps)
11269
- },
11270
- motivation: normalizeMotivation(obj.motivation)
11271
- };
11604
+ ...discussion,
11605
+ discussions: discussion.discussions.filter((thread) => !omitted.has(thread.id))
11606
+ };
11607
+ }
11608
+ /** The edit time a restored, never-edited comment carries: earlier than any edit a forge reports. */
11609
+ var NEVER_SEEN_EDIT = "1970-01-01T00:00:00.000Z";
11610
+ function deliveredSnapshot(current, baseline, withheld) {
11611
+ if (withheld.added.length + withheld.edited.length + withheld.removed.length + withheld.vanishedThreads.length + withheld.newReviews.length + withheld.resolved.length + withheld.reopened.length === 0) return current;
11612
+ const next = structuredClone(current);
11613
+ const threads = new Map(next.discussions.map((thread) => [thread.id, thread]));
11614
+ const before = new Map((baseline && baseline !== "unknown" ? baseline.discussions : []).map((thread) => [thread.id, thread]));
11615
+ /** A thread the current snapshot no longer has, put back as the baseline held it. */
11616
+ const restore = (id) => {
11617
+ if (threads.has(id)) return threads.get(id);
11618
+ const held = before.get(id);
11619
+ if (!held) return void 0;
11620
+ const thread = structuredClone(held);
11621
+ next.discussions.push(thread);
11622
+ threads.set(id, thread);
11623
+ return thread;
11624
+ };
11625
+ for (const entry of withheld.added) {
11626
+ const thread = threads.get(entry.thread.id);
11627
+ if (thread) thread.comments = thread.comments.filter((comment) => comment.id !== entry.comment.id);
11628
+ }
11629
+ for (const entry of withheld.edited) {
11630
+ const thread = threads.get(entry.thread.id);
11631
+ const previous = entry.previous && {
11632
+ ...entry.previous,
11633
+ editedAt: entry.previous.editedAt || NEVER_SEEN_EDIT
11634
+ };
11635
+ if (thread && previous) thread.comments = thread.comments.map((comment) => comment.id === previous.id ? previous : comment);
11636
+ }
11637
+ for (const entry of withheld.removed) {
11638
+ const thread = restore(entry.thread.id);
11639
+ if (thread && !thread.comments.some((comment) => comment.id === entry.comment.id)) thread.comments.push(entry.comment);
11640
+ }
11641
+ for (const ref of withheld.vanishedThreads) restore(ref.id);
11642
+ for (const ref of withheld.resolved) {
11643
+ const thread = threads.get(ref.id);
11644
+ if (thread) thread.resolved = false;
11645
+ }
11646
+ for (const ref of withheld.reopened) {
11647
+ const thread = threads.get(ref.id);
11648
+ if (thread) thread.resolved = true;
11649
+ }
11650
+ if (withheld.newReviews.length && next.reviews) {
11651
+ const unseen = new Set(withheld.newReviews);
11652
+ const unseenIds = new Set(withheld.newReviews.flatMap((review) => review.id ? [review.id] : []));
11653
+ const unseenKeys = new Set(withheld.newReviews.map(reviewKey));
11654
+ next.reviews = next.reviews.filter((review) => !unseen.has(review) && !(review.id && unseenIds.has(review.id)) && !unseenKeys.has(reviewKey(review)));
11655
+ }
11656
+ next.discussions = next.discussions.filter((thread) => thread.comments.length > 0);
11657
+ return next;
11272
11658
  }
11273
- 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.";
11274
11659
  //#endregion
11275
- //#region ../../packages/review-harness/src/domain/kiss/prompt.ts
11276
- var SCOPE_BOUNDARY = `Scope:
11277
- Judge the complexity this change introduces or materially expands. That is the whole of what you are reviewing.
11278
- Pre-existing complexity around the change is context you may use to understand it, and is not a finding. Do not attribute it to this author.
11660
+ //#region ../../packages/review-harness/src/domain/discussion/conversation_section.ts
11661
+ /**
11662
+ * The line every state ends with.
11663
+ *
11664
+ * Persuasion, not the boundary: the agent's tool policy is what actually stops a fetch. This is
11665
+ * here so that an answer is not padded with commands the reviewer was never going to need.
11666
+ */
11667
+ var NO_FORGE_RETRIEVAL = "Do not attempt to retrieve pull request or forge data yourself, and do not suggest commands or API calls for retrieving it.";
11668
+ var MISSING_PREVENTS = "Where the missing conversation prevents a conclusion, say that plainly rather than guessing.";
11669
+ var UNAVAILABLE = {
11670
+ "unsupported-forge": "PR Review Buddy does not read pull request conversation on this forge yet",
11671
+ "cli-missing": "the forge's command-line tool is not installed on this machine",
11672
+ "not-authenticated": "the forge's command-line tool is not signed in on this machine",
11673
+ failed: "the fetch failed"
11674
+ };
11675
+ var REREAD_FAILURE = {
11676
+ ...UNAVAILABLE,
11677
+ "too-slow": "the forge took longer to answer than PR Review Buddy waits before replying"
11678
+ };
11679
+ function iso(epochMs) {
11680
+ return Number.isFinite(epochMs) && epochMs > 0 ? new Date(epochMs).toISOString() : "an unknown time";
11681
+ }
11682
+ /** What PR Review Buddy has of the conversation, in a short paragraph that names the state outright. */
11683
+ function renderConversationState(state) {
11684
+ switch (state.kind) {
11685
+ case "attached":
11686
+ if (state.reread === "failed") return [
11687
+ `PR Review Buddy could not re-read the pull request conversation just now${state.rereadReason ? `: ${REREAD_FAILURE[state.rereadReason]}` : ""}.`,
11688
+ `What follows is the copy it fetched at ${iso(state.fetchedAt)}, and it may be out of date. Say so where that matters.`,
11689
+ NO_FORGE_RETRIEVAL
11690
+ ].join(" ");
11691
+ return [state.reread === "fresh" ? `PR Review Buddy fetched the pull request conversation at ${iso(state.fetchedAt)}, for this request.` : `PR Review Buddy fetched the pull request conversation at ${iso(state.fetchedAt)}.`, NO_FORGE_RETRIEVAL].join(" ");
11692
+ case "unavailable": return [
11693
+ `PR Review Buddy could not retrieve the pull request conversation: ${UNAVAILABLE[state.reason]}.`,
11694
+ "None of it is included, so do not conclude that there are no comments.",
11695
+ MISSING_PREVENTS,
11696
+ NO_FORGE_RETRIEVAL
11697
+ ].join(" ");
11698
+ case "no-request": return ["This change is not on a pull request PR Review Buddy could find, so there is no conversation to include.", NO_FORGE_RETRIEVAL].join(" ");
11699
+ case "not-attached": return [
11700
+ "No pull request conversation is attached to this review, so none is included.",
11701
+ MISSING_PREVENTS,
11702
+ NO_FORGE_RETRIEVAL
11703
+ ].join(" ");
11704
+ case "withheld": return [
11705
+ "The reviewer has chosen not to share the pull request conversation, so none is included. Do not conclude that there are no comments.",
11706
+ MISSING_PREVENTS,
11707
+ NO_FORGE_RETRIEVAL
11708
+ ].join(" ");
11709
+ }
11710
+ }
11711
+ /**
11712
+ * A ceiling, not a measurement: one and a half times the assistant's conversation budget
11713
+ * (`CHAT_DISCUSSION_BUDGET_CHARS`), so a first delta against a review made without any
11714
+ * conversation cannot crowd the diff out of an update prompt. Overflow is counted in the text,
11715
+ * so a delta that binds says so rather than reading as complete.
11716
+ */
11717
+ var DELTA_BUDGET_CHARS = 12e3;
11718
+ var DELTA_HEADING = "PR DISCUSSION CHANGES SINCE THIS REVIEW WAS MADE";
11719
+ var BASELINE_LINE = {
11720
+ recorded: "Computed by PR Review Buddy by comparing the conversation this review was given with the conversation now. Times are as the forge reported them.",
11721
+ none: "This review was made without the pull request conversation, so all of it is new to the review.",
11722
+ unknown: "The conversation this review was given was not recorded, so what is listed below may include comments the review already took into account. Treat each item as possibly new, not certainly new."
11723
+ };
11724
+ /** What a delta is measured from, in the sentence the delta section opens with. */
11725
+ function baselineNote(baseline) {
11726
+ return BASELINE_LINE[baseline];
11727
+ }
11728
+ function where(thread) {
11729
+ return `[${thread.id}] ${locationOf(thread)}`;
11730
+ }
11731
+ /** A comment's line names the comment as well as its thread, so a finding can cite exactly what it rests on. */
11732
+ function whereComment(entry) {
11733
+ return `[${entry.thread.id}] [comment ${entry.comment.id}] ${locationOf(entry.thread)}`;
11734
+ }
11735
+ function body(text) {
11736
+ return truncateBody(text.trim(), 800);
11737
+ }
11738
+ function addedLine(entry) {
11739
+ const { comment } = entry;
11740
+ const kind = entry.newThread ? " (new thread)" : comment.replyToId ? " (reply)" : "";
11741
+ return `- ${whereComment(entry)} · @${comment.author} · ${comment.timestamp || "time unknown"}${kind}: ${body(comment.body)}`;
11742
+ }
11743
+ function editedLine(entry) {
11744
+ const { comment, previous } = entry;
11745
+ const before = previous ? ` It previously read: ${body(previous.body)}` : "";
11746
+ return `- ${whereComment(entry)} · @${comment.author} · written ${comment.timestamp || "at an unknown time"}, edited ${comment.editedAt}. It now reads: ${body(comment.body)}${before}`;
11747
+ }
11748
+ function removedLine(entry) {
11749
+ const { comment } = entry;
11750
+ return `- ${whereComment(entry)} · @${comment.author} · written ${comment.timestamp || "at an unknown time"}. It read: ${body(comment.body)}`;
11751
+ }
11752
+ /** Every list of a delta, in the order a reader needs them, with its heading and its line. */
11753
+ var SECTIONS = [
11754
+ {
11755
+ list: "added",
11756
+ heading: (baseline) => baseline === "recorded" ? "## Added" : "## Comments",
11757
+ line: addedLine
11758
+ },
11759
+ {
11760
+ list: "edited",
11761
+ heading: () => "## Edited",
11762
+ line: editedLine
11763
+ },
11764
+ {
11765
+ list: "removed",
11766
+ heading: () => "## Deleted",
11767
+ line: removedLine
11768
+ },
11769
+ {
11770
+ list: "vanishedThreads",
11771
+ heading: () => "## Review threads no longer present",
11772
+ line: ((thread) => `- ${where(thread)}`)
11773
+ },
11774
+ {
11775
+ list: "newReviews",
11776
+ heading: (baseline) => baseline === "recorded" ? "## New reviews submitted" : "## Reviews submitted",
11777
+ line: ((review) => {
11778
+ const text = review.body.trim();
11779
+ return `- ${review.id ? `[review ${review.id}] ` : ""}@${review.reviewer} · ${VERDICT_LABELS[review.verdict]} · ${review.timestamp || "time unknown"}${text ? `: ${body(text)}` : " (no summary; any comments it left on lines are listed above as comments)"}`;
11780
+ })
11781
+ },
11782
+ {
11783
+ list: "resolved",
11784
+ heading: () => "## Threads resolved since",
11785
+ line: ((thread) => `- ${where(thread)}`)
11786
+ },
11787
+ {
11788
+ list: "reopened",
11789
+ heading: () => "## Threads reopened since",
11790
+ line: ((thread) => `- ${where(thread)}`)
11791
+ }
11792
+ ];
11793
+ function emptyLike(delta) {
11794
+ return {
11795
+ ...delta,
11796
+ added: [],
11797
+ edited: [],
11798
+ removed: [],
11799
+ vanishedThreads: [],
11800
+ newReviews: [],
11801
+ resolved: [],
11802
+ reopened: []
11803
+ };
11804
+ }
11805
+ function headerOf(delta) {
11806
+ return `${DELTA_HEADING}\n${BASELINE_LINE[delta.baseline]}`;
11807
+ }
11808
+ /**
11809
+ * Split a delta at a budget, by the same lines it is rendered as.
11810
+ *
11811
+ * Its own step because the split is a fact the caller needs, not only a count in the text: an
11812
+ * update records as taken into account only what the model was actually shown, so a change held
11813
+ * back for length is still new to the next one. Whole lines only, and a line that does not fit does
11814
+ * not stop a shorter one after it from fitting.
11815
+ */
11816
+ function fitDiscussionDelta(delta, budgetChars = DELTA_BUDGET_CHARS) {
11817
+ const shown = emptyLike(delta);
11818
+ const withheld = emptyLike(delta);
11819
+ let spent = headerOf(delta).length;
11820
+ for (const section of SECTIONS) {
11821
+ const items = delta[section.list];
11822
+ if (items.length === 0) continue;
11823
+ const heading = section.heading(delta.baseline);
11824
+ let opened = false;
11825
+ for (const item of items) {
11826
+ const cost = section.line(item).length + 1 + (opened ? 0 : heading.length + 2);
11827
+ if (spent + cost > budgetChars) {
11828
+ withheld[section.list].push(item);
11829
+ continue;
11830
+ }
11831
+ spent += cost;
11832
+ opened = true;
11833
+ shown[section.list].push(item);
11834
+ }
11835
+ }
11836
+ return {
11837
+ shown,
11838
+ withheld
11839
+ };
11840
+ }
11841
+ /** How many changes a delta lists. */
11842
+ function changeCount(delta) {
11843
+ return SECTIONS.reduce((total, section) => total + delta[section.list].length, 0);
11844
+ }
11845
+ /**
11846
+ * The delta as a prompt section, within a budget.
11847
+ *
11848
+ * Whole lines only, in the order a reader needs them: what was said, then what was rewritten or
11849
+ * withdrawn, then verdicts and resolution. Anything that did not fit is counted in a closing note,
11850
+ * because a delta that silently stops reads as a complete one.
11851
+ */
11852
+ function renderDiscussionDelta(delta, budgetChars = DELTA_BUDGET_CHARS) {
11853
+ const header = headerOf(delta);
11854
+ const partial = partialNote(delta);
11855
+ if (changeCount(delta) === 0) return `${header}\n\nNothing in the conversation has changed since this review was made.${partial ? `\n\n${partial}` : ""}`;
11856
+ const { shown, withheld } = fitDiscussionDelta(delta, budgetChars);
11857
+ const parts = [header];
11858
+ for (const section of SECTIONS) {
11859
+ const items = shown[section.list];
11860
+ if (items.length > 0) parts.push(`${section.heading(delta.baseline)}\n${items.map(section.line).join("\n")}`);
11861
+ }
11862
+ const omitted = changeCount(withheld);
11863
+ if (omitted > 0) parts.push(`(${omitted} further change${omitted === 1 ? "" : "s"} omitted for length.)`);
11864
+ if (partial) parts.push(partial);
11865
+ return parts.join("\n\n");
11866
+ }
11867
+ var COLLECTION_NAMES = {
11868
+ threads: "review threads",
11869
+ conversation: "top-level comments",
11870
+ reviews: "review submissions"
11871
+ };
11872
+ /** Said when part of the conversation was past what PR Review Buddy fetches, so the lists are not read as whole. */
11873
+ function partialNote(delta) {
11874
+ if (delta.partial.length === 0) return "";
11875
+ const names = delta.partial.map((collection) => COLLECTION_NAMES[collection]);
11876
+ return `(This pull request has more ${names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0]} than PR Review Buddy fetches, so changes among the ones it did not fetch are not listed.)`;
11877
+ }
11878
+ /**
11879
+ * A ceiling, not a measurement: half the assistant's conversation budget, beside which this is
11880
+ * rendered. Overflow is counted in the text.
11881
+ */
11882
+ var REVIEW_HISTORY_BUDGET_CHARS = 4e3;
11883
+ /**
11884
+ * Every review summary on the request, for a reader who may ask about any of them.
11885
+ *
11886
+ * The conversation block shows each reviewer's standing verdict, which is their latest review and
11887
+ * no other. An earlier summary appears in a delta only while it is new; once an analysis has taken
11888
+ * it into account it would otherwise be nowhere, and a reviewer asking "what did they say in their
11889
+ * first review?" would get a guess. So the history is stated here whole, within a budget.
11890
+ *
11891
+ * Reviews with no summary are counted rather than listed: their content is the comments they left
11892
+ * on lines, which the conversation already carries. Filled from the newest back, so a budget that
11893
+ * binds costs the oldest summaries, and shown oldest first, which is how a reader follows them.
11894
+ */
11895
+ function renderReviewHistory(discussion, budgetChars = REVIEW_HISTORY_BUDGET_CHARS) {
11896
+ const human = (discussion.reviews ?? []).filter((review) => !review.reviewerIsBot);
11897
+ const summarised = human.filter((review) => review.body.trim());
11898
+ if (summarised.length === 0) return "";
11899
+ const heading = "## Review summaries, oldest first";
11900
+ let spent = 33;
11901
+ const kept = [];
11902
+ for (const review of [...summarised].reverse()) {
11903
+ const line = historyLine(review);
11904
+ if (spent + line.length + 1 > budgetChars) continue;
11905
+ spent += line.length + 1;
11906
+ kept.unshift(review);
11907
+ }
11908
+ const notes = [];
11909
+ const omitted = summarised.length - kept.length;
11910
+ if (omitted > 0) notes.push(`(${omitted} earlier summar${omitted === 1 ? "y" : "ies"} omitted for length.)`);
11911
+ const empty = human.length - summarised.length;
11912
+ if (empty > 0) notes.push(`(${empty} further review${empty === 1 ? "" : "s"} had no summary; what they said is in the comments above.)`);
11913
+ return [
11914
+ heading,
11915
+ ...kept.map(historyLine),
11916
+ ...notes
11917
+ ].join("\n");
11918
+ }
11919
+ function historyLine(review) {
11920
+ return `- @${review.reviewer} · ${VERDICT_LABELS[review.verdict]} · ${review.timestamp || "time unknown"}: ${body(review.body)}`;
11921
+ }
11922
+ //#endregion
11923
+ //#region ../../packages/review-harness/src/domain/ai_review/status_history.ts
11924
+ /** A comment as evidence, copied as it reads now. */
11925
+ function commentEvidence(threadId, comment) {
11926
+ return {
11927
+ kind: "comment",
11928
+ threadId,
11929
+ commentId: comment.id,
11930
+ author: comment.author,
11931
+ body: truncateBody(comment.body.trim(), 800),
11932
+ ...comment.url ? { url: comment.url } : {},
11933
+ ...comment.timestamp ? { writtenAt: comment.timestamp } : {},
11934
+ ...comment.editedAt ? { editedAt: comment.editedAt } : {}
11935
+ };
11936
+ }
11937
+ function reviewEvidence(review) {
11938
+ return {
11939
+ kind: "review",
11940
+ ...review.id ? { reviewId: review.id } : {},
11941
+ author: review.reviewer,
11942
+ body: truncateBody(review.body.trim(), 800),
11943
+ ...review.timestamp ? { writtenAt: review.timestamp } : {}
11944
+ };
11945
+ }
11946
+ /**
11947
+ * The comment a resolved finding rests on, when a comment is what resolved it. Only a comment: a
11948
+ * review summary cannot resolve a finding (`REVIEW_CITATION`), so no resolution rests on one.
11949
+ */
11950
+ function conversationResolution(issue) {
11951
+ if (issue.reconciliationStatus !== "resolved") return void 0;
11952
+ const last = issue.statusHistory?.at(-1);
11953
+ if (!last || last.status !== "resolved" || last.basis !== "conversation") return void 0;
11954
+ return last.evidence?.kind === "comment" && last.evidence.commentId ? last.evidence : void 0;
11955
+ }
11956
+ /**
11957
+ * The findings, by index, whose resolving comment the delta edits or deletes.
11958
+ *
11959
+ * Pass the delta the model is actually shown, never the whole one: a finding flagged on an edit held
11960
+ * back for length would be judged again on evidence the model never saw.
11961
+ */
11962
+ function reassessmentFlags(issues, delta) {
11963
+ const flags = /* @__PURE__ */ new Map();
11964
+ issues.forEach((issue, index) => {
11965
+ const resolvedBy = conversationResolution(issue);
11966
+ if (!resolvedBy) return;
11967
+ const edited = delta.edited.find((entry) => entry.comment.id === resolvedBy.commentId);
11968
+ if (edited) {
11969
+ flags.set(index, {
11970
+ change: "edited",
11971
+ resolvedBy,
11972
+ now: commentEvidence(edited.thread.id, edited.comment)
11973
+ });
11974
+ return;
11975
+ }
11976
+ if (delta.removed.some((entry) => entry.comment.id === resolvedBy.commentId) || delta.vanishedThreads.some((thread) => thread.id === resolvedBy.threadId)) flags.set(index, {
11977
+ change: "deleted",
11978
+ resolvedBy,
11979
+ now: {
11980
+ ...resolvedBy,
11981
+ body: "",
11982
+ deleted: true
11983
+ }
11984
+ });
11985
+ });
11986
+ return flags;
11987
+ }
11988
+ function findComment(discussion, commentId) {
11989
+ for (const thread of discussion.discussions) {
11990
+ const comment = thread.comments.find((candidate) => candidate.id === commentId);
11991
+ if (comment) return {
11992
+ thread,
11993
+ comment
11994
+ };
11995
+ }
11996
+ }
11997
+ function resolveCitation(citation, discussion) {
11998
+ if (!discussion) return void 0;
11999
+ if ("reviewId" in citation) {
12000
+ const review = (discussion.reviews ?? []).find((candidate) => candidate.id === citation.reviewId);
12001
+ return review ? reviewEvidence(review) : void 0;
12002
+ }
12003
+ const found = findComment(discussion, citation.commentId);
12004
+ return found ? commentEvidence(found.thread.id, found.comment) : void 0;
12005
+ }
12006
+ /** Said, after the model's own note, when the harness would not let a resolution stand. */
12007
+ var UNFOUND_CITATION = "It cited a comment that is not in the pull request conversation, so it is marked for reassessment rather than resolved.";
12008
+ /**
12009
+ * A review summary may be cited, but not as what resolved a finding. Resolving is the one status a
12010
+ * later change to its evidence must be able to undo, and an edit to a review's summary is not yet
12011
+ * read, so a resolution resting on one could never be reassessed. Until it is, it is marked for
12012
+ * reassessment, with the review kept as its evidence.
12013
+ */
12014
+ var REVIEW_CITATION = "It rests on a review summary, which can mark a finding for reassessment but not resolve it, so the reviewer should decide.";
12015
+ var NO_CITATION = "No commits were pushed and no comment was cited for it, so it is marked for reassessment rather than resolved.";
12016
+ function withSentence(note, sentence) {
12017
+ return note ? `${note} ${sentence}` : sentence;
12018
+ }
12019
+ /**
12020
+ * The model's citations, checked against the conversation that exists and copied from it.
12021
+ *
12022
+ * A resolution that rests on the conversation has to rest on something real. One citing a comment
12023
+ * that is not there, or (with no commits pushed) citing nothing, is marked "affected" with the reason
12024
+ * said, never left standing as resolved. A citation on any other status is evidence for the record
12025
+ * only, and one that is not there is dropped.
12026
+ *
12027
+ * The one resolution that needs no citation with no commits pushed: a flagged finding the model
12028
+ * keeps resolved, whose evidence is then the edited comment it was asked to judge. A deleted one has
12029
+ * nothing left to rest on.
12030
+ */
12031
+ function groundFindings(findings, discussion, context) {
12032
+ return findings.map((finding) => {
12033
+ const { cites, ...rest } = finding;
12034
+ const evidence = cites ? resolveCitation(cites, discussion) : void 0;
12035
+ if (finding.status !== "resolved") return evidence ? {
12036
+ ...rest,
12037
+ evidence
12038
+ } : rest;
12039
+ if (cites && !evidence) return {
12040
+ ...rest,
12041
+ status: "affected",
12042
+ note: withSentence(finding.note, UNFOUND_CITATION)
12043
+ };
12044
+ if (evidence?.kind === "review") return {
12045
+ ...rest,
12046
+ status: "affected",
12047
+ note: withSentence(finding.note, REVIEW_CITATION),
12048
+ evidence
12049
+ };
12050
+ if (evidence) return {
12051
+ ...rest,
12052
+ evidence
12053
+ };
12054
+ if (context.codeChanged) return rest;
12055
+ const flag = context.flags?.get(finding.sourceIndex);
12056
+ if (flag?.change === "edited") return {
12057
+ ...rest,
12058
+ evidence: flag.now
12059
+ };
12060
+ return {
12061
+ ...rest,
12062
+ status: "affected",
12063
+ note: withSentence(finding.note, NO_CITATION)
12064
+ };
12065
+ });
12066
+ }
12067
+ /** Statuses that all mean "open": a finding never judged, one raised by an update, and one judged still open. */
12068
+ function isOpen(status) {
12069
+ return status === void 0 || status === "new" || status === "still-open";
12070
+ }
12071
+ /** Whether going from one status to the other is a change worth a history entry. */
12072
+ function isStatusChange(from, to) {
12073
+ return from !== to && !(isOpen(from) && isOpen(to));
12074
+ }
12075
+ /**
12076
+ * The history with one more entry. A finding that had a status before history was kept starts from
12077
+ * that status, carried with the note it had, so the first recorded change has something to move from.
12078
+ */
12079
+ function appendStatusChange(issue, entry) {
12080
+ return [...issue.statusHistory ?? (issue.reconciliationStatus && !isOpen(issue.reconciliationStatus) ? [{
12081
+ status: issue.reconciliationStatus,
12082
+ carried: true,
12083
+ ...issue.reconciliationNote ? { reason: issue.reconciliationNote } : {}
12084
+ }] : []), entry];
12085
+ }
12086
+ /**
12087
+ * The entry for a flagged finding, whatever the model said: still resolved, reopened, marked for
12088
+ * reassessment, or nothing, which is marked for reassessment. Recorded even when it stays resolved,
12089
+ * so the history shows the resolution survived the change and on what.
12090
+ */
12091
+ function reassessedEntry(verdict, flag, record) {
12092
+ const status = verdict?.status ?? "affected";
12093
+ const byCode = status === "resolved" && !verdict?.evidence && record.basis === "code";
12094
+ const evidence = byCode ? void 0 : verdict?.evidence ?? flag.now;
12095
+ const reason = verdict?.note ?? (flag.change === "edited" ? "The comment that resolved this was edited, and the update did not say whether it still holds." : "The comment that resolved this was deleted, and the update did not say whether it still holds.");
12096
+ return {
12097
+ status,
12098
+ at: record.at,
12099
+ commitSha: record.commitSha,
12100
+ basis: byCode ? "code" : "conversation",
12101
+ reason,
12102
+ ...evidence ? { evidence } : {},
12103
+ trigger: flag.change === "edited" ? "evidence-edited" : "evidence-deleted"
12104
+ };
12105
+ }
12106
+ /** The entry for an ordinary change of status. */
12107
+ function changeEntry(status, verdict, record) {
12108
+ return {
12109
+ status,
12110
+ at: record.at,
12111
+ commitSha: record.commitSha,
12112
+ basis: verdict?.evidence ? "conversation" : record.basis,
12113
+ ...verdict?.note ? { reason: verdict.note } : {},
12114
+ ...verdict?.evidence ? { evidence: verdict.evidence } : {}
12115
+ };
12116
+ }
12117
+ //#endregion
12118
+ //#region ../../packages/review-harness/src/domain/discussion/prepared_conversation.ts
12119
+ /**
12120
+ * How long a question about the review waits for the conversation to be fetched again before it
12121
+ * answers from the copy it has, stated as possibly out of date. The same on every surface, so the
12122
+ * workspace's Ask and the extension's Chat are equally patient.
12123
+ */
12124
+ var CONVERSATION_REREAD_CEILING_MS = 3e3;
12125
+ /** The state as a prompt block, headed the same way on every surface. */
12126
+ function conversationStateBlock(state) {
12127
+ return `PR CONVERSATION\n${renderConversationState(state)}`;
12128
+ }
12129
+ /** Whether anything in the conversation is new to the analysis, which is what an update reconciles against. */
12130
+ function conversationMoved(conversation) {
12131
+ return conversation.delta !== void 0 && hasDiscussionChanges(conversation.delta);
12132
+ }
12133
+ /**
12134
+ * The conversation block of an update prompt: the state always, and what changed only when
12135
+ * something did, so an update the conversation did not move reads as it always has plus one line.
12136
+ */
12137
+ function updateConversationBlock(conversation) {
12138
+ return [conversationStateBlock(conversation.state), ...conversationMoved(conversation) && conversation.delta ? [renderDiscussionDelta(conversation.delta)] : []].join("\n\n");
12139
+ }
12140
+ /**
12141
+ * The resolved findings an update judges again: those whose resolving comment the conversation it
12142
+ * shows edits or deletes. Read from the delta as fitted, the same split the prompt renders, so a
12143
+ * finding is never sent back on an edit the model was not shown. None when the conversation is only
12144
+ * stated, not reconciled against.
12145
+ */
12146
+ function conversationReassessment(previous, conversation) {
12147
+ const { delta } = conversation;
12148
+ if (!delta || !previous || !hasDiscussionChanges(delta)) return /* @__PURE__ */ new Map();
12149
+ return reassessmentFlags(previous.issues, fitDiscussionDelta(delta).shown);
12150
+ }
12151
+ /**
12152
+ * The conversation an update can claim to have taken into account: what it was shown, and no more.
12153
+ * A change held back for length, or a conversation that was stated but not reconciled against,
12154
+ * stays new to the next update rather than being recorded as seen.
12155
+ *
12156
+ * `baseline` is the one the delta was measured from, read before anything moves it.
12157
+ */
12158
+ function conversationTakenIntoAccount(conversation, baseline) {
12159
+ const { discussion, delta } = conversation;
12160
+ if (!discussion || !delta) return discussion;
12161
+ return deliveredSnapshot(discussion, baseline, hasDiscussionChanges(delta) ? fitDiscussionDelta(delta).withheld : delta);
12162
+ }
12163
+ /**
12164
+ * The conversation for a question about the review, stated whatever its state.
12165
+ *
12166
+ * Three parts: what PR Review Buddy has and how fresh it is; the conversation as it now stands; and
12167
+ * what changed in it since the analysis the reviewer is reading. The last only where there is a
12168
+ * recorded baseline to measure from. Without one, every comment would be listed twice, so the
12169
+ * conversation appears once and a sentence says why nothing is marked new.
12170
+ */
12171
+ function chatConversationSection(conversation) {
12172
+ const parts = [conversationStateBlock(conversation.state)];
12173
+ if (conversation.discussion) {
12174
+ const full = buildChatDiscussionSection(processDiscussion(conversation.discussion));
12175
+ parts.push(full || "The pull request has no human comments or reviews.");
12176
+ const history = renderReviewHistory(conversation.discussion);
12177
+ if (history) parts.push(history);
12178
+ }
12179
+ if (conversation.delta) parts.push(conversation.delta.baseline === "recorded" ? renderDiscussionDelta(conversation.delta) : `WHAT IS NEW SINCE THIS REVIEW\n${baselineNote(conversation.delta.baseline)}`);
12180
+ return parts.join("\n\n");
12181
+ }
12182
+ //#endregion
12183
+ //#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
12184
+ 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:
12185
+ {
12186
+ "summary": string,
12187
+ "motivation"?: {
12188
+ "status": "stated" | "inferred" | "unclear",
12189
+ "summary": string,
12190
+ "sourceQuote"?: string,
12191
+ "evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
12192
+ "authorQuestion"?: string
12193
+ },
12194
+ "overallRisk": "low" | "medium" | "high",
12195
+ "sections": [{
12196
+ "id": string,
12197
+ "order": number,
12198
+ "title": string,
12199
+ "oneLineSummary": string,
12200
+ "purpose": string,
12201
+ "files": [{ "path": string, "focus": string }],
12202
+ "whyReview": string,
12203
+ "checklist": string[],
12204
+ "priority": "implementation" | "supporting" | "tests" | "documentation",
12205
+ "reviewAttention": "high" | "medium" | "low",
12206
+ "reviewWeight": 1 | 2 | 3 | 4 | 5,
12207
+ "recommendedStartingPoint": boolean
12208
+ }],
12209
+ "issues": [{ "id": string, "severity": "critical" | "major" | "minor" | "nit", "title": string, "description": string, "file"?: string, "line"?: number, "sectionId"?: string, "confidence"?: "high" | "medium" | "low", "existingDiscussion"?: { "threadId": string, "relation": "same" | "related" } }],
12210
+ "questions": [{ "id": string, "question": string, "importance"?: "high" | "medium" | "low", "whyThisMatters"?: string, "file"?: string, "sectionId"?: string }],
12211
+ "recommendation": string,
12212
+ "nextSteps": string[]
12213
+ }
12214
+
12215
+ Field guidance:
12216
+ - ${SUMMARY_GUIDANCE}
12217
+ - "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":
12218
+ - "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".
12219
+ - "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...".
12220
+ - "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".
12221
+ 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.
12222
+ - "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.
12223
+ - "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."
12224
+ - "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.
12225
+ - "sectionId" on issues/questions: reference the "id" of the section that item belongs to. Omit it rather than guessing if an item doesn't clearly belong to one section.
12226
+ - "recommendation": a 1-2 sentence merge verdict aimed at the developer (e.g. "Approve" or "Request changes — fix the unhandled rejection before merging"). Do not just restate the risk level, and do not copy Guide section content verbatim into this field.
12227
+ - "nextSteps": concrete actions to take before merging (specific tests to run, files to double-check), not a restatement of the Guide.
12228
+ - "issues": report only issues worth the reviewer's attention — do not invent issues to fill out the array, and do not omit significant issues. Let the count reflect the actual PR (a trivial PR may have zero or one issue; a large or risky PR may have several).
12229
+ - "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.
12230
+ - "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.
12231
+ - "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.
12232
+ - 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.
12233
+ - "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.
12234
+
12235
+ 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.`;
12236
+ function buildStructuredReviewPrompt(pr, options = {}) {
12237
+ const { prompt, discussionOverflow, discussionThreadIds, partiallyReadFiles, partialFiles } = buildChatReviewPrompt(pr, [], {
12238
+ discussionSummary: options.discussionSummary,
12239
+ repositoryAccess: options.repositoryAccess,
12240
+ changeInventory: options.changeInventory,
12241
+ guideCoverage: options.guideCoverage
12242
+ });
12243
+ return {
12244
+ prompt: `${prompt}\n\n---\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}`,
12245
+ discussionOverflow: discussionOverflow ?? false,
12246
+ discussionThreadIds: discussionThreadIds ?? [],
12247
+ partiallyReadFiles: partiallyReadFiles ?? 0,
12248
+ partialFiles: partialFiles ?? []
12249
+ };
12250
+ }
12251
+ /**
12252
+ * A reply, parsed, with the motivation checked against the description the analysis read.
12253
+ *
12254
+ * The check happens here rather than after, so there is no parsed result anywhere whose motivation
12255
+ * has not been through it. `correction` says what the check refused, for a caller with an audit to
12256
+ * put it in.
12257
+ */
12258
+ function parseReviewResponse(raw, authorDescription) {
12259
+ const { result, motivation } = parseUnchecked(raw);
12260
+ const applied = applyMotivation(result, motivation, authorDescription);
12261
+ return {
12262
+ result: applied.result,
12263
+ ...applied.correction ? { motivationCorrection: applied.correction } : {}
12264
+ };
12265
+ }
12266
+ /** What tells this reply's answer from a JSON example quoted in the prose before it. */
12267
+ var REVIEW_ANSWER = objectWithAnyOf("summary", "recommendation", "issues", "nextSteps");
12268
+ function parseUnchecked(raw) {
12269
+ const jsonText = extractJsonText(raw, REVIEW_ANSWER);
12270
+ let parsed;
12271
+ try {
12272
+ parsed = JSON.parse(jsonText);
12273
+ } catch {
12274
+ throw new Error("Could not parse the AI response as JSON.");
12275
+ }
12276
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("AI response JSON was not an object.");
12277
+ const obj = parsed;
12278
+ const summary = asString$1(obj.summary);
12279
+ const recommendation = asString$1(obj.recommendation);
12280
+ 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.");
12281
+ return {
12282
+ result: {
12283
+ schemaVersion: 1,
12284
+ summary,
12285
+ overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
12286
+ sections: normalizeSections(obj.sections),
12287
+ issues: normalizeIssues(obj.issues),
12288
+ questions: normalizeQuestions(obj.questions),
12289
+ recommendation,
12290
+ nextSteps: asStringArray(obj.nextSteps)
12291
+ },
12292
+ motivation: normalizeMotivation(obj.motivation)
12293
+ };
12294
+ }
12295
+ 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.";
12296
+ //#endregion
12297
+ //#region ../../packages/review-harness/src/domain/kiss/prompt.ts
12298
+ var SCOPE_BOUNDARY = `Scope:
12299
+ Judge the complexity this change introduces or materially expands. That is the whole of what you are reviewing.
12300
+ Pre-existing complexity around the change is context you may use to understand it, and is not a finding. Do not attribute it to this author.
11279
12301
  The one exception: where this change makes existing complexity worse, or where the work could not have been done without taking it on, that is in scope and you should say which of the two it is.
11280
12302
  Do not propose refactoring architecture the change merely touches.`;
11281
12303
  var EVIDENCE_THRESHOLD = `Evidence threshold:
@@ -11497,199 +12519,712 @@ ${diff}`;
11497
12519
  text: "x".repeat(instructionsChars)
11498
12520
  });
11499
12521
  return {
11500
- prompt,
11501
- budget: buildPromptBudget(sections),
11502
- discussionOverflow: discussion.overflow,
11503
- partiallyReadFiles: budgeted.partialFiles.length,
11504
- partialFiles: budgeted.partialFiles
12522
+ prompt,
12523
+ budget: buildPromptBudget(sections),
12524
+ discussionOverflow: discussion.overflow,
12525
+ discussionThreadIds: discussion.includedThreadIds,
12526
+ partiallyReadFiles: budgeted.partialFiles.length,
12527
+ partialFiles: budgeted.partialFiles
12528
+ };
12529
+ }
12530
+ //#endregion
12531
+ //#region ../../packages/review-harness/src/guide/diff_inventory.ts
12532
+ /**
12533
+ * How much diff text this client may put inline in one review context.
12534
+ *
12535
+ * A Claude Code context budget, not a coverage limit — the two were the same number in the
12536
+ * extension only because a provider API cannot go and fetch what it was not sent. Here every
12537
+ * changed file is named in the inventory whatever this is set to, and the reader holds the
12538
+ * working tree, so all this decides is how much reading it is saved rather than how much of the
12539
+ * change it knows about.
12540
+ *
12541
+ * 300k characters is roughly 75k tokens, which is already a large share of a live session that
12542
+ * has other work in it. It is a starting point chosen against that constraint rather than a
12543
+ * measured optimum; change it freely once real reviews say something.
12544
+ */
12545
+ var PLUGIN_DIFF_BUDGET_CHARS = 3e5;
12546
+ /**
12547
+ * Decide whose patch travels inline, whole or not at all.
12548
+ *
12549
+ * The extension truncates an oversized patch and marks how far it read, which is the best
12550
+ * available answer when the reader cannot go and get the rest. This client's reader can, and a
12551
+ * truncated patch is strictly worse than a pointer to the real file: it invites a conclusion
12552
+ * drawn from a prefix, where the pointer invites a `git diff`. So there is no partial state here.
12553
+ *
12554
+ * Files are served smallest-first, the same progressive filling `domain/prompts/diff_budget` uses
12555
+ * and for the same reason: a claim that fits within what is left is granted in full, so one
12556
+ * enormous file is left out by itself instead of taking a slice off everything else.
12557
+ *
12558
+ * The consequence is worth stating rather than hiding — the *largest* files are the likeliest to
12559
+ * be left out. That is not a ranking. Size is not importance, a large change is conspicuous in
12560
+ * the inventory whether or not its diff is inline, and deciding which large file mattered is the
12561
+ * model's job, not this function's.
12562
+ */
12563
+ function selectInlineDiffs(files, budget = PLUGIN_DIFF_BUDGET_CHARS) {
12564
+ const candidates = files.map((file, index) => ({
12565
+ file,
12566
+ index
12567
+ })).filter((entry) => !isGeneratedOrVendored(entry.file));
12568
+ const chosen = /* @__PURE__ */ new Set();
12569
+ let used = 0;
12570
+ for (const entry of [...candidates].sort((a, b) => a.file.patch.length - b.file.patch.length || a.index - b.index)) {
12571
+ if (used + entry.file.patch.length > budget) break;
12572
+ chosen.add(entry.index);
12573
+ used += entry.file.patch.length;
12574
+ }
12575
+ const inline = [];
12576
+ const inventoryOnly = [];
12577
+ files.forEach((file, index) => (chosen.has(index) ? inline : inventoryOnly).push(file));
12578
+ return {
12579
+ inline,
12580
+ inventoryOnly,
12581
+ usedChars: used,
12582
+ budgetChars: budget
12583
+ };
12584
+ }
12585
+ /**
12586
+ * Every file the change touches, whether or not its diff came with it.
12587
+ *
12588
+ * Deliberately never truncated, however many files there are. The inventory is the one thing that
12589
+ * makes the budget above a context decision rather than a coverage ceiling: the moment it starts
12590
+ * eliding files, the review is back to describing a change it has not been told the shape of.
12591
+ */
12592
+ function renderChangeInventory(files, inlinePaths) {
12593
+ return `CHANGE INVENTORY
12594
+ Every file this change touches, complete. The diffs further down cover only the files marked
12595
+ "diff inline below"; the rest are not hidden from you and were not judged unimportant — they
12596
+ simply did not fit the context budget, and you have the working tree.
12597
+
12598
+ ${files.map((file) => {
12599
+ const churn = `+${file.additions}/-${file.deletions}`;
12600
+ const rename = file.previousName ? ` (was ${file.previousName})` : "";
12601
+ const availability = inlinePaths.has(file.name) ? "diff inline below" : "diff not inline — open it in the working tree if this review needs it";
12602
+ return `- ${file.name}${rename} · ${file.status} · ${churn} · ${availability}`;
12603
+ }).join("\n")}`;
12604
+ }
12605
+ //#endregion
12606
+ //#region ../../packages/review-harness/src/guide/review_context.ts
12607
+ /**
12608
+ * Turn a local git change into everything Claude Code needs to write a ReviewGuide.
12609
+ *
12610
+ * Three concerns kept apart, because the extension had to fuse them and this client does not:
12611
+ *
12612
+ * inventory every changed file is named, always — the harness's job, and never truncated
12613
+ * priority where to start is the guide's call, made after reading; no ranking happens here
12614
+ * budget `PLUGIN_DIFF_BUDGET_CHARS` decides whose patch rides along, and nothing more
12615
+ *
12616
+ * A provider API given a diff cannot fetch what it was not sent, so for the extension a budget
12617
+ * *is* a coverage ceiling and the two collapse into one number. Here they do not, and treating
12618
+ * them as one is what produced "3 changed files were not analysed at all" for a client that
12619
+ * could have opened all three.
12620
+ *
12621
+ * Note what this still does not do: there is no planning call, and no ranking of any kind.
12622
+ * `processPRData` is handed exactly the files the budget already settled, so its own cap and its
12623
+ * largest-diff-first fallback can never bind — a second selection here would be the harness
12624
+ * deciding what matters, which is the one thing it must never do.
12625
+ */
12626
+ async function buildReviewContext(options) {
12627
+ const changeSet = await buildChangeSet(options);
12628
+ const budget = options.diffBudgetChars ?? 3e5;
12629
+ const lens = options.lens ?? { type: "standard" };
12630
+ return {
12631
+ contextId: randomUUID(),
12632
+ ...await assemble(changeSet, budget, lens)
12633
+ };
12634
+ }
12635
+ /**
12636
+ * The same context with the pull request's conversation folded in.
12637
+ *
12638
+ * A new context rather than a mutation of the old one, and rebuilt through the same assembly:
12639
+ * the model can only cite a thread whose `[T_...]` marker is in the prompt it was given, so the
12640
+ * prompt has to be produced again once the threads exist. Doing that through `assemble` rather
12641
+ * than by appending a discussion block is what keeps one prompt builder — a second, hand-written
12642
+ * copy is how two clients start disagreeing about what a review prompt contains.
12643
+ *
12644
+ * The change set is left exactly as git reported it. The pull request is a fact about a forge,
12645
+ * kept beside the change rather than written into it, so nothing downstream can mistake one for
12646
+ * the other.
12647
+ */
12648
+ async function withPrContext(context, prContext) {
12649
+ return {
12650
+ ...context,
12651
+ ...await assemble(context.changeSet, context.diffBudgetChars, context.lens, prContext),
12652
+ contextId: context.contextId,
12653
+ prContext
12654
+ };
12655
+ }
12656
+ async function assemble(changeSet, budget, lens, prContext) {
12657
+ const allFiles = changeSet.data.files;
12658
+ const selection = selectInlineDiffs(allFiles, budget);
12659
+ const inlinePaths = new Set(selection.inline.map((file) => file.name));
12660
+ const processed = await processPRData({
12661
+ ...changeSet.data,
12662
+ files: selection.inline,
12663
+ changedFileCount: allFiles.length,
12664
+ ...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
12665
+ discussion: prContext?.discussion
12666
+ }, selection.inline.length, null);
12667
+ const shared = {
12668
+ repositoryAccess: "repository",
12669
+ changeInventory: renderChangeInventory(allFiles, inlinePaths)
12670
+ };
12671
+ const { prompt, discussionOverflow, discussionThreadIds } = lens.type === "kiss" ? buildKissPrompt(processed, {
12672
+ ...shared,
12673
+ audience: lens.audience
12674
+ }) : buildStructuredReviewPrompt(processed, {
12675
+ ...shared,
12676
+ guideCoverage: "review-path"
12677
+ });
12678
+ return {
12679
+ lens,
12680
+ instructions: prompt,
12681
+ diff: buildDiffText(selection.inline),
12682
+ changeSet,
12683
+ processed,
12684
+ fileCoverage: {
12685
+ ...processed.fileCoverage,
12686
+ repositoryAvailable: true
12687
+ },
12688
+ inventoryOnlyFiles: selection.inventoryOnly.map((file) => file.name),
12689
+ diffBudgetChars: budget,
12690
+ discussionOverflow: discussionOverflow ?? false,
12691
+ ...prContext ? { deliveredDiscussion: withoutOmittedThreads(prContext.discussion, processed.discussion.all.map((thread) => thread.id), discussionThreadIds ?? []) } : {}
12692
+ };
12693
+ }
12694
+ /**
12695
+ * The change's pull request, as the prompt should present it once a forge request is attached.
12696
+ *
12697
+ * With no description, the commit subjects stay: they are still the best account of the change
12698
+ * there is, and still labelled as commits rather than as the author's words.
12699
+ */
12700
+ function pullRequestFor(local, prContext) {
12701
+ const body = prContext.pullRequest.body;
12702
+ if (!body) return local;
12703
+ const { bodySource: _commits, ...rest } = local;
12704
+ return {
12705
+ ...rest,
12706
+ body
12707
+ };
12708
+ }
12709
+ /**
12710
+ * The inline patches as one blob, for grounding the assistant.
12711
+ *
12712
+ * No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
12713
+ * the same quantity is a number nobody can explain the day it binds.
12714
+ */
12715
+ function buildDiffText(files) {
12716
+ return files.map((file) => `--- ${file.name}\n${file.patch}`).join("\n\n");
12717
+ }
12718
+ //#endregion
12719
+ //#region ../../packages/review-harness/src/workspace/agent_env.ts
12720
+ /**
12721
+ * The environment a review's later runs are pinned to: the job's, not the daemon's.
12722
+ *
12723
+ * A job records what it must run under at the moment it is created, and the analysis reads it back
12724
+ * from there. Every run that happens *after* the analysis read `captureEnv()` instead, which
12725
+ * answers a question about the daemon rather than about the review, and the daemon is the process
12726
+ * that happened to be started first by whichever shell got there first. Two things went wrong with
12727
+ * that. `CLAUDE_CONFIG_DIR` decides which account answers, so a review made under one and revisited
12728
+ * under another is served by a Claude that may not have access to the same models or settings. And
12729
+ * `agentId` decides which adapter runs at all, so a review made by one agent had its questions
12730
+ * answered, and its findings reconciled, by whichever is default today, silently, with the result
12731
+ * still shown as the review's.
12732
+ *
12733
+ * Three callers, and they are exactly the three things a reviewer can ask of a review after it is
12734
+ * written: a question in the Ask panel, bringing the analysis up to date, and re-reading the
12735
+ * conversation. All three put the answer on the page under the review's own name, so all three owe
12736
+ * the reader the review's own agent.
12737
+ *
12738
+ * Falling back to capturing now covers a workspace with no job and one whose job has aged out of
12739
+ * retention, and that fallback is not the rare path: jobs are finished business within hours and
12740
+ * reviews are kept for thirty days, so most questions asked of an older review find nothing here.
12741
+ *
12742
+ * Which is why it captures for *the review's own agent* rather than the default one. Capturing for
12743
+ * the default reintroduced the exact bug this module exists to fix, one retention period later and
12744
+ * far more quietly: a review made by Codex, asked a question a day after its job was swept, would
12745
+ * have been answered by Claude with the answer shown as the review's. The environment has to be
12746
+ * captured now, because it genuinely is all there is left to read. Who to capture it for is a fact
12747
+ * the review still holds.
12748
+ */
12749
+ function agentEnvOf(workspace) {
12750
+ const job = workspace.jobId ? loadJob(workspace.jobId) : null;
12751
+ if (job?.env) return job.env;
12752
+ const id = agentIdOf(workspace.session);
12753
+ return (AGENT_IDS.includes(id) ? agentById(id) : defaultAgent()).captureEnv();
12754
+ }
12755
+ //#endregion
12756
+ //#region ../../packages/review-harness/src/domain/discussion/discussion_attribution.ts
12757
+ /**
12758
+ * Every surviving attribution has its author, status and link taken from the real thread — the
12759
+ * model's only contribution is which thread it points at. Anything it echoed back about who said
12760
+ * what is discarded, so a correct id can't smuggle a wrong attribution alongside it.
12761
+ */
12762
+ function attributeIssuesToDiscussion(result, pd) {
12763
+ let attributed = 0;
12764
+ let rejected = 0;
12765
+ const issues = result.issues.map((issue) => {
12766
+ if (!issue.existingDiscussion) return issue;
12767
+ const thread = pd.byId[issue.existingDiscussion.threadId];
12768
+ if (!thread) {
12769
+ rejected++;
12770
+ const { existingDiscussion: _dropped, ...rest } = issue;
12771
+ return rest;
12772
+ }
12773
+ attributed++;
12774
+ return {
12775
+ ...issue,
12776
+ existingDiscussion: {
12777
+ threadId: thread.id,
12778
+ raisedBy: thread.comments[0]?.author ?? "unknown",
12779
+ status: thread.resolved ? "resolved" : "unresolved",
12780
+ url: thread.comments[0]?.url || void 0,
12781
+ relation: issue.existingDiscussion.relation === "related" ? "related" : "same"
12782
+ }
12783
+ };
12784
+ });
12785
+ return {
12786
+ result: {
12787
+ ...result,
12788
+ issues
12789
+ },
12790
+ attributed,
12791
+ rejected
11505
12792
  };
11506
12793
  }
11507
12794
  //#endregion
11508
- //#region ../../packages/review-harness/src/guide/diff_inventory.ts
12795
+ //#region ../../packages/review-harness/src/workspace/discussion_diff.ts
11509
12796
  /**
11510
- * How much diff text this client may put inline in one review context.
12797
+ * What moved in a pull request conversation between two imports.
11511
12798
  *
11512
- * A Claude Code context budget, not a coverage limit — the two were the same number in the
11513
- * extension only because a provider API cannot go and fetch what it was not sent. Here every
11514
- * changed file is named in the inventory whatever this is set to, and the reader holds the
11515
- * working tree, so all this decides is how much reading it is saved rather than how much of the
11516
- * change it knows about.
12799
+ * Pure, and deliberately only counting. The workspace's job here is to say what changed, not what
12800
+ * it means — "two threads were resolved since you last looked" is a fact; whether that settles the
12801
+ * findings attached to them is a judgement, and judgement belongs to the analyser.
11517
12802
  *
11518
- * 300k characters is roughly 75k tokens, which is already a large share of a live session that
11519
- * has other work in it. It is a starting point chosen against that constraint rather than a
11520
- * measured optimum; change it freely once real reviews say something.
12803
+ * A projection of `diffDiscussion`, so the sentence a re-read shows and the delta an update reasons
12804
+ * from are one computation and cannot disagree about what moved. That brings its rules with it:
12805
+ * bots are not counted, and a thread or comment is called gone only where both imports prove they
12806
+ * held the whole of it.
11521
12807
  */
11522
- var PLUGIN_DIFF_BUDGET_CHARS = 3e5;
12808
+ function diffDiscussions(before, after) {
12809
+ return countsOf(diffDiscussion(before, after));
12810
+ }
12811
+ function countsOf(delta) {
12812
+ return {
12813
+ newThreads: new Set(delta.added.filter((entry) => entry.newThread).map((entry) => entry.thread.id)).size,
12814
+ vanishedThreads: delta.vanishedThreads.length,
12815
+ resolvedSince: delta.resolved.length,
12816
+ reopenedSince: delta.reopened.length,
12817
+ newComments: delta.added.length,
12818
+ editedComments: delta.edited.length,
12819
+ removedComments: delta.removed.length,
12820
+ newReviews: delta.newReviews.length
12821
+ };
12822
+ }
12823
+ /** Nothing moved. Worth its own answer: "checked, and nothing changed" is a useful thing to hear. */
12824
+ function isUnchanged(changes) {
12825
+ return changes.newThreads === 0 && changes.vanishedThreads === 0 && changes.resolvedSince === 0 && changes.reopenedSince === 0 && changes.newComments === 0 && !changes.editedComments && !changes.removedComments && !changes.newReviews;
12826
+ }
11523
12827
  /**
11524
- * Decide whose patch travels inline, whole or not at all.
12828
+ * The change, in a sentence, newest-mattering first.
11525
12829
  *
11526
- * The extension truncates an oversized patch and marks how far it read, which is the best
11527
- * available answer when the reader cannot go and get the rest. This client's reader can, and a
11528
- * truncated patch is strictly worse than a pointer to the real file: it invites a conclusion
11529
- * drawn from a prefix, where the pointer invites a `git diff`. So there is no partial state here.
12830
+ * Resolutions lead because they are the ones that make a standing claim in this review false —
12831
+ * an "already raised … still unresolved" line that is no longer true is the reason this whole
12832
+ * re-check exists.
12833
+ */
12834
+ function describeChanges(changes) {
12835
+ if (isUnchanged(changes)) return "Nothing has changed in the conversation since it was imported.";
12836
+ const parts = [];
12837
+ const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
12838
+ if (changes.resolvedSince > 0) parts.push(`${plural(changes.resolvedSince, "thread", "threads")} resolved since`);
12839
+ if (changes.reopenedSince > 0) parts.push(`${plural(changes.reopenedSince, "thread", "threads")} reopened`);
12840
+ if (changes.newThreads > 0) parts.push(`${plural(changes.newThreads, "new thread", "new threads")}`);
12841
+ if (changes.newComments > 0) parts.push(`${plural(changes.newComments, "new comment", "new comments")}`);
12842
+ if (changes.newReviews) parts.push(`${plural(changes.newReviews, "new review", "new reviews")}`);
12843
+ if (changes.editedComments) parts.push(`${plural(changes.editedComments, "comment", "comments")} edited`);
12844
+ if (changes.removedComments) parts.push(`${plural(changes.removedComments, "comment", "comments")} deleted`);
12845
+ if (changes.vanishedThreads > 0) parts.push(`${plural(changes.vanishedThreads, "thread", "threads")} no longer present`);
12846
+ return `${parts.join(", ")}.`;
12847
+ }
12848
+ //#endregion
12849
+ //#region ../../packages/review-harness/src/workspace/pr_refresh.ts
12850
+ /**
12851
+ * Re-read the pull request conversation, without re-reviewing anything.
11530
12852
  *
11531
- * Files are served smallest-first, the same progressive filling `domain/prompts/diff_budget` uses
11532
- * and for the same reason: a claim that fits within what is left is granted in full, so one
11533
- * enormous file is left out by itself instead of taking a slice off everything else.
12853
+ * The problem this solves is narrow and specific. Every "Already raised by @sarah · thread still
12854
+ * unresolved" line in a review is a fact that was true at import and is frozen. Nothing re-checks
12855
+ * it, so an hour after Sarah resolves that thread the workspace is still telling the reviewer she
12856
+ * is waiting on it: a confident, specific, wrong claim about what a named colleague thinks.
11534
12857
  *
11535
- * The consequence is worth stating rather than hiding — the *largest* files are the likeliest to
11536
- * be left out. That is not a ranking. Size is not importance, a large change is conspicuous in
11537
- * the inventory whether or not its diff is inline, and deciding which large file mattered is the
11538
- * model's job, not this function's.
12858
+ * **This is not a re-review, and the distinction is the whole design.** Re-reading the conversation
12859
+ * can only update the state of findings that already exist: who raised the thread, whether it is
12860
+ * open, where it links to. It cannot produce a new finding, and it must not try. Deciding that a
12861
+ * new comment invalidates a finding, or that a new commit introduced a bug, is judgement, and
12862
+ * judgement belongs to the analyser. `attributeIssuesToDiscussion` is what runs here, and it is
12863
+ * arithmetic: the model's only surviving contribution is which thread each finding points at.
12864
+ *
12865
+ * This used to spawn an agent in the reviewer's own checkout and ask it to fetch the conversation.
12866
+ * It now asks the forge adapter, which is a couple of subprocess calls and answers in seconds
12867
+ * rather than minutes. The daemon still reaches no forge itself and still holds no credential: the
12868
+ * adapter drives the CLI the developer has already signed in to, exactly as the review drives the
12869
+ * agent they have already signed in to.
11539
12870
  */
11540
- function selectInlineDiffs(files, budget = PLUGIN_DIFF_BUDGET_CHARS) {
11541
- const candidates = files.map((file, index) => ({
11542
- file,
11543
- index
11544
- })).filter((entry) => !isGeneratedOrVendored(entry.file));
11545
- const chosen = /* @__PURE__ */ new Set();
11546
- let used = 0;
11547
- for (const entry of [...candidates].sort((a, b) => a.file.patch.length - b.file.patch.length || a.index - b.index)) {
11548
- if (used + entry.file.patch.length > budget) break;
11549
- chosen.add(entry.index);
11550
- used += entry.file.patch.length;
12871
+ /**
12872
+ * Computes; does not persist. The route saves, so a test can run the whole thing without writing
12873
+ * into the reviewer's own store.
12874
+ */
12875
+ async function refreshPrContext(workspace, fetch = refetchConversation) {
12876
+ const startedAt = Date.now();
12877
+ if (!workspace.prContext) return {
12878
+ at: Date.now(),
12879
+ startedAt,
12880
+ ok: false,
12881
+ message: "This review has no pull request conversation attached, so there is nothing to re-read."
12882
+ };
12883
+ const pr = workspace.prContext.pullRequest;
12884
+ const outcome = await fetch(workspace.repoPath, {
12885
+ url: pr.url,
12886
+ number: pr.number
12887
+ }, agentEnvOf(workspace));
12888
+ if (outcome.kind !== "attached") return {
12889
+ at: Date.now(),
12890
+ startedAt,
12891
+ ok: false,
12892
+ message: `${whyNot(outcome)} Nothing here has changed, and the discussion shown is still the one that came with the review.`,
12893
+ reason: outcome.kind
12894
+ };
12895
+ const fetched = outcome.context;
12896
+ const before = workspace.prContext.discussion;
12897
+ const changes = diffDiscussions(before, fetched.discussion);
12898
+ const restated = reattribute(workspace, fetched.discussion);
12899
+ workspace.prContext = {
12900
+ pullRequest: {
12901
+ ...workspace.prContext.pullRequest,
12902
+ ...movable(fetched.pullRequest)
12903
+ },
12904
+ discussion: fetched.discussion
12905
+ };
12906
+ workspace.lastPrRefresh = {
12907
+ at: Date.now(),
12908
+ startedAt,
12909
+ ok: true,
12910
+ message: describeChanges(changes),
12911
+ changes,
12912
+ ...restated
12913
+ };
12914
+ return workspace.lastPrRefresh;
12915
+ }
12916
+ /**
12917
+ * Why the re-check did not happen, in the tense a person clicking a button is standing in.
12918
+ *
12919
+ * The same closed union the review's own disclosure renders, and deliberately not the same
12920
+ * sentences. Those are written about a review that has already been made; these are written about
12921
+ * a button that has just been pressed and did nothing.
12922
+ */
12923
+ function whyNot(outcome) {
12924
+ switch (outcome.kind) {
12925
+ case "no-request": return "The pull request this review is attached to could not be found.";
12926
+ case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet.` : "This repository has no remote to look the pull request up on.";
12927
+ case "cli-missing": return `\`${outcome.cli}\` is not installed here, so the pull request could not be re-read.`;
12928
+ case "not-authenticated": return `\`${outcome.cli}\` is not signed in, so the pull request could not be re-read. Run \`${outcome.cli} auth login\` and try again.`;
12929
+ case "failed": return `The pull request could not be re-read. ${outcome.detail}`;
11551
12930
  }
11552
- const inline = [];
11553
- const inventoryOnly = [];
11554
- files.forEach((file, index) => (chosen.has(index) ? inline : inventoryOnly).push(file));
11555
- return {
11556
- inline,
11557
- inventoryOnly,
11558
- usedChars: used,
11559
- budgetChars: budget
12931
+ }
12932
+ /**
12933
+ * Which fields a re-read may update, and why these.
12934
+ *
12935
+ * The head SHA and title move on their own as the request gains commits, and the two link shapes
12936
+ * are how this product gets from a file here to the same file there. Number, url and provider are
12937
+ * identity and stay as attached: a re-read does not move a review onto a different pull request.
12938
+ */
12939
+ function movable(fetched) {
12940
+ const updates = {};
12941
+ const copy = (field) => {
12942
+ const value = fetched[field];
12943
+ if (value !== void 0 && value !== "" && value !== null) updates[field] = value;
11560
12944
  };
12945
+ copy("headSha");
12946
+ copy("title");
12947
+ if (typeof fetched.body === "string") updates.body = fetched.body;
12948
+ copy("filesUrl");
12949
+ copy("fileUrlTemplate");
12950
+ copy("author");
12951
+ return updates;
11561
12952
  }
11562
12953
  /**
11563
- * Every file the change touches, whether or not its diff came with it.
12954
+ * Re-derive every attribution from the threads as they now are.
11564
12955
  *
11565
- * Deliberately never truncated, however many files there are. The inventory is the one thing that
11566
- * makes the budget above a context decision rather than a coverage ceiling: the moment it starts
11567
- * eliding files, the review is back to describing a change it has not been told the shape of.
12956
+ * The same function the review's own attribution pass used, run again against fresh threads. It
12957
+ * rebuilds author, status and link from the real thread and drops any claim whose thread is no
12958
+ * longer there, so nothing the model once said can outlive the conversation that justified it.
11568
12959
  */
11569
- function renderChangeInventory(files, inlinePaths) {
11570
- return `CHANGE INVENTORY
11571
- Every file this change touches, complete. The diffs further down cover only the files marked
11572
- "diff inline below"; the rest are not hidden from you and were not judged unimportant — they
11573
- simply did not fit the context budget, and you have the working tree.
11574
-
11575
- ${files.map((file) => {
11576
- const churn = `+${file.additions}/-${file.deletions}`;
11577
- const rename = file.previousName ? ` (was ${file.previousName})` : "";
11578
- const availability = inlinePaths.has(file.name) ? "diff inline below" : "diff not inline — open it in the working tree if this review needs it";
11579
- return `- ${file.name}${rename} · ${file.status} · ${churn} · ${availability}`;
11580
- }).join("\n")}`;
12960
+ function reattribute(workspace, discussion) {
12961
+ const result = workspace.session.result;
12962
+ if (!result) return {
12963
+ attributionsWithdrawn: 0,
12964
+ attributionsRestated: 0
12965
+ };
12966
+ const before = new Map(result.issues.map((issue) => [issue.id, issue.existingDiscussion?.status]));
12967
+ const outcome = attributeIssuesToDiscussion(result, processDiscussion(discussion));
12968
+ workspace.session = {
12969
+ ...workspace.session,
12970
+ result: outcome.result
12971
+ };
12972
+ const restated = outcome.result.issues.filter((issue) => issue.existingDiscussion && before.get(issue.id) !== issue.existingDiscussion.status).length;
12973
+ return {
12974
+ attributionsWithdrawn: outcome.rejected,
12975
+ attributionsRestated: restated
12976
+ };
11581
12977
  }
11582
12978
  //#endregion
11583
- //#region ../../packages/review-harness/src/guide/review_context.ts
12979
+ //#region ../../packages/review-harness/src/workspace/conversation_context.ts
11584
12980
  /**
11585
- * Turn a local git change into everything Claude Code needs to write a ReviewGuide.
12981
+ * The pull request conversation, prepared by the harness for a model to reason over.
11586
12982
  *
11587
- * Three concerns kept apart, because the extension had to fuse them and this client does not:
12983
+ * Every model-facing path that can reason about a pull request starts here, so each of them gets
12984
+ * the same three facts the same way: whether there is a conversation, how fresh it is, and what in
12985
+ * it has changed since the analysis being built on. The harness answers those. What any of it
12986
+ * means for the review is the model's question, and nothing here tries to answer it.
11588
12987
  *
11589
- * inventory every changed file is named, always — the harness's job, and never truncated
11590
- * priority where to start is the guide's call, made after reading; no ranking happens here
11591
- * budget `PLUGIN_DIFF_BUDGET_CHARS` decides whose patch rides along, and nothing more
12988
+ * The fetch stays where it was, in the forge adapter, which returns a whole snapshot. "What
12989
+ * changed" is computed here between two snapshots, so no forge has to support asking for changes.
12990
+ */
12991
+ /**
12992
+ * The conversation the current analysis took into account, or why that cannot be known.
11592
12993
  *
11593
- * A provider API given a diff cannot fetch what it was not sent, so for the extension a budget
11594
- * *is* a coverage ceiling and the two collapse into one number. Here they do not, and treating
11595
- * them as one is what produced "3 changed files were not analysed at all" for a client that
11596
- * could have opened all three.
12994
+ * Recorded since this was introduced. For a review made before then, the stored conversation is
12995
+ * still exactly what the analysis was given provided nothing has re-read it since the analysis
12996
+ * was made, so it is used rather than discarded. Once a re-read has replaced it, what the analysis
12997
+ * saw is gone, and the answer is `unknown`: offered as possibly new rather than claimed as new.
11597
12998
  *
11598
- * Note what this still does not do: there is no planning call, and no ranking of any kind.
11599
- * `processPRData` is handed exactly the files the budget already settled, so its own cap and its
11600
- * largest-diff-first fallback can never bind — a second selection here would be the harness
11601
- * deciding what matters, which is the one thing it must never do.
12999
+ * A record naming a different session describes an analysis this review no longer holds, and is
13000
+ * unknown for the same reason. It is never taken as this one's.
11602
13001
  */
11603
- async function buildReviewContext(options) {
11604
- const changeSet = await buildChangeSet(options);
11605
- const budget = options.diffBudgetChars ?? 3e5;
11606
- const lens = options.lens ?? { type: "standard" };
11607
- return {
11608
- contextId: randomUUID(),
11609
- ...await assemble(changeSet, budget, lens)
13002
+ function analysedBaselineOf(workspace) {
13003
+ const recorded = workspace.analysedConversation;
13004
+ if (recorded) return recorded.sessionId === workspace.session.id ? recorded.discussion : "unknown";
13005
+ if (!workspace.prContext) return null;
13006
+ const generatedAt = workspace.session.metadata?.generatedAt;
13007
+ const reread = workspace.lastPrRefresh;
13008
+ if (typeof generatedAt !== "number") return "unknown";
13009
+ if (reread && reread.ok && reread.at >= generatedAt) return "unknown";
13010
+ return workspace.prContext.discussion;
13011
+ }
13012
+ /**
13013
+ * Record a legacy review's baseline before anything re-reads the conversation over it.
13014
+ *
13015
+ * The derivation above holds only until the first re-read, which replaces the copy it reads. So
13016
+ * every path that re-reads calls this first, and the save that follows writes it with the re-read
13017
+ * (`saveRefresh`, or the update's own write). After that the review has a recorded baseline like any
13018
+ * other, and nothing is lost by the re-read. A no-op on a review that already has one, or whose
13019
+ * baseline is already unknowable.
13020
+ */
13021
+ function adoptLegacyBaseline(workspace) {
13022
+ if (workspace.analysedConversation) return;
13023
+ const baseline = analysedBaselineOf(workspace);
13024
+ if (baseline === "unknown" || !workspace.prContext) return;
13025
+ workspace.analysedConversation = {
13026
+ sessionId: workspace.session.id,
13027
+ discussion: baseline
11610
13028
  };
11611
13029
  }
13030
+ /** What a failed re-read can say about itself in a prompt. `no-request` there means the request vanished. */
13031
+ function rereadReasonOf(outcome) {
13032
+ const reason = outcome.reason;
13033
+ return reason === "unsupported-forge" || reason === "cli-missing" || reason === "not-authenticated" ? reason : "failed";
13034
+ }
11612
13035
  /**
11613
- * The same context with the pull request's conversation folded in.
13036
+ * The conversation for one model run.
11614
13037
  *
11615
- * A new context rather than a mutation of the old one, and rebuilt through the same assembly:
11616
- * the model can only cite a thread whose `[T_...]` marker is in the prompt it was given, so the
11617
- * prompt has to be produced again once the threads exist. Doing that through `assemble` rather
11618
- * than by appending a discussion block is what keeps one prompt builder — a second, hand-written
11619
- * copy is how two clients start disagreeing about what a review prompt contains.
13038
+ * With `reread`, the conversation is fetched again first, through the same re-read the Re-check
13039
+ * button uses, so a question about "the new comments" is answered from the request as it is now.
13040
+ * A re-read that fails leaves the stored copy in place and is stated as stale rather than hidden.
13041
+ * The re-read changes `workspace` in memory only; `refresh` is returned for the caller to save.
11620
13042
  *
11621
- * The change set is left exactly as git reported it. The pull request is a fact about a forge,
11622
- * kept beside the change rather than written into it, so nothing downstream can mistake one for
11623
- * the other.
13043
+ * `rereadOutcome` is for a caller that has already re-read for reasons of its own (an update does,
13044
+ * before it decides anything): its outcome is stated rather than a second fetch made.
11624
13045
  */
11625
- async function withPrContext(context, prContext) {
13046
+ async function prepareConversation(workspace, options) {
13047
+ if (!workspace.prContext) return { prepared: { state: { kind: "not-attached" } } };
13048
+ let refresh = options.rereadOutcome;
13049
+ let reread = refresh ? refresh.ok ? "fresh" : "failed" : "skipped";
13050
+ if (options.reread && !refresh) {
13051
+ adoptLegacyBaseline(workspace);
13052
+ const startedAt = Date.now();
13053
+ try {
13054
+ refresh = options.fetch ? await refreshPrContext(workspace, options.fetch) : await refreshPrContext(workspace);
13055
+ } catch {
13056
+ refresh = unforeseenFailure(startedAt);
13057
+ }
13058
+ reread = refresh.ok ? "fresh" : "failed";
13059
+ }
11626
13060
  return {
11627
- ...context,
11628
- ...await assemble(context.changeSet, context.diffBudgetChars, context.lens, prContext),
11629
- contextId: context.contextId,
11630
- prContext
13061
+ prepared: storedConversation(workspace, reread, refresh && !refresh.ok ? rereadReasonOf(refresh) : void 0),
13062
+ ...refresh && !options.rereadOutcome ? { refresh } : {}
11631
13063
  };
11632
13064
  }
11633
- async function assemble(changeSet, budget, lens, prContext) {
11634
- const allFiles = changeSet.data.files;
11635
- const selection = selectInlineDiffs(allFiles, budget);
11636
- const inlinePaths = new Set(selection.inline.map((file) => file.name));
11637
- const processed = await processPRData({
11638
- ...changeSet.data,
11639
- files: selection.inline,
11640
- changedFileCount: allFiles.length,
11641
- ...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
11642
- discussion: prContext?.discussion
11643
- }, selection.inline.length, null);
11644
- const shared = {
11645
- repositoryAccess: "repository",
11646
- changeInventory: renderChangeInventory(allFiles, inlinePaths)
13065
+ /**
13066
+ * Every way the fetch can fail is an outcome rather than an exception, so a throw means something
13067
+ * unforeseen did. The honest thing to say about it is that nothing changed.
13068
+ */
13069
+ function unforeseenFailure(startedAt) {
13070
+ return {
13071
+ at: Date.now(),
13072
+ startedAt,
13073
+ ok: false,
13074
+ message: "The pull request could not be re-read. Nothing here has changed.",
13075
+ reason: "failed"
11647
13076
  };
11648
- const { prompt, discussionOverflow } = lens.type === "kiss" ? buildKissPrompt(processed, {
11649
- ...shared,
11650
- audience: lens.audience
11651
- }) : buildStructuredReviewPrompt(processed, {
11652
- ...shared,
11653
- guideCoverage: "review-path"
13077
+ }
13078
+ /**
13079
+ * The longest a Buddy AI turn waits for the conversation before it answers without the re-read.
13080
+ *
13081
+ * A product decision, not a measurement (2026-10-11): about two seconds is the aim and three the
13082
+ * hard ceiling, because an assistant that pauses before every answer feels broken however fresh
13083
+ * its facts. Measured against it: a small request re-reads in 0.6 to 0.75 seconds; one with
13084
+ * hundreds of threads and reviews takes 3.4 to 4, and so answers from the stored copy, stated as
13085
+ * stale, while the re-read finishes and is saved for the next turn.
13086
+ */
13087
+ var ASK_REREAD_CEILING_MS = CONVERSATION_REREAD_CEILING_MS;
13088
+ /**
13089
+ * `prepareConversation` with a re-read that may take no longer than `ceilingMs`.
13090
+ *
13091
+ * The re-read runs on a copy of the review, so one that misses the ceiling cannot change the
13092
+ * review in memory while the turn is built from it. One that lands in time is taken over onto the
13093
+ * review and returned for the caller to save. One that lands late is saved by itself when it does,
13094
+ * through the same guarded save, so the next turn has it.
13095
+ */
13096
+ async function prepareConversationWithin(workspace, ceilingMs, fetch) {
13097
+ if (!workspace.prContext) return { prepared: { state: { kind: "not-attached" } } };
13098
+ adoptLegacyBaseline(workspace);
13099
+ const reattributing = JSON.stringify(workspace.session.result);
13100
+ const copy = structuredClone(workspace);
13101
+ const startedAt = Date.now();
13102
+ const reread = (async () => {
13103
+ try {
13104
+ return fetch ? await refreshPrContext(copy, fetch) : await refreshPrContext(copy);
13105
+ } catch {
13106
+ return unforeseenFailure(startedAt);
13107
+ }
13108
+ })();
13109
+ let timer;
13110
+ const late = new Promise((resolve) => {
13111
+ timer = setTimeout(() => resolve("late"), ceilingMs);
11654
13112
  });
13113
+ const first = await Promise.race([reread, late]);
13114
+ clearTimeout(timer);
13115
+ if (first === "late") {
13116
+ reread.then((outcome) => saveRefresh(copy, outcome, reattributing)).catch(() => void 0);
13117
+ return { prepared: storedConversation(workspace, "failed", "too-slow") };
13118
+ }
13119
+ workspace.prContext = copy.prContext;
13120
+ workspace.session = copy.session;
11655
13121
  return {
11656
- lens,
11657
- instructions: prompt,
11658
- diff: buildDiffText(selection.inline),
11659
- changeSet,
11660
- processed,
11661
- fileCoverage: {
11662
- ...processed.fileCoverage,
11663
- repositoryAvailable: true
11664
- },
11665
- inventoryOnlyFiles: selection.inventoryOnly.map((file) => file.name),
11666
- diffBudgetChars: budget,
11667
- discussionOverflow: discussionOverflow ?? false
13122
+ prepared: storedConversation(workspace, first.ok ? "fresh" : "failed", first.ok ? void 0 : rereadReasonOf(first)),
13123
+ refresh: first
11668
13124
  };
11669
13125
  }
11670
13126
  /**
11671
- * The change's pull request, as the prompt should present it once a forge request is attached.
11672
- *
11673
- * With no description, the commit subjects stay: they are still the best account of the change
11674
- * there is, and still labelled as commits rather than as the author's words.
13127
+ * The conversation as the review holds it, with no fetch: what `prepareConversation` returns once
13128
+ * any re-read is done, and what a prompt builder states when nobody prepared one for it.
11675
13129
  */
11676
- function pullRequestFor(local, prContext) {
11677
- const body = prContext.pullRequest.body;
11678
- if (!body) return local;
11679
- const { bodySource: _commits, ...rest } = local;
13130
+ function storedConversation(workspace, reread = "skipped", rereadReason) {
13131
+ if (!workspace.prContext) return { state: { kind: "not-attached" } };
13132
+ const discussion = workspace.prContext.discussion;
11680
13133
  return {
11681
- ...rest,
11682
- body
13134
+ state: {
13135
+ kind: "attached",
13136
+ fetchedAt: discussion.fetchedAt,
13137
+ reread,
13138
+ ...reread === "failed" && rereadReason ? { rereadReason } : {}
13139
+ },
13140
+ discussion,
13141
+ delta: diffDiscussion(analysedBaselineOf(workspace), discussion)
11683
13142
  };
11684
13143
  }
11685
13144
  /**
11686
- * The inline patches as one blob, for grounding the assistant.
13145
+ * Put a re-read on disk: the one save the Re-check button and the assistant both make.
11687
13146
  *
11688
- * No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
11689
- * the same quantity is a number nobody can explain the day it binds.
13147
+ * Re-reads overlap. A Buddy AI turn's can miss its ceiling and land seconds later, after the next
13148
+ * turn or the Re-check button has saved a newer one, so this save checks what is on disk before
13149
+ * writing anything over it:
13150
+ *
13151
+ * - A re-read that failed never writes the conversation or the pull request. It holds only the
13152
+ * copy it started from, and writing that back would restore an older conversation over a newer
13153
+ * one. It is recorded as the latest attempt only if it is: nothing on disk began after it did.
13154
+ * - A re-read that worked writes only if its fetch began no earlier than the one on disk did. A
13155
+ * slow one that finishes after a quicker, later one saved is older, however late it finished,
13156
+ * and is dropped whole. When it began is `requestedAt`; see `orderOf`.
13157
+ *
13158
+ * Its findings go only if they are still the findings on disk: an update that landed meanwhile
13159
+ * wrote a newer analysis, and putting these back would undo it. That update re-read the
13160
+ * conversation for itself, so nothing is lost by leaving its findings alone.
13161
+ *
13162
+ * `reattributing` is the findings as they stood before the re-read began, serialised.
11690
13163
  */
11691
- function buildDiffText(files) {
11692
- return files.map((file) => `--- ${file.name}\n${file.patch}`).join("\n\n");
13164
+ function saveRefresh(workspace, outcome, reattributing) {
13165
+ workspace.lastPrRefresh = outcome;
13166
+ mutateWorkspace(workspace.id, (current) => {
13167
+ const adopted = workspace.analysedConversation;
13168
+ if (!current.analysedConversation && adopted && adopted.sessionId === current.session.id) current.analysedConversation = adopted;
13169
+ if (!outcome.ok) {
13170
+ if (!overtaken(current, outcome)) current.lastPrRefresh = outcome;
13171
+ return;
13172
+ }
13173
+ if (workspace.prContext) {
13174
+ if (!atLeastAsNew(workspace.prContext.discussion, current)) return;
13175
+ current.prContext = workspace.prContext;
13176
+ }
13177
+ current.lastPrRefresh = outcome;
13178
+ if (JSON.stringify(current.session.result) === reattributing) current.session = {
13179
+ ...current.session,
13180
+ result: workspace.session.result
13181
+ };
13182
+ });
13183
+ }
13184
+ /** Whether something on disk is newer than a failed attempt: another attempt begun after it, or a conversation fetched after it began. */
13185
+ function overtaken(current, outcome) {
13186
+ const began = outcome.startedAt ?? outcome.at;
13187
+ const recorded = current.lastPrRefresh;
13188
+ if (recorded && (recorded.startedAt ?? recorded.at) > began) return true;
13189
+ const onDisk = current.prContext ? orderOf(current.prContext.discussion) : void 0;
13190
+ return onDisk !== void 0 && onDisk > began;
13191
+ }
13192
+ /**
13193
+ * Where a snapshot falls in time: when its fetch began. A snapshot from before that was recorded
13194
+ * has only when it finished, which is no earlier than when it began, so it is never taken as older
13195
+ * than it is.
13196
+ */
13197
+ function orderOf(discussion) {
13198
+ const at = discussion.requestedAt ?? discussion.fetchedAt;
13199
+ return typeof at === "number" && Number.isFinite(at) ? at : void 0;
13200
+ }
13201
+ function atLeastAsNew(discussion, current) {
13202
+ const onDisk = current.prContext ? orderOf(current.prContext.discussion) : void 0;
13203
+ const ours = orderOf(discussion);
13204
+ return onDisk === void 0 || ours === void 0 || ours >= onDisk;
13205
+ }
13206
+ /**
13207
+ * What a review's own fetch came to, as the state its prompt states.
13208
+ *
13209
+ * The outcome is the job's record of the `conversation` phase. `attached` is dated by the copy the
13210
+ * prompt was actually built from, which a re-read may have replaced since the phase ran.
13211
+ */
13212
+ function stateOfOutcome(outcome, prContext) {
13213
+ switch (outcome.kind) {
13214
+ case "attached": return prContext ? {
13215
+ kind: "attached",
13216
+ fetchedAt: prContext.discussion.fetchedAt,
13217
+ reread: "skipped"
13218
+ } : { kind: "not-attached" };
13219
+ case "no-request": return { kind: "no-request" };
13220
+ case "unsupported-forge":
13221
+ case "cli-missing":
13222
+ case "not-authenticated":
13223
+ case "failed": return {
13224
+ kind: "unavailable",
13225
+ reason: outcome.kind
13226
+ };
13227
+ }
11693
13228
  }
11694
13229
  //#endregion
11695
13230
  //#region ../../packages/review-harness/src/domain/kiss/parse.ts
@@ -11932,45 +13467,6 @@ async function validateKiss(raw, changedPaths, existingPaths) {
11932
13467
  };
11933
13468
  }
11934
13469
  //#endregion
11935
- //#region ../../packages/review-harness/src/domain/discussion/discussion_attribution.ts
11936
- /**
11937
- * Every surviving attribution has its author, status and link taken from the real thread — the
11938
- * model's only contribution is which thread it points at. Anything it echoed back about who said
11939
- * what is discarded, so a correct id can't smuggle a wrong attribution alongside it.
11940
- */
11941
- function attributeIssuesToDiscussion(result, pd) {
11942
- let attributed = 0;
11943
- let rejected = 0;
11944
- const issues = result.issues.map((issue) => {
11945
- if (!issue.existingDiscussion) return issue;
11946
- const thread = pd.byId[issue.existingDiscussion.threadId];
11947
- if (!thread) {
11948
- rejected++;
11949
- const { existingDiscussion: _dropped, ...rest } = issue;
11950
- return rest;
11951
- }
11952
- attributed++;
11953
- return {
11954
- ...issue,
11955
- existingDiscussion: {
11956
- threadId: thread.id,
11957
- raisedBy: thread.comments[0]?.author ?? "unknown",
11958
- status: thread.resolved ? "resolved" : "unresolved",
11959
- url: thread.comments[0]?.url || void 0,
11960
- relation: issue.existingDiscussion.relation === "related" ? "related" : "same"
11961
- }
11962
- };
11963
- });
11964
- return {
11965
- result: {
11966
- ...result,
11967
- issues
11968
- },
11969
- attributed,
11970
- rejected
11971
- };
11972
- }
11973
- //#endregion
11974
13470
  //#region ../../packages/review-harness/src/git/tree.ts
11975
13471
  /**
11976
13472
  * Which of these paths the commit actually holds.
@@ -14734,14 +16230,14 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
14734
16230
  signal?.throwIfAborted();
14735
16231
  job = update(job, "analysing", "Analysing the change");
14736
16232
  let observedModel;
14737
- const payload = await runAnalysisFor(job, workspaceId, runners, signal, (model) => {
16233
+ const { payload, delivered } = await runAnalysisFor(job, workspaceId, runners, signal, (model) => {
14738
16234
  observedModel = model;
14739
16235
  });
14740
16236
  signal?.throwIfAborted();
14741
16237
  if (!commitJob(job.id)) throw new AgentCancelledError("Cancelled from the workspace page.");
14742
16238
  const versionOf = runners.agentVersion ?? installedAgentVersion$1;
14743
16239
  if (job.reviewType === "kiss") await applyKiss(job, workspaceId, payload, versionOf, observedModel);
14744
- else await applyGuide(job, workspaceId, payload, versionOf, () => measurementOf(from, startedAt, now()), observedModel);
16240
+ else await applyGuide(job, workspaceId, payload, delivered, versionOf, () => measurementOf(from, startedAt, now()), observedModel);
14745
16241
  job = update(job, "done");
14746
16242
  const completedAt = now();
14747
16243
  recordAnalysisRuntime(job, workspaceId, from, completedAt - startedAt, completedAt, observedModel);
@@ -14946,7 +16442,13 @@ async function contextFor(job, workspaceId) {
14946
16442
  ...baseFor(job.target),
14947
16443
  ...expectedFor(job.target)
14948
16444
  });
14949
- return stored.prContext ? withPrContext(base, stored.prContext) : base;
16445
+ const context = stored.prContext ? await withPrContext(base, stored.prContext) : base;
16446
+ if (!job.conversation) return context;
16447
+ const state = stateOfOutcome(job.conversation, stored.prContext);
16448
+ return {
16449
+ ...context,
16450
+ instructions: `${context.instructions}\n\n---\n\n${conversationStateBlock(state)}`
16451
+ };
14950
16452
  }
14951
16453
  /** As `contextFor`, starting from the workspace alone by way of the job it belongs to. */
14952
16454
  async function contextOf(stored) {
@@ -14954,6 +16456,7 @@ async function contextOf(stored) {
14954
16456
  }
14955
16457
  async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel) {
14956
16458
  const context = await contextFor(job, workspaceId);
16459
+ const delivered = context.deliveredDiscussion ?? null;
14957
16460
  const onProgress = (text) => {
14958
16461
  const current = loadJob(job.id);
14959
16462
  if (current) saveJob({
@@ -14962,26 +16465,32 @@ async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel
14962
16465
  updatedAt: Date.now()
14963
16466
  });
14964
16467
  };
14965
- if (runners.analyse) return runners.analyse({
14966
- prompt: context.instructions,
14967
- cwd: job.worktreePath,
14968
- onProgress,
14969
- ...onObservedModel ? { onObservedModel } : {},
14970
- signal
14971
- });
16468
+ if (runners.analyse) return {
16469
+ payload: await runners.analyse({
16470
+ prompt: context.instructions,
16471
+ cwd: job.worktreePath,
16472
+ onProgress,
16473
+ ...onObservedModel ? { onObservedModel } : {},
16474
+ signal
16475
+ }),
16476
+ delivered
16477
+ };
14972
16478
  const kissRunId = job.reviewType === "kiss" ? (loadWorkspace(workspaceId)?.kissRuns ?? []).find((run) => run.jobId === job.id)?.id : void 0;
14973
- return withUsageRecorded(workspaceId, job.reviewType === "kiss" ? "kiss" : "analysis", job.env, (onUsage) => agentFor(job.env).run({
14974
- capability: "read-code",
14975
- prompt: context.instructions,
14976
- cwd: job.worktreePath,
14977
- originRepoPath: job.target.originRepoPath,
14978
- env: job.env,
14979
- ...job.requestedModel ? { requestedModel: job.requestedModel } : {},
14980
- onProgress,
14981
- onUsage,
14982
- ...onObservedModel ? { onObservedModel } : {},
14983
- signal
14984
- }), kissRunId, job.requestedModel);
16479
+ return {
16480
+ payload: await withUsageRecorded(workspaceId, job.reviewType === "kiss" ? "kiss" : "analysis", job.env, (onUsage) => agentFor(job.env).run({
16481
+ capability: "read-code",
16482
+ prompt: context.instructions,
16483
+ cwd: job.worktreePath,
16484
+ originRepoPath: job.target.originRepoPath,
16485
+ env: job.env,
16486
+ ...job.requestedModel ? { requestedModel: job.requestedModel } : {},
16487
+ onProgress,
16488
+ onUsage,
16489
+ ...onObservedModel ? { onObservedModel } : {},
16490
+ signal
16491
+ }), kissRunId, job.requestedModel),
16492
+ delivered
16493
+ };
14985
16494
  }
14986
16495
  /**
14987
16496
  * Write a finished KISS analysis onto the run that asked for it.
@@ -15030,7 +16539,7 @@ function failCurrentKissRun(workspaceId, job, reason, kind, rejectedAnswer) {
15030
16539
  failKissRun(stored, run.id, reason, kind, rejectedAnswer);
15031
16540
  });
15032
16541
  }
15033
- async function applyGuide(job, workspaceId, payload, versionOf, measure, observedModel) {
16542
+ async function applyGuide(job, workspaceId, payload, delivered, versionOf, measure, observedModel) {
15034
16543
  const stored = loadWorkspace(workspaceId);
15035
16544
  const context = await contextOf(stored);
15036
16545
  let applied;
@@ -15055,7 +16564,11 @@ async function applyGuide(job, workspaceId, payload, versionOf, measure, observe
15055
16564
  ...fresh,
15056
16565
  session: keepReviewerFields(fresh, session),
15057
16566
  audit: applied.audit,
15058
- referencedFiles: applied.referencedFiles
16567
+ referencedFiles: applied.referencedFiles,
16568
+ analysedConversation: {
16569
+ sessionId: session.id,
16570
+ discussion: delivered
16571
+ }
15059
16572
  }));
15060
16573
  }
15061
16574
  /**
@@ -15192,6 +16705,54 @@ async function settleReview(workspaceId, reviewType = "standard", signal) {
15192
16705
  job
15193
16706
  };
15194
16707
  }
16708
+ var realReads = {
16709
+ job: (workspaceId) => {
16710
+ const jobId = loadWorkspace(workspaceId)?.jobId;
16711
+ return jobId ? loadJob(jobId) : null;
16712
+ },
16713
+ hasResult: (workspaceId) => Boolean(loadWorkspace(workspaceId)?.session?.result),
16714
+ driverGone,
16715
+ wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
16716
+ };
16717
+ /**
16718
+ * Wait for a review that something else is analysing, and say how it ended, running nothing.
16719
+ *
16720
+ * `settleReview` is the wrong tool for a review already in progress: it drives the job, and a
16721
+ * second driver on a running job is the duplicate analysis this exists to avoid. This only reads
16722
+ * the job record until it ends, with the same outcomes `settleReview` reports. A driver that went
16723
+ * away mid-run ends the wait too, as a failure, rather than leaving it to the sweep half an hour on.
16724
+ */
16725
+ async function observeReview(workspaceId, reads = realReads, everyMs = 2e3) {
16726
+ for (;;) {
16727
+ const job = reads.job(workspaceId);
16728
+ if (!job) return {
16729
+ ok: false,
16730
+ reason: "The review’s analysis record could not be read.",
16731
+ stopped: false
16732
+ };
16733
+ if (isTerminal(job)) {
16734
+ if (job.phase !== "done") return {
16735
+ ok: false,
16736
+ reason: failureMessage(job),
16737
+ stopped: job.failure?.stopped === true
16738
+ };
16739
+ return reads.hasResult(workspaceId) ? {
16740
+ ok: true,
16741
+ job
16742
+ } : {
16743
+ ok: false,
16744
+ reason: "The analysis finished without recording a review.",
16745
+ stopped: false
16746
+ };
16747
+ }
16748
+ if (reads.driverGone(job.id)) return {
16749
+ ok: false,
16750
+ reason: `The analysis stopped: ${DRIVER_GONE}`,
16751
+ stopped: false
16752
+ };
16753
+ await reads.wait(everyMs);
16754
+ }
16755
+ }
15195
16756
  /**
15196
16757
  * Why a run ended, when it did not end by finishing.
15197
16758
  *
@@ -16096,64 +17657,27 @@ async function recordKissRun(input) {
16096
17657
  ...requestedModel ? { requestedModel } : {}
16097
17658
  });
16098
17659
  mutateWorkspace(workspace.id, (current) => {
16099
- current.kissRuns = [...current.kissRuns ?? [], run];
16100
- current.latestKissRunId = run.id;
16101
- });
16102
- return job;
16103
- });
16104
- if (isBusy(started)) throw new ReviewBeingDeletedError();
16105
- return started;
16106
- }
16107
- /**
16108
- * Record the run and drive it to its end.
16109
- *
16110
- * The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
16111
- * during the twenty minutes that follow is refused by the live job claim rather than made to wait
16112
- * for it.
16113
- *
16114
- * Counted by `track` from the first await, so a restart is refused for the whole of the run rather
16115
- * than only once a caller remembered to count it. Only the server calls this; the CLI records the
16116
- * run and drives it in its own process.
16117
- */
16118
- function startKissJob(input, runners = {}) {
16119
- return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
16120
- }
16121
- //#endregion
16122
- //#region ../../packages/review-harness/src/workspace/agent_env.ts
16123
- /**
16124
- * The environment a review's later runs are pinned to: the job's, not the daemon's.
16125
- *
16126
- * A job records what it must run under at the moment it is created, and the analysis reads it back
16127
- * from there. Every run that happens *after* the analysis read `captureEnv()` instead, which
16128
- * answers a question about the daemon rather than about the review, and the daemon is the process
16129
- * that happened to be started first by whichever shell got there first. Two things went wrong with
16130
- * that. `CLAUDE_CONFIG_DIR` decides which account answers, so a review made under one and revisited
16131
- * under another is served by a Claude that may not have access to the same models or settings. And
16132
- * `agentId` decides which adapter runs at all, so a review made by one agent had its questions
16133
- * answered, and its findings reconciled, by whichever is default today, silently, with the result
16134
- * still shown as the review's.
16135
- *
16136
- * Three callers, and they are exactly the three things a reviewer can ask of a review after it is
16137
- * written: a question in the Ask panel, bringing the analysis up to date, and re-reading the
16138
- * conversation. All three put the answer on the page under the review's own name, so all three owe
16139
- * the reader the review's own agent.
17660
+ current.kissRuns = [...current.kissRuns ?? [], run];
17661
+ current.latestKissRunId = run.id;
17662
+ });
17663
+ return job;
17664
+ });
17665
+ if (isBusy(started)) throw new ReviewBeingDeletedError();
17666
+ return started;
17667
+ }
17668
+ /**
17669
+ * Record the run and drive it to its end.
16140
17670
  *
16141
- * Falling back to capturing now covers a workspace with no job and one whose job has aged out of
16142
- * retention, and that fallback is not the rare path: jobs are finished business within hours and
16143
- * reviews are kept for thirty days, so most questions asked of an older review find nothing here.
17671
+ * The lock is released before the analysis begins, exactly as `reanalyseReview` does, so a deletion
17672
+ * during the twenty minutes that follow is refused by the live job claim rather than made to wait
17673
+ * for it.
16144
17674
  *
16145
- * Which is why it captures for *the review's own agent* rather than the default one. Capturing for
16146
- * the default reintroduced the exact bug this module exists to fix, one retention period later and
16147
- * far more quietly: a review made by Codex, asked a question a day after its job was swept, would
16148
- * have been answered by Claude with the answer shown as the review's. The environment has to be
16149
- * captured now, because it genuinely is all there is left to read. Who to capture it for is a fact
16150
- * the review still holds.
17675
+ * Counted by `track` from the first await, so a restart is refused for the whole of the run rather
17676
+ * than only once a caller remembered to count it. Only the server calls this; the CLI records the
17677
+ * run and drives it in its own process.
16151
17678
  */
16152
- function agentEnvOf(workspace) {
16153
- const job = workspace.jobId ? loadJob(workspace.jobId) : null;
16154
- if (job?.env) return job.env;
16155
- const id = agentIdOf(workspace.session);
16156
- return (AGENT_IDS.includes(id) ? agentById(id) : defaultAgent()).captureEnv();
17679
+ function startKissJob(input, runners = {}) {
17680
+ return track(recordKissRun(input).then((job) => runJob(job.workspaceId, runners, "kiss")));
16157
17681
  }
16158
17682
  //#endregion
16159
17683
  //#region ../../packages/review-harness/src/domain/ai_review/reconciliation.ts
@@ -16195,17 +17719,29 @@ function priorVerdict(item) {
16195
17719
  return ` [previously: ${status}]`;
16196
17720
  }
16197
17721
  /**
17722
+ * A finding a comment resolved says which comment, so the model can tell it from one the code
17723
+ * resolved. And one whose comment has changed since says that too: it is the one settled finding
17724
+ * the model is asked to judge again. Every other finding reads exactly as `priorVerdict` has it.
17725
+ */
17726
+ function priorFindingVerdict(issue, flag) {
17727
+ const resolvedBy = conversationResolution(issue);
17728
+ if (!resolvedBy) return priorVerdict(issue);
17729
+ const cite = `@${resolvedBy.author} in [${resolvedBy.threadId}] [comment ${resolvedBy.commentId}]`;
17730
+ if (!flag) return ` [previously: resolved by ${cite}]`;
17731
+ return ` [previously: resolved by ${cite}; that comment has since been ${flag.change}. Reassess this finding]`;
17732
+ }
17733
+ /**
16198
17734
  * Each serialiser below states a finding's grade as the review now holds it, through `assess`: the
16199
17735
  * reviewer may have moved it, and a model told only the analysis's grade would re-judge from where
16200
17736
  * the review no longer stands. A caller with no ledger passes `ANALYSIS_ONLY`.
16201
17737
  */
16202
- function formatPriorFindings(issues, assess) {
17738
+ function formatPriorFindings(issues, assess, reassess = /* @__PURE__ */ new Map()) {
16203
17739
  if (!issues.length) return "(The previous analysis raised no issues.)";
16204
17740
  return issues.map((issue, index) => {
16205
17741
  const related = (issue.relatedFiles ?? []).map((file) => ` [related: ${file}, not changed]`).join("");
16206
17742
  const location = (issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "") + related;
16207
17743
  const description = issue.description.trim();
16208
- return `${index}. (${promptGrade(assess("issue", issue))})${location}${priorVerdict(issue)} ${issue.title.trim()}${description ? `\n ${description}` : ""}`;
17744
+ return `${index}. (${promptGrade(assess("issue", issue))})${location}${priorFindingVerdict(issue, reassess.get(index))} ${issue.title.trim()}${description ? `\n ${description}` : ""}`;
16209
17745
  }).join("\n");
16210
17746
  }
16211
17747
  /**
@@ -16241,9 +17777,10 @@ var RECONCILIATION_SHAPE = `{
16241
17777
  * part of the job. Built rather than kept as two constants: two hand-maintained copies of a schema
16242
17778
  * is how the one that is edited less often quietly stops matching the parser.
16243
17779
  */
16244
- function reconciliationShape(includeQuestions) {
16245
- if (!includeQuestions) return RECONCILIATION_SHAPE;
16246
- const lines = RECONCILIATION_SHAPE.split("\n");
17780
+ function reconciliationShape(includeQuestions, withDiscussion = false) {
17781
+ const shape = withDiscussion ? RECONCILIATION_SHAPE.replace("\"confidence\"?: \"high\" | \"medium\" | \"low\" }]", `"confidence"?: "high" | "medium" | "low"${DISCUSSION_ISSUE_FIELD} }]`).replace("\"severityReason\"?: string }]", `"severityReason"?: string${DISCUSSION_EVIDENCE_FIELD} }]`) : RECONCILIATION_SHAPE;
17782
+ if (!includeQuestions) return shape;
17783
+ const lines = shape.split("\n");
16247
17784
  const findingsLine = lines.findIndex((line) => line.includes("\"findings\""));
16248
17785
  return [
16249
17786
  ...lines.slice(0, findingsLine + 1),
@@ -16270,6 +17807,41 @@ function formatPriorQuestions(questions, assess) {
16270
17807
  }).join("\n");
16271
17808
  }
16272
17809
  /**
17810
+ * The shape again, with the one field a discussion-aware run may also set on a new issue. The
17811
+ * parser has always accepted it; it is shown only where the prompt holds threads to cite.
17812
+ */
17813
+ var DISCUSSION_ISSUE_FIELD = ", \"existingDiscussion\"?: { \"threadId\": string, \"relation\": \"same\" | \"related\" }";
17814
+ /** The comment or review a finding's status rests on, on the same discussion-aware runs. */
17815
+ var DISCUSSION_EVIDENCE_FIELD = ", \"evidence\"?: { \"threadId\": string, \"commentId\": string } | { \"reviewId\": string }";
17816
+ /**
17817
+ * The conversation as evidence: what it may settle, what it may not, and how to say which.
17818
+ *
17819
+ * The line this draws is between evidence about reality and claims about code. A maintainer saying
17820
+ * the service a finding worries about is deleted next week changes the finding's premise, and may
17821
+ * settle it with no commit at all. Someone saying a bug is fixed is a claim about code the model
17822
+ * can read, and only the code settles that.
17823
+ */
17824
+ var DISCUSSION_RULES = `THE CONVERSATION AS EVIDENCE
17825
+
17826
+ The changes in the pull request's conversation are listed above. Treat them as context, not ground truth: reviewers can be wrong, or writing about an older revision.
17827
+ - A comment may settle or reclassify a finding when it gives evidence that invalidates or materially changes the premise the finding rested on: for example a maintainer establishing that the code concerned is being removed, or that an assumption the finding made is false. Then "resolved" (or "affected", where it changes the finding without settling it) is right, and the note must cite that comment.
17828
+ - A comment saying a defect is fixed, or will be, is a claim about the code and not evidence. Without the fixing change in code you can read, that finding stays "still-open" or becomes "affected", never "resolved".
17829
+ - In "note" and "answer", name a comment by its author and what it establishes, for example "@sam confirms the job is being retired". Write no ids in them: the reviewer reads those sentences, and ids belong only in "evidence".
17830
+ - Whenever a comment or a review is the reason for a finding's status, also name it in that finding's "evidence": "threadId" and "commentId" from the comment's line (its "[T_123]" and "[comment C_9]" markers, without the brackets or the word "comment"), or "reviewId" from a review's "[review R_1]" marker. Only a comment can be the evidence for "resolved": a review summary may support "affected" or "still-open", and a resolution resting on one is marked for reassessment. A resolution that rests on the conversation without valid evidence is marked for reassessment too. Copy every id verbatim from the list above, and never invent one.
17831
+ - A question the conversation answers may be "answered": put the answer in "answer", naming the comment the same way.
17832
+ - A concern raised in the conversation that the earlier analysis did not cover belongs in "newIssues" only if you confirm it by reading the code. Then set "existingDiscussion" on it: "threadId" copied verbatim from that thread's marker, "relation" "same" when the thread makes this exact point or "related" when it is only near it. Omit the field if no listed thread matches.
17833
+ - A severity or importance suggestion may rest on the conversation; say which comment in the reason.`;
17834
+ /**
17835
+ * The one exception to "resolved stays resolved", stated only when a finding needs it: a resolution
17836
+ * that rested on a comment the conversation has since edited or deleted. Three answers, kept apart,
17837
+ * because "cannot tell" is not "reopened": the reviewer decides on a finding marked affected.
17838
+ */
17839
+ var REASSESS_TASK = ` The one exception: a finding marked "that comment has since been edited" or "deleted" was resolved by a comment that has changed since. For that finding alone, judge again from the comment as it now reads; the change, and what the comment said before, are listed above.
17840
+ - "resolved": it remains resolved despite the change. Name in "evidence" what it now rests on.
17841
+ - "still-open": the change establishes that the original finding is valid again.
17842
+ - "affected": the evidence changed and you cannot establish whether the finding is still valid. Use this, not "still-open", whenever you are unsure.
17843
+ Keep it as this entry. Do not raise it again in "newIssues" unless the problem is now materially different.`;
17844
+ /**
16273
17845
  * The one optional judgement about a grade: a suggestion the reviewer answers, separate from the
16274
17846
  * status, reasoned from the grade the review holds now (each list states it), and only where the
16275
17847
  * new changes move it. Unasked for, a model restates every grade it was shown, and each would reach
@@ -16297,33 +17869,42 @@ var GRADE_TASK = {
16297
17869
  */
16298
17870
  function buildReconciliationInstructions(input, options = {}) {
16299
17871
  const { previous, totalCommits, changedFiles, possiblyTruncated } = input;
17872
+ const reassess = input.reassess ?? /* @__PURE__ */ new Map();
16300
17873
  const includeQuestions = options.includeQuestions === true;
16301
17874
  const commitCount = totalCommits === 1 ? "1 new commit" : `${totalCommits} new commits`;
16302
17875
  const fileCount = changedFiles === 1 ? "1 file" : `${changedFiles} files`;
17876
+ const discussion = options.discussion;
17877
+ const conversationOnly = discussion !== void 0 && !discussion.codeChanged;
16303
17878
  const truncationWarning = possiblyTruncated ? `\n\nIMPORTANT: the list of changes above is INCOMPLETE — more files changed than could be retrieved. Do not mark a previous finding "resolved" on the strength of absence: a file you cannot see may or may not contain the fix. Prefer "still-open" or "affected" where you cannot see the relevant code, and say so in the note.` : "";
16304
17879
  const questionList = includeQuestions ? `\n\nPrevious open questions, numbered — refer to each by its number in "sourceIndex":\n${formatPriorQuestions(previous.questions, input.assess)}` : "";
16305
17880
  const tasks = [
16306
- `For EVERY numbered finding above, decide what the new changes did to it:
17881
+ `${discussion ? `For EVERY numbered finding above, decide what ${conversationOnly ? "the changes in the conversation" : "the new commits and the changes in the conversation"} did to it:
17882
+ - "resolved": ${conversationOnly ? "" : "the new commits fix it, and you can point at the change that does so; or "}the conversation gives evidence that invalidates its premise, under the rules below.
17883
+ - "still-open": nothing since settles it. This includes findings nothing since touches.
17884
+ - "affected": ${conversationOnly ? "a comment bears on it" : "the code it concerns changed, or a comment bears on it,"} without clearly settling it either way. The reviewer needs to look again.` : `For EVERY numbered finding above, decide what the new changes did to it:
16307
17885
  - "resolved" — the new changes appear to fix it. Only when you can point at the change that does so.
16308
17886
  - "still-open" — the new changes do not address it. This includes findings the changes never touch.
16309
- - "affected" — the code it concerns changed, but not in a way that clearly settles it either way. The reviewer needs to look again.
17887
+ - "affected" — the code it concerns changed, but not in a way that clearly settles it either way. The reviewer needs to look again.`}
16310
17888
  You MUST return one entry per numbered finding. If you are unsure, return "still-open" or "affected" with a note — do NOT omit the finding. Omitting a finding does not make it go away; it will be reported as still open regardless, without your reasoning attached.
16311
- A finding marked [previously: resolved] was already fixed by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays resolved, and "still-open" is not an available answer for it — these changes not being where the fix was says nothing about whether it holds. If you can see that later code has undone that fix, report it in "newIssues" with what you saw; do not reopen the old entry.
17889
+ A finding marked [previously: resolved] was already fixed by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays resolved, and "still-open" is not an available answer for it — these changes not being where the fix was says nothing about whether it holds. If you can see that later code has undone that fix, report it in "newIssues" with what you saw; do not reopen the old entry.${reassess.size > 0 ? `
17890
+ ${REASSESS_TASK}` : ""}
16312
17891
  ${GRADE_TASK.issue}`,
16313
17892
  ...includeQuestions ? [`For EVERY numbered question above, decide what the new changes did to it:
16314
- - "answered" — the changes settle it. Put the answer in "answer", in a sentence or two, and name what in the code establishes it.
17893
+ - "answered" — the changes settle it. Put the answer in "answer", in a sentence or two, and name what in the code establishes it.${discussion ? " A comment in the conversation that answers it counts too: name it as the rules below say." : ""}
16315
17894
  - "still-open" — the changes do not settle it. This includes questions the changes never touch, and questions only the author can answer.
16316
17895
  - "affected" — the code the question is about changed without answering it. The reviewer should read the question again before asking it.
16317
17896
  A question marked [previously: answered] was already settled by an earlier commit on this branch, which is code you are NOT being shown a diff of. It stays answered, and "still-open" is not an available answer for it.
16318
17897
  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.
16319
17898
  ${GRADE_TASK.question}`] : [],
16320
- `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.`,
17899
+ conversationOnly ? `No commits were pushed, so there is no new code to review. Report a problem in "newIssues" only where the conversation points at something in the code the earlier analysis missed and you have confirmed it by reading the code. Do NOT repeat a previous finding here: if it still stands, that is what "still-open" is for.` : `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.`,
16321
17900
  `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.`,
16322
17901
  `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}`
16323
17902
  ].map((task, index) => `${index + 1}. ${task}`).join("\n");
16324
- return `You are RECONCILING an earlier analysis of this pull request against the changes pushed since that analysis was made.
17903
+ return `${conversationOnly ? `You are RECONCILING an earlier analysis of this pull request against what has changed in its conversation since that analysis was made.
17904
+
17905
+ No commits have been pushed since: the code is exactly what the earlier analysis read. What has changed is the discussion, listed above.` : `You are RECONCILING an earlier analysis of this pull request against the changes pushed since that analysis was made${discussion ? ", and against what has changed in its conversation since" : ""}.
16325
17906
 
16326
- The diff above is NOT the whole pull request. It is only what changed in the ${commitCount} (${fileCount}) pushed after the earlier analysis. The earlier analysis already covered everything before that point.
17907
+ The diff above is NOT the whole pull request. It is only what changed in the ${commitCount} (${fileCount}) pushed after the earlier analysis. The earlier analysis already covered everything before that point.`}
16327
17908
 
16328
17909
  PREVIOUS ANALYSIS
16329
17910
 
@@ -16332,16 +17913,16 @@ Overall risk: ${previous.overallRisk}
16332
17913
  Recommendation: ${previous.recommendation}
16333
17914
 
16334
17915
  Previous findings, numbered — refer to each by its number in "sourceIndex":
16335
- ${formatPriorFindings(previous.issues, input.assess)}${questionList}${truncationWarning}
16336
-
17916
+ ${formatPriorFindings(previous.issues, input.assess, reassess)}${questionList}${truncationWarning}
17917
+ ${discussion ? `\n${DISCUSSION_RULES}\n` : ""}
16337
17918
  YOUR TASK
16338
17919
 
16339
17920
  ${tasks}
16340
17921
 
16341
17922
  Respond with a single JSON object (no markdown, no surrounding text) matching exactly this shape:
16342
- ${reconciliationShape(includeQuestions)}
17923
+ ${reconciliationShape(includeQuestions, discussion !== void 0)}
16343
17924
 
16344
- "note" is one short sentence on *why* the status is what it is — name the change that resolved it, or what leaves it standing. Omit it rather than restating the finding.
17925
+ "note" is one short sentence on *why* the status is what it is — name the change that resolved it, or what leaves it standing.${discussion ? " Where a comment is the reason, name its author." : ""} Omit it rather than restating the finding.
16345
17926
 
16346
17927
  "severity" on a new issue is how much it would matter if real; "confidence" is how sure you are that it is real. Separate axes — do not let one stand in for the other, and omit "confidence" rather than guessing a middle. Report only what is worth the reviewer's attention: empty arrays are valid answers and are far better than padded ones.`;
16347
17928
  }
@@ -16392,6 +17973,19 @@ function reconciliationGradeSuggestions(previous, reconciled, assess) {
16392
17973
  }
16393
17974
  return suggestions;
16394
17975
  }
17976
+ /** A citation as the reply gave it: a comment by thread and comment id, or a review by its id. Anything else is no citation. */
17977
+ function parseCitation(value) {
17978
+ if (typeof value !== "object" || value === null) return void 0;
17979
+ const item = value;
17980
+ const threadId = asString$1(item.threadId).trim();
17981
+ const commentId = asString$1(item.commentId).trim();
17982
+ if (threadId && commentId) return {
17983
+ threadId,
17984
+ commentId
17985
+ };
17986
+ const reviewId = asString$1(item.reviewId).trim();
17987
+ return reviewId ? { reviewId } : void 0;
17988
+ }
16395
17989
  function asStatus(value, allowed) {
16396
17990
  return allowed.includes(value) ? value : null;
16397
17991
  }
@@ -16424,6 +18018,7 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
16424
18018
  seen.add(sourceIndex);
16425
18019
  const note = asString$1(item.note).trim();
16426
18020
  const grade = parseGrade("issue", item.severity, item.severityReason);
18021
+ const cites = parseCitation(item.evidence);
16427
18022
  findings.push({
16428
18023
  sourceIndex,
16429
18024
  status,
@@ -16431,7 +18026,8 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
16431
18026
  ...grade ? { grade: {
16432
18027
  to: grade.to,
16433
18028
  reason: grade.reason
16434
- } } : {}
18029
+ } } : {},
18030
+ ...cites ? { cites } : {}
16435
18031
  });
16436
18032
  }
16437
18033
  return {
@@ -16461,15 +18057,30 @@ function parseReconciliationResult(raw, priorCount, options = {}) {
16461
18057
  * Every prior finding therefore survives into the new result, in its original order, carrying its
16462
18058
  * original id, severity and text. Only the status is added.
16463
18059
  */
16464
- function reconcileIssues(prior, findings) {
18060
+ function reconcileIssues(prior, findings, record) {
16465
18061
  const byIndex = new Map(findings.map((f) => [f.sourceIndex, f]));
16466
18062
  return prior.map((issue, index) => {
16467
- if (issue.reconciliationStatus === "resolved") return issue;
16468
18063
  const verdict = byIndex.get(index);
18064
+ const flag = issue.reconciliationStatus === "resolved" ? record?.flags?.get(index) : void 0;
18065
+ if (flag && record) {
18066
+ const entry = reassessedEntry(verdict, flag, record);
18067
+ return {
18068
+ ...issue,
18069
+ reconciliationStatus: entry.status,
18070
+ ...entry.reason ? { reconciliationNote: entry.reason } : {},
18071
+ statusHistory: appendStatusChange(issue, entry),
18072
+ ...record.against ? { reconciledAgainst: entry.basis === "code" ? "commits" : "conversation" } : {}
18073
+ };
18074
+ }
18075
+ if (issue.reconciliationStatus === "resolved") return issue;
18076
+ const status = verdict?.status ?? "still-open";
18077
+ const recorded = record && isStatusChange(issue.reconciliationStatus, status) ? { statusHistory: appendStatusChange(issue, changeEntry(status, verdict, record)) } : {};
16469
18078
  return {
16470
18079
  ...issue,
16471
- reconciliationStatus: verdict?.status ?? "still-open",
16472
- ...verdict?.note ? { reconciliationNote: verdict.note } : {}
18080
+ reconciliationStatus: status,
18081
+ ...verdict?.note ? { reconciliationNote: verdict.note } : {},
18082
+ ...recorded,
18083
+ ...record?.against ? { reconciledAgainst: verdict?.evidence ? "conversation" : record.against } : {}
16473
18084
  };
16474
18085
  });
16475
18086
  }
@@ -16534,7 +18145,8 @@ function parseQuestions(value, priorCount) {
16534
18145
  function alreadyAnswered(question) {
16535
18146
  return question.reconciliationStatus === "answered" || question.reconciliationAnswer !== void 0;
16536
18147
  }
16537
- function reconcileQuestions(prior, reconciled) {
18148
+ function reconcileQuestions(prior, reconciled, against) {
18149
+ const recorded = against ? { reconciledAgainst: against } : {};
16538
18150
  const byIndex = new Map(reconciled.map((entry) => [entry.sourceIndex, entry]));
16539
18151
  return prior.map((question, index) => {
16540
18152
  if (alreadyAnswered(question)) return {
@@ -16544,7 +18156,8 @@ function reconcileQuestions(prior, reconciled) {
16544
18156
  const verdict = byIndex.get(index);
16545
18157
  if (!verdict) return {
16546
18158
  ...question,
16547
- reconciliationStatus: "still-open"
18159
+ reconciliationStatus: "still-open",
18160
+ ...recorded
16548
18161
  };
16549
18162
  const answered = verdict.status === "answered" && verdict.answer !== void 0;
16550
18163
  const status = verdict.status !== "answered" ? verdict.status : answered ? "answered" : "affected";
@@ -16552,15 +18165,17 @@ function reconcileQuestions(prior, reconciled) {
16552
18165
  ...question,
16553
18166
  reconciliationStatus: status,
16554
18167
  ...verdict.note ? { reconciliationNote: verdict.note } : {},
16555
- ...answered ? { reconciliationAnswer: verdict.answer } : {}
18168
+ ...answered ? { reconciliationAnswer: verdict.answer } : {},
18169
+ ...recorded
16556
18170
  };
16557
18171
  });
16558
18172
  }
16559
18173
  /** Marks a reconciliation's own findings as new, so the UI can tell them from carried-forward ones. */
16560
- function markNewIssues(issues) {
18174
+ function markNewIssues(issues, against) {
16561
18175
  return issues.map((issue) => ({
16562
18176
  ...issue,
16563
- reconciliationStatus: "new"
18177
+ reconciliationStatus: "new",
18178
+ ...against ? { reconciledAgainst: against } : {}
16564
18179
  }));
16565
18180
  }
16566
18181
  /**
@@ -16589,16 +18204,93 @@ function composeSections(parent, added) {
16589
18204
  }));
16590
18205
  }
16591
18206
  //#endregion
16592
- //#region ../../packages/review-harness/src/domain/ui/components/pr_update_notice.ts
16593
- function countByStatus(issues) {
18207
+ //#region ../../packages/review-harness/src/domain/ai_review/update_counts.ts
18208
+ /**
18209
+ * What an update did to a review, counted by comparing each finding and question with what it was
18210
+ * before. Shared by the workspace's update and the extension's, so both announce the same numbers.
18211
+ */
18212
+ /**
18213
+ * Paired by position: `reconcileIssues` and `reconcileQuestions` return the previous ones in their
18214
+ * order, and what the update added comes after them.
18215
+ */
18216
+ function countUpdateChanges(previous, result) {
16594
18217
  const counts = {
16595
18218
  resolved: 0,
16596
- "still-open": 0,
18219
+ stillOpen: 0,
16597
18220
  affected: 0,
16598
- new: 0
18221
+ newIssues: 0,
18222
+ questionsAnswered: 0
18223
+ };
18224
+ let byConversation = 0;
18225
+ let reopened = 0;
18226
+ result.issues.forEach((issue, index) => {
18227
+ const before = previous.issues[index]?.reconciliationStatus;
18228
+ const now = issue.reconciliationStatus;
18229
+ if (now === "new") counts.newIssues++;
18230
+ else if (now === "resolved") {
18231
+ if (before === "resolved") return;
18232
+ counts.resolved++;
18233
+ if (issue.statusHistory?.at(-1)?.basis === "conversation") byConversation++;
18234
+ } else if (before === "resolved" && now === "still-open") reopened++;
18235
+ else if (now === "affected" && before !== "affected") counts.affected++;
18236
+ else counts.stillOpen++;
18237
+ });
18238
+ counts.questionsAnswered = result.questions.filter((question, index) => {
18239
+ const before = previous.questions[index];
18240
+ return question.reconciliationStatus === "answered" && !(before && alreadyAnswered(before));
18241
+ }).length;
18242
+ return {
18243
+ ...counts,
18244
+ ...byConversation ? { resolvedByConversation: byConversation } : {},
18245
+ ...reopened ? { reopened } : {}
18246
+ };
18247
+ }
18248
+ //#endregion
18249
+ //#region ../../packages/review-harness/src/domain/ai_review/update_reconciliation.ts
18250
+ function reconciledAgainstOf(basis) {
18251
+ return basis.code ? basis.discussion ? "commits-and-conversation" : "commits" : "conversation";
18252
+ }
18253
+ function applyReconciliation(previous, parsed, context) {
18254
+ const { conversation } = context;
18255
+ const basis = {
18256
+ code: context.codeChanged,
18257
+ discussion: conversationMoved(conversation)
18258
+ };
18259
+ const against = reconciledAgainstOf(basis);
18260
+ const flags = conversationReassessment(previous, conversation);
18261
+ const findings = groundFindings(parsed.findings, conversation.discussion, {
18262
+ codeChanged: context.codeChanged,
18263
+ flags
18264
+ });
18265
+ const suggestions = reconciliationGradeSuggestions(previous, {
18266
+ ...parsed,
18267
+ findings
18268
+ }, context.assess);
18269
+ const issues = [...reconcileIssues(previous.issues, findings, {
18270
+ at: context.at,
18271
+ commitSha: context.commitSha,
18272
+ basis: context.codeChanged ? "code" : "conversation",
18273
+ flags,
18274
+ against
18275
+ }), ...markNewIssues(parsed.newIssues, against)];
18276
+ const questions = reconcileQuestions(previous.questions, parsed.questions, against);
18277
+ const { motivation } = context;
18278
+ const composed = {
18279
+ schemaVersion: 1,
18280
+ summary: parsed.summary || previous.summary,
18281
+ ...motivation ? { motivation } : {},
18282
+ overallRisk: parsed.overallRisk ?? previous.overallRisk,
18283
+ recommendation: parsed.recommendation || previous.recommendation,
18284
+ nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
18285
+ sections: composeSections(previous.sections, parsed.newSections),
18286
+ issues,
18287
+ questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
18288
+ };
18289
+ return {
18290
+ result: conversation.discussion ? attributeIssuesToDiscussion(composed, processDiscussion(conversation.discussion)).result : composed,
18291
+ suggestions,
18292
+ basis
16599
18293
  };
16600
- for (const issue of issues) if (issue.reconciliationStatus) counts[issue.reconciliationStatus] += 1;
16601
- return counts;
16602
18294
  }
16603
18295
  //#endregion
16604
18296
  //#region ../../packages/review-harness/src/workspace/running_jobs.ts
@@ -16795,178 +18487,6 @@ async function askCheckout(workspace) {
16795
18487
  return path;
16796
18488
  }
16797
18489
  //#endregion
16798
- //#region ../../packages/review-harness/src/workspace/discussion_diff.ts
16799
- function diffDiscussions(before, after) {
16800
- const oldThreads = new Map(before.discussions.map((thread) => [thread.id, thread]));
16801
- const newIds = new Set(after.discussions.map((thread) => thread.id));
16802
- const oldComments = new Set(before.discussions.flatMap((thread) => thread.comments.map((comment) => comment.id)));
16803
- let newThreads = 0;
16804
- let resolvedSince = 0;
16805
- let reopenedSince = 0;
16806
- let newComments = 0;
16807
- for (const thread of after.discussions) {
16808
- const previous = oldThreads.get(thread.id);
16809
- if (!previous) newThreads++;
16810
- else if (thread.resolved && !previous.resolved) resolvedSince++;
16811
- else if (!thread.resolved && previous.resolved) reopenedSince++;
16812
- for (const comment of thread.comments) if (!oldComments.has(comment.id)) newComments++;
16813
- }
16814
- return {
16815
- newThreads,
16816
- vanishedThreads: [...oldThreads.keys()].filter((id) => !newIds.has(id)).length,
16817
- resolvedSince,
16818
- reopenedSince,
16819
- newComments
16820
- };
16821
- }
16822
- /** Nothing moved. Worth its own answer: "checked, and nothing changed" is a useful thing to hear. */
16823
- function isUnchanged(changes) {
16824
- return changes.newThreads === 0 && changes.vanishedThreads === 0 && changes.resolvedSince === 0 && changes.reopenedSince === 0 && changes.newComments === 0;
16825
- }
16826
- /**
16827
- * The change, in a sentence, newest-mattering first.
16828
- *
16829
- * Resolutions lead because they are the ones that make a standing claim in this review false —
16830
- * an "already raised … still unresolved" line that is no longer true is the reason this whole
16831
- * re-check exists.
16832
- */
16833
- function describeChanges(changes) {
16834
- if (isUnchanged(changes)) return "Nothing has changed in the conversation since it was imported.";
16835
- const parts = [];
16836
- const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
16837
- if (changes.resolvedSince > 0) parts.push(`${plural(changes.resolvedSince, "thread", "threads")} resolved since`);
16838
- if (changes.reopenedSince > 0) parts.push(`${plural(changes.reopenedSince, "thread", "threads")} reopened`);
16839
- if (changes.newThreads > 0) parts.push(`${plural(changes.newThreads, "new thread", "new threads")}`);
16840
- if (changes.newComments > 0) parts.push(`${plural(changes.newComments, "new comment", "new comments")}`);
16841
- if (changes.vanishedThreads > 0) parts.push(`${plural(changes.vanishedThreads, "thread", "threads")} no longer present`);
16842
- return `${parts.join(", ")}.`;
16843
- }
16844
- //#endregion
16845
- //#region ../../packages/review-harness/src/workspace/pr_refresh.ts
16846
- /**
16847
- * Re-read the pull request conversation, without re-reviewing anything.
16848
- *
16849
- * The problem this solves is narrow and specific. Every "Already raised by @sarah · thread still
16850
- * unresolved" line in a review is a fact that was true at import and is frozen. Nothing re-checks
16851
- * it, so an hour after Sarah resolves that thread the workspace is still telling the reviewer she
16852
- * is waiting on it: a confident, specific, wrong claim about what a named colleague thinks.
16853
- *
16854
- * **This is not a re-review, and the distinction is the whole design.** Re-reading the conversation
16855
- * can only update the state of findings that already exist: who raised the thread, whether it is
16856
- * open, where it links to. It cannot produce a new finding, and it must not try. Deciding that a
16857
- * new comment invalidates a finding, or that a new commit introduced a bug, is judgement, and
16858
- * judgement belongs to the analyser. `attributeIssuesToDiscussion` is what runs here, and it is
16859
- * arithmetic: the model's only surviving contribution is which thread each finding points at.
16860
- *
16861
- * This used to spawn an agent in the reviewer's own checkout and ask it to fetch the conversation.
16862
- * It now asks the forge adapter, which is a couple of subprocess calls and answers in seconds
16863
- * rather than minutes. The daemon still reaches no forge itself and still holds no credential: the
16864
- * adapter drives the CLI the developer has already signed in to, exactly as the review drives the
16865
- * agent they have already signed in to.
16866
- */
16867
- /**
16868
- * Computes; does not persist. The route saves, so a test can run the whole thing without writing
16869
- * into the reviewer's own store.
16870
- */
16871
- async function refreshPrContext(workspace, fetch = refetchConversation) {
16872
- if (!workspace.prContext) return {
16873
- at: Date.now(),
16874
- ok: false,
16875
- message: "This review has no pull request conversation attached, so there is nothing to re-read."
16876
- };
16877
- const pr = workspace.prContext.pullRequest;
16878
- const outcome = await fetch(workspace.repoPath, {
16879
- url: pr.url,
16880
- number: pr.number
16881
- }, agentEnvOf(workspace));
16882
- if (outcome.kind !== "attached") return {
16883
- at: Date.now(),
16884
- ok: false,
16885
- message: `${whyNot(outcome)} Nothing here has changed, and the discussion shown is still the one that came with the review.`
16886
- };
16887
- const fetched = outcome.context;
16888
- const before = workspace.prContext.discussion;
16889
- const changes = diffDiscussions(before, fetched.discussion);
16890
- const restated = reattribute(workspace, fetched.discussion);
16891
- workspace.prContext = {
16892
- pullRequest: {
16893
- ...workspace.prContext.pullRequest,
16894
- ...movable(fetched.pullRequest)
16895
- },
16896
- discussion: fetched.discussion
16897
- };
16898
- workspace.lastPrRefresh = {
16899
- at: Date.now(),
16900
- ok: true,
16901
- message: describeChanges(changes),
16902
- changes,
16903
- ...restated
16904
- };
16905
- return workspace.lastPrRefresh;
16906
- }
16907
- /**
16908
- * Why the re-check did not happen, in the tense a person clicking a button is standing in.
16909
- *
16910
- * The same closed union the review's own disclosure renders, and deliberately not the same
16911
- * sentences. Those are written about a review that has already been made; these are written about
16912
- * a button that has just been pressed and did nothing.
16913
- */
16914
- function whyNot(outcome) {
16915
- switch (outcome.kind) {
16916
- case "no-request": return "The pull request this review is attached to could not be found.";
16917
- case "unsupported-forge": return outcome.host ? `PR Review Buddy does not read pull request conversation on ${outcome.host} yet.` : "This repository has no remote to look the pull request up on.";
16918
- case "cli-missing": return `\`${outcome.cli}\` is not installed here, so the pull request could not be re-read.`;
16919
- case "not-authenticated": return `\`${outcome.cli}\` is not signed in, so the pull request could not be re-read. Run \`${outcome.cli} auth login\` and try again.`;
16920
- case "failed": return `The pull request could not be re-read. ${outcome.detail}`;
16921
- }
16922
- }
16923
- /**
16924
- * Which fields a re-read may update, and why these.
16925
- *
16926
- * The head SHA and title move on their own as the request gains commits, and the two link shapes
16927
- * are how this product gets from a file here to the same file there. Number, url and provider are
16928
- * identity and stay as attached: a re-read does not move a review onto a different pull request.
16929
- */
16930
- function movable(fetched) {
16931
- const updates = {};
16932
- const copy = (field) => {
16933
- const value = fetched[field];
16934
- if (value !== void 0 && value !== "" && value !== null) updates[field] = value;
16935
- };
16936
- copy("headSha");
16937
- copy("title");
16938
- if (typeof fetched.body === "string") updates.body = fetched.body;
16939
- copy("filesUrl");
16940
- copy("fileUrlTemplate");
16941
- copy("author");
16942
- return updates;
16943
- }
16944
- /**
16945
- * Re-derive every attribution from the threads as they now are.
16946
- *
16947
- * The same function the review's own attribution pass used, run again against fresh threads. It
16948
- * rebuilds author, status and link from the real thread and drops any claim whose thread is no
16949
- * longer there, so nothing the model once said can outlive the conversation that justified it.
16950
- */
16951
- function reattribute(workspace, discussion) {
16952
- const result = workspace.session.result;
16953
- if (!result) return {
16954
- attributionsWithdrawn: 0,
16955
- attributionsRestated: 0
16956
- };
16957
- const before = new Map(result.issues.map((issue) => [issue.id, issue.existingDiscussion?.status]));
16958
- const outcome = attributeIssuesToDiscussion(result, processDiscussion(discussion));
16959
- workspace.session = {
16960
- ...workspace.session,
16961
- result: outcome.result
16962
- };
16963
- const restated = outcome.result.issues.filter((issue) => issue.existingDiscussion && before.get(issue.id) !== issue.existingDiscussion.status).length;
16964
- return {
16965
- attributionsWithdrawn: outcome.rejected,
16966
- attributionsRestated: restated
16967
- };
16968
- }
16969
- //#endregion
16970
18490
  //#region ../../packages/review-harness/src/workspace/review_update.ts
16971
18491
  /**
16972
18492
  * Make this review describe the code that is on the ref it follows.
@@ -17156,34 +18676,44 @@ async function bringForward(workspace, runners, reviewedSha, held, step, signal,
17156
18676
  const remote = await remoteHead(workspace, signal, runners);
17157
18677
  signal.throwIfAborted();
17158
18678
  if (!remote.sha) return refuse(reviewedSha, remote.fetchFailed, remote.refusal ?? noRemoteHeadMessage(workspace));
18679
+ adoptLegacyBaseline(workspace);
17159
18680
  if (workspace.prContext) step("conversation");
17160
18681
  const conversationOk = await reReadConversation(workspace, runners.runRefresh);
17161
18682
  signal.throwIfAborted();
18683
+ const { prepared } = await prepareConversation(workspace, {
18684
+ reread: false,
18685
+ ...conversationOk !== null && workspace.lastPrRefresh ? { rereadOutcome: workspace.lastPrRefresh } : {}
18686
+ });
18687
+ const discussionMoved = conversationMoved(prepared);
17162
18688
  if (reviewedSha && !await commitPresent(workspace.repoPath, reviewedSha)) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
17163
- if (!reviewedSha || reviewedSha === remote.sha) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
18689
+ if (!reviewedSha || reviewedSha === remote.sha && !discussionMoved) return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
17164
18690
  step("context");
17165
18691
  let worktreePath;
17166
18692
  try {
17167
18693
  ({path: worktreePath} = await pinCheckout(workspace, remote.sha, held));
17168
18694
  } catch (error) {
17169
18695
  if (error instanceof ReviewBeingDeletedError) throw error;
17170
- return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the new commits could not be prepared, so nothing was reassessed.");
18696
+ return settled(reviewedSha, remote.sha, remote.fetchFailed, conversationOk, "analysis-failed", "A checkout of the latest commit could not be prepared, so nothing was reassessed.");
17171
18697
  }
17172
18698
  signal.throwIfAborted();
17173
- return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock);
18699
+ return reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, held.job.id, signal, clock, prepared, discussionMoved);
17174
18700
  }
17175
- async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock) {
18701
+ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk, worktreePath, step, jobId, signal, clock, prepared, discussionMoved) {
17176
18702
  const newHead = remote.sha;
17177
- let delta;
17178
- try {
18703
+ let delta = null;
18704
+ if (reviewedSha !== newHead) try {
17179
18705
  delta = await buildReviewContext({
17180
18706
  repoPath: worktreePath,
17181
18707
  base: reviewedSha
17182
18708
  });
17183
18709
  } catch (error) {
17184
- if (error instanceof EmptyChangeError) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
17185
- return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
18710
+ if (!(error instanceof EmptyChangeError)) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "baseline-gone", BASELINE_GONE_MESSAGE);
17186
18711
  }
18712
+ if (!delta && !discussionMoved) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "already-current", alreadyCurrentMessage(conversationOk));
18713
+ const basis = {
18714
+ code: delta !== null,
18715
+ discussion: discussionMoved
18716
+ };
17187
18717
  const previous = workspace.session.result;
17188
18718
  if (!previous) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "no-result", "This review has no analysis to bring up to date.");
17189
18719
  step("analysing");
@@ -17202,50 +18732,45 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
17202
18732
  const requestedModel = resolveModel({ review: { session: workspace.session } }) ?? void 0;
17203
18733
  let raw;
17204
18734
  try {
17205
- const prompt = buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath);
18735
+ const prompt = buildUpdatePrompt(workspace, delta, prepared, reviewedSha, newHead, worktreePath);
17206
18736
  raw = runners.run ? await runners.run(prompt, worktreePath, agentEnvOf(workspace), onProgress, onObservedModel, signal) : await runUpdate(workspace.id, prompt, worktreePath, workspace.repoPath, agentEnvOf(workspace), onProgress, requestedModel, onObservedModel, signal);
17207
18737
  } catch (error) {
17208
18738
  return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", explainUpdateFailure(error, agentEnvOf(workspace), requestedModel));
17209
18739
  }
17210
18740
  const parsed = parseReconciliationResult(raw, previous.issues.length, { priorQuestionCount: previous.questions.length });
17211
18741
  if (!parsed) return settled(reviewedSha, newHead, remote.fetchFailed, conversationOk, "analysis-failed", "The reassessment came back in a form this could not read, so nothing was changed.");
17212
- const suggestions = reconciliationGradeSuggestions(previous, parsed, assessmentLookup(workspace.session));
17213
- const issues = [...reconcileIssues(previous.issues, parsed.findings), ...markNewIssues(parsed.newIssues)];
17214
18742
  const motivation = carryMotivation(previous.motivation, workspace.session.metadata?.authorDescription, workspace.prContext?.pullRequest.body);
17215
- const questions = reconcileQuestions(previous.questions, parsed.questions);
17216
- const result = {
17217
- schemaVersion: 1,
17218
- summary: parsed.summary || previous.summary,
17219
- ...motivation ? { motivation } : {},
17220
- overallRisk: parsed.overallRisk ?? previous.overallRisk,
17221
- recommendation: parsed.recommendation || previous.recommendation,
17222
- nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
17223
- sections: composeSections(previous.sections, parsed.newSections),
17224
- issues,
17225
- questions: motivation === previous.motivation ? questions : withIntentQuestion(questions, motivation)
17226
- };
18743
+ const { result, suggestions } = applyReconciliation(previous, parsed, {
18744
+ at: clock.now(),
18745
+ commitSha: newHead,
18746
+ codeChanged: delta !== null,
18747
+ conversation: prepared,
18748
+ assess: assessmentLookup(workspace.session),
18749
+ motivation
18750
+ });
17227
18751
  signal.throwIfAborted();
17228
18752
  cancellers.delete(jobId);
17229
18753
  const gradeChanges = await applyUpdate(workspace, result, previous, newHead, worktreePath, {
17230
18754
  ...requestedModel ? { requestedModel } : {},
17231
18755
  ...observedModel ? { observedModel } : {},
17232
18756
  ...await (runners.agentVersion ?? installedAgentVersion)(agentEnvOf(workspace))
17233
- }, clock, remote.base, suggestions);
18757
+ }, clock, remote.base, suggestions, conversationTakenIntoAccount(prepared, analysedBaselineOf(workspace)));
17234
18758
  const counts = {
17235
- ...summariseCounts(result),
18759
+ ...countUpdateChanges(previous, result),
17236
18760
  ...gradeChanges ? { gradeChanges } : {}
17237
18761
  };
17238
18762
  return {
17239
18763
  at: Date.now(),
17240
18764
  ok: true,
17241
- message: describeUpdate(counts, newHead),
18765
+ message: describeUpdate(counts, newHead, basis),
17242
18766
  stop: null,
17243
18767
  fetchFailed: remote.fetchFailed,
17244
18768
  moved: reviewedSha !== newHead,
17245
18769
  ...conversationOk === null ? {} : { conversationOk },
17246
18770
  fromSha: reviewedSha,
17247
18771
  toSha: newHead,
17248
- counts
18772
+ counts,
18773
+ reassessed: basis
17249
18774
  };
17250
18775
  }
17251
18776
  var BASELINE_GONE_MESSAGE = "The commit this review was made from is no longer in this repository, so there is nothing to measure the new code against. This one needs starting fresh: run `prreviewbuddy review --fresh`.";
@@ -17398,6 +18923,7 @@ var UPDATE_OWNED_FIELDS = [
17398
18923
  "reviewedPaths",
17399
18924
  "previousResult",
17400
18925
  "prContext",
18926
+ "analysedConversation",
17401
18927
  "lastPrRefresh",
17402
18928
  "dismissedFreshnessSha",
17403
18929
  "lastUpdate",
@@ -17460,7 +18986,7 @@ function carryUpdateSuggestions(merged, updated) {
17460
18986
  merged.lastUpdate = {
17461
18987
  ...outcome,
17462
18988
  counts,
17463
- message: describeUpdate(counts, outcome.toSha)
18989
+ message: describeUpdate(counts, outcome.toSha, outcome.reassessed)
17464
18990
  };
17465
18991
  }
17466
18992
  /**
@@ -17471,8 +18997,9 @@ function carryUpdateSuggestions(merged, updated) {
17471
18997
  * to browse the review's past. There is no restore button yet, and that is fine. Insurance that is
17472
18998
  * only written once somebody has already built the claim form is not insurance.
17473
18999
  */
17474
- async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = []) {
19000
+ async function applyUpdate(workspace, result, replaced, newHead, worktreePath, run, clock, freshBase, suggestions = [], conversation) {
17475
19001
  workspace.previousResult = {
19002
+ ...workspace.analysedConversation ? { analysedConversation: workspace.analysedConversation } : {},
17476
19003
  result: replaced,
17477
19004
  commitSha: workspace.session.metadata?.commitSha ?? "",
17478
19005
  replacedAt: Date.now(),
@@ -17505,6 +19032,10 @@ async function applyUpdate(workspace, result, replaced, newHead, worktreePath, r
17505
19032
  }
17506
19033
  });
17507
19034
  delete workspace.dismissedFreshnessSha;
19035
+ workspace.analysedConversation = {
19036
+ sessionId: workspace.session.id,
19037
+ discussion: conversation ?? null
19038
+ };
17508
19039
  return proposeGradeSuggestions(workspace.session, suggestions, {
17509
19040
  kind: "update",
17510
19041
  commitSha: newHead
@@ -17599,11 +19130,13 @@ function legacyBaseCandidates(recorded) {
17599
19130
  /** Null when there is no conversation attached, which is neither success nor failure. */
17600
19131
  async function reReadConversation(workspace, fetch) {
17601
19132
  if (!workspace.prContext) return null;
19133
+ const startedAt = Date.now();
17602
19134
  try {
17603
19135
  const outcome = fetch ? await refreshPrContext(workspace, fetch) : await refreshPrContext(workspace);
17604
19136
  workspace.lastPrRefresh = outcome;
17605
19137
  return outcome.ok;
17606
19138
  } catch {
19139
+ workspace.lastPrRefresh = unforeseenFailure(startedAt);
17607
19140
  return false;
17608
19141
  }
17609
19142
  }
@@ -17625,16 +19158,6 @@ function alreadyCurrentMessage(conversationOk) {
17625
19158
  if (conversationOk === null) return code;
17626
19159
  return conversationOk ? `${code} The pull request conversation was re-read.` : `${code} The pull request conversation could not be re-read, so the notes about who raised what are unchanged.`;
17627
19160
  }
17628
- function summariseCounts(result) {
17629
- const byStatus = countByStatus(result.issues);
17630
- return {
17631
- resolved: byStatus.resolved,
17632
- stillOpen: byStatus["still-open"],
17633
- affected: byStatus.affected,
17634
- newIssues: byStatus.new,
17635
- questionsAnswered: result.questions.filter((question) => question.reconciliationStatus === "answered").length
17636
- };
17637
- }
17638
19161
  /**
17639
19162
  * What happened, in the order the reviewer cares about.
17640
19163
  *
@@ -17642,18 +19165,35 @@ function summariseCounts(result) {
17642
19165
  * they need. Still-present is the number that is usually largest and almost never interesting, so
17643
19166
  * it goes last and only when there is something to sit beside.
17644
19167
  */
17645
- function describeUpdate(counts, sha) {
19168
+ function describeUpdate(counts, sha, basis = {
19169
+ code: true,
19170
+ discussion: false
19171
+ }) {
17646
19172
  const parts = [];
17647
- if (counts.resolved) parts.push(`${counts.resolved} fixed`);
19173
+ const byConversation = counts.resolvedByConversation ?? 0;
19174
+ const fixed = counts.resolved - byConversation;
19175
+ if (fixed) parts.push(`${fixed} fixed`);
19176
+ if (byConversation) parts.push(`${byConversation} resolved by the conversation`);
19177
+ if (counts.reopened) parts.push(`${counts.reopened} reopened`);
17648
19178
  if (counts.newIssues) parts.push(`${counts.newIssues} new`);
17649
19179
  if (counts.affected) parts.push(`${counts.affected} to look at again`);
17650
19180
  if (counts.questionsAnswered) parts.push(`${counts.questionsAnswered} question${counts.questionsAnswered === 1 ? "" : "s"} answered`);
17651
19181
  if (counts.stillOpen && parts.length) parts.push(`${counts.stillOpen} still present`);
17652
19182
  const grades = counts.gradeChanges ? ` ${counts.gradeChanges} grade ${counts.gradeChanges === 1 ? "change" : "changes"} suggested.` : "";
17653
- const at = `Updated to ${sha.slice(0, 7)}`;
17654
- if (!parts.length) return `${at}. The new commits changed nothing about the findings: all ${counts.stillOpen} are still present.${grades}`;
19183
+ if (!basis.code) {
19184
+ const at = "Reassessed against the pull request conversation; no new commits";
19185
+ if (!parts.length) return `${at}. The conversation changed nothing about the findings${stillPresent(counts)}.${grades}`;
19186
+ return `${at}: ${parts.join(", ")}.${grades}`;
19187
+ }
19188
+ const at = `Updated to ${sha.slice(0, 7)}${basis.discussion ? ", with the conversation" : ""}`;
19189
+ if (!parts.length) return `${at}. The new commits${basis.discussion ? " and comments" : ""} changed nothing about the findings${stillPresent(counts)}.${grades}`;
17655
19190
  return `${at}: ${parts.join(", ")}.${grades}`;
17656
19191
  }
19192
+ /** The open findings an update left as they were, after a sentence saying it changed nothing. */
19193
+ function stillPresent(counts) {
19194
+ if (!counts.stillOpen) return "";
19195
+ return `: ${counts.stillOpen} ${counts.stillOpen === 1 ? "is" : "are"} still present`;
19196
+ }
17657
19197
  /**
17658
19198
  * The reconciliation, asked for as a review of new code rather than as a tidy-up of an old list.
17659
19199
  *
@@ -17663,19 +19203,26 @@ function describeUpdate(counts, sha) {
17663
19203
  * Everything after it is the shared builder, so the two clients cannot drift into asking for
17664
19204
  * different shapes.
17665
19205
  */
17666
- function buildUpdatePrompt(workspace, delta, reviewedSha, newHead, worktreePath) {
17667
- const files = delta.changeSet.data.files;
19206
+ function buildUpdatePrompt(workspace, delta, conversation, reviewedSha, newHead, worktreePath) {
17668
19207
  const previous = workspace.session.result;
17669
- const commits = (delta.changeSet.authors ?? []).reduce((total, author) => total + author.commits, 0);
17670
- return `An earlier review of this branch is being brought up to date with the commits pushed since it was written.
19208
+ const discussionChanged = conversationMoved(conversation);
19209
+ const conversationBlock = updateConversationBlock(conversation);
19210
+ const opening = delta ? `An earlier review of this branch is being brought up to date with the commits pushed since it was written${discussionChanged ? ", and with what has changed in its pull request conversation since" : ""}.` : "An earlier review of this branch is being brought up to date with what has changed in its pull request conversation since it was written. No commits have been pushed since.";
19211
+ const code = delta ? `The change below is what happened between ${reviewedSha} and ${newHead}. It is not the whole branch: the earlier review already covered everything up to ${reviewedSha}.
19212
+
19213
+ ${renderChangeInventory(delta.changeSet.data.files, inlinePathsOf(delta))}
19214
+
19215
+ ${delta.diff}` : `The code is unchanged since the earlier review: ${newHead} is the commit it read.`;
19216
+ const commits = delta ? (delta.changeSet.authors ?? []).reduce((total, author) => total + author.commits, 0) : 0;
19217
+ return `${opening}
17671
19218
 
17672
19219
  The working tree at ${worktreePath} is checked out at ${newHead}, which is the code as it now stands. You may read any file in it, and you should: the point of doing this here rather than from a diff alone is that you can open the code and see whether a fix actually holds. Read only. Change nothing, commit nothing, and run no command that writes.
17673
19220
 
17674
- The change below is what happened between ${reviewedSha} and ${newHead}. It is not the whole branch: the earlier review already covered everything up to ${reviewedSha}.
19221
+ ${code}
17675
19222
 
17676
- ${renderChangeInventory(files, new Set(files.map((file) => file.name).filter((name) => !delta.inventoryOnlyFiles.includes(name))))}
19223
+ ---
17677
19224
 
17678
- ${delta.diff}
19225
+ ${conversationBlock}
17679
19226
 
17680
19227
  ---
17681
19228
 
@@ -17685,9 +19232,17 @@ ${buildReconciliationInstructions({
17685
19232
  fromSha: reviewedSha,
17686
19233
  toSha: newHead,
17687
19234
  totalCommits: commits,
17688
- changedFiles: files.length,
17689
- possiblyTruncated: false
17690
- }, { includeQuestions: true })}`;
19235
+ changedFiles: delta ? delta.changeSet.data.files.length : 0,
19236
+ possiblyTruncated: false,
19237
+ reassess: conversationReassessment(previous, conversation)
19238
+ }, {
19239
+ includeQuestions: true,
19240
+ ...discussionChanged ? { discussion: { codeChanged: delta !== null } } : {}
19241
+ })}`;
19242
+ }
19243
+ /** The files whose patch travelled inline, as against those only named in the inventory. */
19244
+ function inlinePathsOf(delta) {
19245
+ return new Set(delta.changeSet.data.files.map((file) => file.name).filter((name) => !delta.inventoryOnlyFiles.includes(name)));
17691
19246
  }
17692
19247
  /**
17693
19248
  * The production run: a `read-code` run standing in the pinned worktree.
@@ -17906,4 +19461,4 @@ function lineageIds(id) {
17906
19461
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
17907
19462
  }
17908
19463
  //#endregion
17909
- export { MAX_WORKSPACE_PORT as $, decideKissRecommendation as $n, findingIn as $r, markReadForReview as $t, relativeTime as A, AgentCancelledError as Ai, describeMovement as An, sectionOf as Ar, partFiles as At, readIndexToken as B, allJobs as Bn, questionsOutstanding as Br, isArchitectureId as Bt, track as C, writeAgentPreference as Ci, MANAGED_ROOT as Cn, activityLabel as Cr, mapRuntimes as Ct, endRun as D, agentById as Di, readMarker as Dn, lifecyclePill as Dr, ARCHITECTURE_DIR as Dt, completeRun as E, AGENT_IDS as Ei, isWorktreeHeld as En, lifecycleItemLabel as Er, describeRuntimes as Et, runBatch as F, followedBranch as Fn, readQueue as Fr, trackedFiles as Ft, reviewsUrl as G, saveJob as Gn, explanationOf as Gr, categoryOf as Gt, clearServerRecordOwnedBy as H, fail as Hn, processDiscussion as Hr, announceJob as Ht, activateQueued as I, reviewedShaOf as In, doneVerb as Ir, ownersOf as It, stopChild as J, phasesFor as Jn, ANALYSIS_ONLY as Jr, createPrUpdate as Jt, serverEntryExists as K, PHASES as Kn, isExplanationAction as Kr, createArchitectureNotification as Kt, compactQueue as L, fetchHead as Ln, isIssueOutstanding as Lr, QUOTE_LIMITS as Lt, extractPullRequestLinks as M, findExistingReview as Mn, queueAddMany as Mr, repositoryKey as Mt, parsePullRequestLink as N, checkCodeFreshness as Nn, queueRemove as Nr, storeArchitecture as Nt, reportRun as O, agentFor as Oi, removeWorktree as On, offersLifecycle as Or, checkArchitecture as Ot, unsupportedReason as P, checkReview as Pn, queueStamp as Pr, storedScopes as Pt, DEFAULT_WORKSPACE_PORT as Q, isClaimed as Qn, findingAssessment as Qr, markRead as Qt, queueForReview as R, forgeResolver as Rn, isQuestionOutstanding as Rr, REPOSITORY_SCOPE as Rt, isDriving as S, readAgentPreference as Si, ReviewBeingDeletedError as Sn, activityAttributes as Sr, waitingHistory as St, beginRun as T, STORE_ROOT as Ti, holdWorktree as Tn, lifecycleChip as Tr, RUNTIME_WORDS as Tt, ensureServer as U, isTerminal as Un, recommendationsOf as Ur, branchOf as Ut, bootstrapUrl as V, endedAt as Vn, reviewerDispositions as Vr, writeJsonAtomically as Vt, readServerRecord as W, loadJob as Wn, explanationActionOf as Wr, subjectOf as Wt, workspaceUrl as X, purposeOf as Xn, assessmentLookup as Xr, listNotifications as Xt, stopServer as Y, progressSteps as Yn, analysisRefOf as Yr, eventName as Yt, writeServerRecord as Z, clearClaim as Zn, currentGradeIn as Zr, markAllRead as Zt, liveUpdateFor as _, resolveBase as _i, CODE_BLOCK_CLASS as _n, summarise as _r, telemetryUploadConsent as _t, wasBlocked as a, wasReassessed as ai, retireNotification as an, kissDecisionsOf as ar, failureOf as at, recordKissRun as b, resolveModel as bi, renderInlineText as bn, workspaceStamps as br, PACKAGE_NAME as bt, RefreshUnavailableError as c, extractJsonText as ci, unreadCount as cn, loadWorkspace as cr, reanalyseReview as ct, mergeUpdateInto as d, WITHHELD_REASON as di, EXPLAIN_SIMPLY_PROMPT as dn, positionInLineage as dr, append as dt, gradeScale as ei, moveIds as en, followedRefName as er, MIN_WORKSPACE_PORT as et, updateReview as f, kissOutcome as fi, UNESTABLISHED_MOTIVATION as fn, presentedKissRunOf as fr, readEvents as ft, UpdateAlreadyRunningError as g, isCommitReview as gi, renderMarkdown as gn, reviewedRepositories as gr, recordTelemetryUploadConsent as gt, askCheckout as h, DetachedHeadError as hi, GITHUB_MARKDOWN as hn, reviewedCommit as hr, deliver as ht, liveJobsFor as i, sourcePhrase as ii, removeNotification as in, kissDecisionRefusal as ir, writePortPreference as it, batchIdentity as j, AgentUnavailableError as ji, describeSubject as jn, queueAdd as jr, readArchitecture as jt, stopRun as k, detectAgents as ki, requestJobStop as kn, parseLifecycle as kr, loadArchitecture as kt, cancelUpdate as l, objectWithAnyOf as li, withUsageRecorded as ln, matchingWorkspaceIds as lr, runJob as lt, isUnchanged as m, originRemote as mi, hasAuthorContent as mn, recentWorkspaces as mr, stamp as mt, deleteReview as n, promptGrade as ni, readNotification as nn, indexKeptIds as nr, resolveWorkspacePort as nt, discardJob as o, followUpFindingId as oi, toneOf as on, latestKissRun as or, prepareReview as ot, refreshPrContext as p, resolveTarget as pi, authorPreview as pn, previousKissResult as pr, record as pt, spawnServer as q, isLiveJob as qn, withExplanation as qr, createPrState as qt, lineageIds as r, reassessIn as ri, readStateOf as rn, indexReviewGroups as rr, statedWorkspacePort as rt, FETCH_TIMEOUT_MS as s, asString$1 as si, unreadAround as sn, lineagePosition as sr, settleReview as st, deleteLineage as t, isGradeFor as ti, pruneNotifications as tn, groupByLineage as tr, parseWorkspacePort as tt, cancellableHere as u, describeAuthorship as ui, existingPaths as un, mutateWorkspace as ur, startJob as ut, runningJobs as v, git as vi, CODE_COPY_CLASS as vn, touchWorkspace as vr, telemetryUploadEnabled as vt, RUN_KINDS as w, CONFIG_PATH as wi, createWorktree as wn, isLifecycle as wr, recordRuntimeBestEffort as wt, startKissJob as x, clearAgentPreference as xi, modelHelpLines as xn, LIFECYCLES as xr, feedbackUrl as xt, agentEnvOf as y, resolveAgent as yi, escapeHtml as yn, workspaceRevision as yr, BUILD_VERSION as yt, settleQueue as z, fillFileUrlTemplate as zn, issuesOutstanding as zr, ancestorsOf as zt };
19464
+ export { resolveWorkspacePort as $, saveJob as $n, explanationActionOf as $r, pruneNotifications as $t, parsePullRequestLink as A, git as Ai, ReviewBeingDeletedError as An, activityAttributes as Ar, repositoryKey as At, ensureServer as B, agentById as Bi, findExistingReview as Bn, queueAddMany as Br, announceJob as Bt, completeRun as C, originRemote as Ci, GITHUB_MARKDOWN as Cn, reviewedCommit as Cr, RUNTIME_WORDS as Ct, relativeTime as D, AmbiguousRefError as Di, escapeHtml as Dn, workspaceRevision as Dr, loadArchitecture as Dt, stopRun as E, resolveBase as Ei, CODE_COPY_CLASS as En, touchWorkspace as Er, checkArchitecture as Et, queueForReview as F, readAgentPreference as Fi, readMarker as Fn, lifecyclePill as Fr, QUOTE_LIMITS as Ft, stopChild as G, fetchHead as Gn, isIssueOutstanding as Gr, createPrState as Gt, reviewsUrl as H, detectAgents as Hi, checkReview as Hn, queueStamp as Hr, subjectOf as Ht, settleQueue as I, writeAgentPreference as Ii, removeWorktree as In, offersLifecycle as Ir, REPOSITORY_SCOPE as It, writeServerRecord as J, allJobs as Jn, questionsOutstanding as Jr, listNotifications as Jt, stopServer as K, forgeResolver as Kn, isQuestionOutstanding as Kr, createPrUpdate as Kt, readIndexToken as L, CONFIG_PATH as Li, requestJobStop as Ln, parseLifecycle as Lr, ancestorsOf as Lt, runBatch as M, resolveAgent as Mi, createWorktree as Mn, isLifecycle as Mr, storedScopes as Mt, activateQueued as N, resolveModel as Ni, holdWorktree as Nn, lifecycleChip as Nr, trackedFiles as Nt, batchIdentity as O, shortNameOf as Oi, renderInlineText as On, workspaceStamps as Or, partFiles as Ot, compactQueue as P, clearAgentPreference as Pi, isWorktreeHeld as Pn, lifecycleItemLabel as Pr, ownersOf as Pt, parseWorkspacePort as Q, loadJob as Qn, recommendationsOf as Qr, moveIds as Qt, bootstrapUrl as R, STORE_ROOT as Ri, describeMovement as Rn, sectionOf as Rr, isArchitectureId as Rt, beginRun as S, resolveTarget as Si, hasAuthorContent as Sn, recentWorkspaces as Sr, recordRuntimeBestEffort as St, reportRun as T, isCommitReview as Ti, CODE_BLOCK_CLASS as Tn, summarise as Tr, ARCHITECTURE_DIR as Tt, serverEntryExists as U, AgentCancelledError as Ui, followedBranch as Un, readQueue as Ur, categoryOf as Ut, readServerRecord as V, agentFor as Vi, checkCodeFreshness as Vn, queueRemove as Vr, branchOf as Vt, spawnServer as W, AgentUnavailableError as Wi, reviewedShaOf as Wn, doneVerb as Wr, createArchitectureNotification as Wt, MAX_WORKSPACE_PORT as X, fail as Xn, reviewerDispositions as Xr, markRead as Xt, DEFAULT_WORKSPACE_PORT as Y, endedAt as Yn, resolvedByConversation as Yr, markAllRead as Yt, MIN_WORKSPACE_PORT as Z, isTerminal as Zn, processDiscussion as Zr, markReadForReview as Zt, recordKissRun as _, extractJsonText as _i, agentEnvOf as _n, matchingWorkspaceIds as _r, BUILD_VERSION as _t, wasBlocked as a, assessmentLookup as ai, unreadAround as an, clearClaim as ar, settleReview as at, track as b, WITHHELD_REASON as bi, UNESTABLISHED_MOTIVATION as bn, presentedKissRunOf as br, waitingHistory as bt, RefreshUnavailableError as c, findingIn as ci, existingPaths as cn, followedRefName as cr, startJob as ct, mergeUpdateInto as d, promptGrade as di, prepareConversationWithin as dn, indexReviewGroups as dr, record as dt, explanationOf as ei, readNotification as en, PHASES as er, statedWorkspacePort as et, updateReview as f, reassessIn as fi, saveRefresh as fn, kissDecisionRefusal as fr, stamp as ft, runningJobs as g, asString$1 as gi, isUnchanged as gn, loadWorkspace as gr, telemetryUploadEnabled as gt, liveUpdateFor as h, followUpFindingId as hi, refreshPrContext as hn, lineagePosition as hr, telemetryUploadConsent as ht, liveJobsFor as i, analysisRefOf as ii, toneOf as in, purposeOf as ir, prepareReview as it, unsupportedReason as j, refExists as ji, MANAGED_ROOT as jn, activityLabel as jr, storeArchitecture as jt, extractPullRequestLinks as k, shorthandOf as ki, modelHelpLines as kn, LIFECYCLES as kr, readArchitecture as kt, cancelUpdate as l, gradeScale as li, ASK_REREAD_CEILING_MS as ln, groupByLineage as lr, append as lt, UpdateAlreadyRunningError as m, wasReassessed as mi, unforeseenFailure as mn, latestKissRun as mr, recordTelemetryUploadConsent as mt, deleteReview as n, withExplanation as ni, removeNotification as nn, phasesFor as nr, failureOf as nt, discardJob as o, currentGradeIn as oi, unreadCount as on, isClaimed as or, reanalyseReview as ot, askCheckout as p, sourcePhrase as pi, storedConversation as pn, kissDecisionsOf as pr, deliver as pt, workspaceUrl as q, fillFileUrlTemplate as qn, issuesOutstanding as qr, eventName as qt, lineageIds as r, ANALYSIS_ONLY as ri, retireNotification as rn, progressSteps as rr, observeReview as rt, FETCH_TIMEOUT_MS as s, findingAssessment as si, withUsageRecorded as sn, decideKissRecommendation as sr, runJob as st, deleteLineage as t, isExplanationAction as ti, readStateOf as tn, isLiveJob as tr, writePortPreference as tt, cancellableHere as u, isGradeFor as ui, adoptLegacyBaseline as un, indexKeptIds as ur, readEvents as ut, startKissJob as v, objectWithAnyOf as vi, EXPLAIN_SIMPLY_PROMPT as vn, mutateWorkspace as vr, PACKAGE_NAME as vt, endRun as w, DetachedHeadError as wi, renderMarkdown as wn, reviewedRepositories as wr, describeRuntimes as wt, RUN_KINDS as x, kissOutcome as xi, authorPreview as xn, previousKissResult as xr, mapRuntimes as xt, isDriving as y, describeAuthorship as yi, chatConversationSection as yn, positionInLineage as yr, feedbackUrl as yt, clearServerRecordOwnedBy as z, AGENT_IDS as zi, describeSubject as zn, queueAdd as zr, writeJsonAtomically as zt };