prreviewbuddy 0.52.2 → 0.55.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.
@@ -1643,7 +1643,8 @@ function completeReviewSession(session, result, context, options = {}) {
1643
1643
  ...session.metadata,
1644
1644
  ...context ? reviewFacts(result, context) : {
1645
1645
  fileCoverage: void 0,
1646
- discussionStats: void 0
1646
+ discussionStats: void 0,
1647
+ authorDescription: void 0
1647
1648
  },
1648
1649
  commitSha: options.commitSha ?? session.metadata.commitSha,
1649
1650
  durationMs: Math.max(0, now - session.metadata.generatedAt)
@@ -1664,7 +1665,8 @@ function reviewFacts(result, context) {
1664
1665
  reviewedFiles: sectionedFiles(result, context),
1665
1666
  totalChangedFiles: context.fileCoverage.totalChangedFiles
1666
1667
  },
1667
- ...context.prContext ? { discussionStats: countDiscussion(context) } : {}
1668
+ ...context.prContext ? { discussionStats: countDiscussion(context) } : {},
1669
+ authorDescription: context.prContext?.pullRequest.body
1668
1670
  };
1669
1671
  }
1670
1672
  /**
@@ -2237,6 +2239,7 @@ async function buildChangeSet(options) {
2237
2239
  number: 0,
2238
2240
  title: changeTitle(subjects, headRef),
2239
2241
  body: subjects.length > 1 ? subjects.map((s) => `- ${s}`).join("\n") : null,
2242
+ bodySource: "commit-subjects",
2240
2243
  base: {
2241
2244
  ref: base.display,
2242
2245
  sha: mergeBase
@@ -3245,6 +3248,30 @@ function failSession(session, error, kind, rejectedAnswer) {
3245
3248
  };
3246
3249
  }
3247
3250
  //#endregion
3251
+ //#region ../../packages/review-harness/src/domain/kiss/identity.ts
3252
+ function distinctIds(items) {
3253
+ const taken = new Set(items.map((item) => item.id));
3254
+ const kept = /* @__PURE__ */ new Set();
3255
+ return items.map((item) => {
3256
+ if (!kept.has(item.id)) {
3257
+ kept.add(item.id);
3258
+ return item;
3259
+ }
3260
+ let n = 2;
3261
+ while (taken.has(`${item.id}~${n}`)) n += 1;
3262
+ const id = `${item.id}~${n}`;
3263
+ taken.add(id);
3264
+ return {
3265
+ ...item,
3266
+ id
3267
+ };
3268
+ });
3269
+ }
3270
+ /** A run's recommendations with the ids decisions are stored against. Read through this, always. */
3271
+ function recommendationsOf(result) {
3272
+ return distinctIds(result?.recommendations ?? []);
3273
+ }
3274
+ //#endregion
3248
3275
  //#region ../../packages/review-harness/src/domain/content/github/diff_parser.ts
3249
3276
  var CONTEXT_LINES = 2;
3250
3277
  /**
@@ -4326,6 +4353,19 @@ var WORKSPACES_DIR = join(STORE_ROOT, "workspaces");
4326
4353
  * in the minutes it took. `handleState` is the only writer and writes nothing else.
4327
4354
  */
4328
4355
  var REVIEWER_SESSION_FIELDS = ["answeredQuestionIds", "dismissedIssues"];
4356
+ /**
4357
+ * A session a long run is about to write, with the reviewer's own fields taken from disk instead.
4358
+ *
4359
+ * The run built `session` from a copy it loaded before awaiting something, and the reviewer may have
4360
+ * answered or dismissed something in that window. Absent on disk means absent here too.
4361
+ */
4362
+ function keepReviewerFields(fresh, session) {
4363
+ const kept = { ...session };
4364
+ const current = fresh.session;
4365
+ for (const field of REVIEWER_SESSION_FIELDS) if (field in fresh.session) kept[field] = current[field];
4366
+ else delete kept[field];
4367
+ return kept;
4368
+ }
4329
4369
  function workspacePath(id) {
4330
4370
  return join(WORKSPACES_DIR, `${id}.json`);
4331
4371
  }
@@ -4945,6 +4985,17 @@ function previousKissResult(workspace) {
4945
4985
  for (let index = before.length - 1; index >= 0; index -= 1) if (before[index].result) return before[index];
4946
4986
  return null;
4947
4987
  }
4988
+ /**
4989
+ * The run whose answer the page presents: the latest when it produced one, otherwise the last that
4990
+ * did. The store's form of `presentedKissRun` in the view, for callers holding a workspace rather
4991
+ * than a view model. Freshness must be measured from this run's commit: measured from a failed
4992
+ * rerun's instead, an answer about older commits would read as current, and could be handed off.
4993
+ */
4994
+ function presentedKissRunOf(workspace) {
4995
+ const latest = latestKissRun(workspace);
4996
+ if (latest?.result) return latest;
4997
+ return previousKissResult(workspace);
4998
+ }
4948
4999
  /** Appends a run and points the page at it. The caller saves: this is one half of a larger write. */
4949
5000
  function startKissRun(workspace, start) {
4950
5001
  const run = {
@@ -5004,6 +5055,38 @@ function failKissRun(workspace, runId, error, kind, rejectedAnswer) {
5004
5055
  return run;
5005
5056
  }
5006
5057
  /**
5058
+ * Whether this review holds the recommendation a decision names. Asked before writing, so a refused
5059
+ * decision leaves the file untouched, and again inside the write, where the answer is final.
5060
+ */
5061
+ function kissDecisionRefusal(workspace, input) {
5062
+ const run = (workspace.kissRuns ?? []).find((candidate) => candidate.id === input.runId);
5063
+ if (!run?.result) return "unknown-run";
5064
+ return recommendationsOf(run.result).some((recommendation) => recommendation.id === input.recommendationId) ? null : "unknown-recommendation";
5065
+ }
5066
+ /**
5067
+ * Record one decision, replacing whatever was decided about that recommendation before.
5068
+ *
5069
+ * One entry per request rather than the page's whole list, so two cards decided in quick
5070
+ * succession cannot overwrite each other: each request changes only the entry it names.
5071
+ */
5072
+ function decideKissRecommendation(workspace, input, now = Date.now()) {
5073
+ const refusal = kissDecisionRefusal(workspace, input);
5074
+ if (refusal) return refusal;
5075
+ const others = (workspace.kissDecisions ?? []).filter((entry) => !(entry.runId === input.runId && entry.recommendationId === input.recommendationId));
5076
+ workspace.kissDecisions = input.decision === "undecided" ? others : [...others, {
5077
+ runId: input.runId,
5078
+ recommendationId: input.recommendationId,
5079
+ decision: input.decision,
5080
+ decidedAt: now
5081
+ }];
5082
+ return null;
5083
+ }
5084
+ /** The decisions made against one run. Empty for a run nobody has decided anything about. */
5085
+ function kissDecisionsOf(workspace, runId) {
5086
+ if (!runId) return [];
5087
+ return (workspace.kissDecisions ?? []).filter((entry) => entry.runId === runId);
5088
+ }
5089
+ /**
5007
5090
  * Record that a workspace was opened.
5008
5091
  *
5009
5092
  * Best-effort and deliberately silent: a review that cannot record having been looked at is still
@@ -6216,6 +6299,7 @@ function sanitisePullRequest(value) {
6216
6299
  };
6217
6300
  const title = typeof value.title === "string" ? value.title.trim() : "";
6218
6301
  if (title) pr.title = title.slice(0, 200);
6302
+ if (typeof value.body === "string" && value.body.trim()) pr.body = value.body;
6219
6303
  const headSha = label(value.headSha);
6220
6304
  if (headSha) pr.headSha = headSha;
6221
6305
  const headRef = label(value.headRef);
@@ -6771,7 +6855,7 @@ function slug(repo) {
6771
6855
  * `baseRefOid` is a diff taken against the wrong *commit*, which is worse because the branch name
6772
6856
  * is right and everything reads as normal. A list nobody asserts is a list somebody tidies.
6773
6857
  */
6774
- var PR_FIELDS = "number,url,title,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
6858
+ var PR_FIELDS = "number,url,title,body,headRefName,headRefOid,baseRefName,baseRefOid,changedFiles,author";
6775
6859
  async function viewRequest(repo, reference, cwd, env) {
6776
6860
  return await ghJson([
6777
6861
  "pr",
@@ -6783,7 +6867,7 @@ async function viewRequest(repo, reference, cwd, env) {
6783
6867
  PR_FIELDS
6784
6868
  ], cwd, env);
6785
6869
  }
6786
- function text(value) {
6870
+ function text$1(value) {
6787
6871
  return typeof value === "string" ? value.trim() : "";
6788
6872
  }
6789
6873
  /**
@@ -6804,30 +6888,31 @@ function fileUrlTemplate(prUrl) {
6804
6888
  * email address all live in `pr_context.ts` and in nothing else.
6805
6889
  */
6806
6890
  function identity(raw) {
6807
- const url = text(raw.url);
6891
+ const url = text$1(raw.url);
6808
6892
  const author = raw.author ?? null;
6809
6893
  return {
6810
6894
  provider: GITHUB_FORGE_ID,
6811
6895
  number: typeof raw.number === "number" ? raw.number : 0,
6812
6896
  url,
6813
- title: text(raw.title),
6814
- headSha: text(raw.headRefOid),
6815
- headRef: text(raw.headRefName),
6816
- baseRef: text(raw.baseRefName),
6897
+ title: text$1(raw.title),
6898
+ body: raw.body,
6899
+ headSha: text$1(raw.headRefOid),
6900
+ headRef: text$1(raw.headRefName),
6901
+ baseRef: text$1(raw.baseRefName),
6817
6902
  filesUrl: url ? `${url}/files` : "",
6818
6903
  fileUrlTemplate: fileUrlTemplate(url),
6819
6904
  author: author ? {
6820
- name: text(author.name),
6821
- handle: text(author.login)
6905
+ name: text$1(author.name),
6906
+ handle: text$1(author.login)
6822
6907
  } : null
6823
6908
  };
6824
6909
  }
6825
6910
  /**
6826
6911
  * The watcher's query: where the branch is, and whether the request is still open.
6827
6912
  *
6828
- * Deliberately not `PR_FIELDS`. That list exists because a review needs all nine of them, and its
6913
+ * Deliberately not `PR_FIELDS`. That list exists because a review needs all ten of them, and its
6829
6914
  * docblock warns that trimming it fails silently rather than loudly. Sharing it here would put a
6830
- * nine-field query on a five-minute timer across the whole working set, and would put the watcher
6915
+ * ten-field query on a five-minute timer across the whole working set, and would put the watcher
6831
6916
  * in a position to "optimise" a list that must never shrink.
6832
6917
  *
6833
6918
  * `state` rides on the same call rather than being a second one, which keeps the watcher's rate
@@ -6850,7 +6935,7 @@ var STATES = /* @__PURE__ */ new Map([
6850
6935
  * about a request GitHub declined to describe, once per cycle, indefinitely.
6851
6936
  */
6852
6937
  function readHeadSha(raw) {
6853
- const sha = text(raw?.headRefOid);
6938
+ const sha = text$1(raw?.headRefOid);
6854
6939
  if (!sha) return {
6855
6940
  kind: "failed",
6856
6941
  detail: "GitHub reported no head commit for this request."
@@ -6858,7 +6943,7 @@ function readHeadSha(raw) {
6858
6943
  return {
6859
6944
  kind: "head",
6860
6945
  sha,
6861
- state: STATES.get(text(raw?.state)) ?? null
6946
+ state: STATES.get(text$1(raw?.state)) ?? null
6862
6947
  };
6863
6948
  }
6864
6949
  async function headOf(repo, number, env, cwd) {
@@ -6893,8 +6978,8 @@ async function resolve$1(repo, reference, env, cwd) {
6893
6978
  } catch (error) {
6894
6979
  throw new TargetError(`${reference} could not be looked up on GitHub: ${error.message}`);
6895
6980
  }
6896
- const headRef = text(raw.headRefName);
6897
- const headSha = text(raw.headRefOid);
6981
+ const headRef = text$1(raw.headRefName);
6982
+ const headSha = text$1(raw.headRefOid);
6898
6983
  if (!headRef || !headSha) throw new TargetError(`${reference} was found on GitHub but reported no branch and commit.`);
6899
6984
  const resolved = {
6900
6985
  reference,
@@ -6902,12 +6987,12 @@ async function resolve$1(repo, reference, env, cwd) {
6902
6987
  headSha,
6903
6988
  provider: GITHUB_FORGE_ID
6904
6989
  };
6905
- const baseRef = text(raw.baseRefName);
6990
+ const baseRef = text$1(raw.baseRefName);
6906
6991
  if (baseRef) resolved.baseRef = baseRef;
6907
- const baseSha = text(raw.baseRefOid);
6992
+ const baseSha = text$1(raw.baseRefOid);
6908
6993
  if (baseSha) resolved.baseSha = baseSha;
6909
6994
  if (typeof raw.changedFiles === "number") resolved.changedFiles = raw.changedFiles;
6910
- const url = text(raw.url);
6995
+ const url = text$1(raw.url);
6911
6996
  if (url) resolved.url = url;
6912
6997
  if (typeof raw.number === "number") resolved.number = raw.number;
6913
6998
  return resolved;
@@ -9001,7 +9086,7 @@ Follow-up Prompts:
9001
9086
  ${buildFollowUpPromptsSpec()}
9002
9087
 
9003
9088
  PR Title: ${raw.pullRequest.title}
9004
- ${raw.pullRequest.body ? `\nPR Description:\n${raw.pullRequest.body}\n` : ""}
9089
+ ${raw.pullRequest.body ? `\n${raw.pullRequest.bodySource === "commit-subjects" ? "Commit subjects (there is no PR Description)" : "PR Description"}:\n${raw.pullRequest.body}\n` : ""}
9005
9090
  ${diff}`;
9006
9091
  const overheadChars = assemble({
9007
9092
  diff: "",
@@ -9310,10 +9395,304 @@ function normalizeSections(value) {
9310
9395
  return sections;
9311
9396
  }
9312
9397
  //#endregion
9398
+ //#region ../../packages/review-harness/src/domain/ai_review/author_description.ts
9399
+ /**
9400
+ * Reading a pull request's description for whether it says anything, without ever changing it.
9401
+ *
9402
+ * Nothing here returns a copy to store or show. The description is shown verbatim, so a cleaned
9403
+ * copy is only ever a means of asking a question of it: whether it says anything, and whether a
9404
+ * quote is something the author said.
9405
+ */
9406
+ /**
9407
+ * Lines that are a pull request template's scaffolding when they stand alone.
9408
+ *
9409
+ * Matched against a whole line only, after its heading marker, bold and trailing colon are gone.
9410
+ * A heading that says something of its own, `## Fix startup crashes`, is not a label and survives.
9411
+ */
9412
+ var TEMPLATE_LABELS = /* @__PURE__ */ new Set([
9413
+ "description",
9414
+ "summary",
9415
+ "changes",
9416
+ "what",
9417
+ "why",
9418
+ "how",
9419
+ "context",
9420
+ "motivation",
9421
+ "motivation and context",
9422
+ "what changed",
9423
+ "what does this pr do",
9424
+ "why is this needed",
9425
+ "type of change",
9426
+ "testing",
9427
+ "tests",
9428
+ "test plan",
9429
+ "how to test",
9430
+ "how has this been tested",
9431
+ "screenshots",
9432
+ "notes",
9433
+ "checklist",
9434
+ "related issues",
9435
+ "related issue",
9436
+ "links"
9437
+ ]);
9438
+ var HTML_COMMENT = /<!--[\s\S]*?-->/g;
9439
+ var HEADING_MARKER = /^#{1,6}\s*/;
9440
+ var UNCHECKED_TASK = /^[-*+]\s+\[ \]/;
9441
+ var EMPTY_LIST_ITEM = /^(?:[-*+]|\d+[.)])\s*$/;
9442
+ function asLabel(line) {
9443
+ return line.replace(HEADING_MARKER, "").replace(/^\*\*(.*)\*\*$/, "$1").replace(/^__(.*)__$/, "$1").replace(/:\s*$/, "").trim().toLowerCase();
9444
+ }
9445
+ /** One line, reduced to whatever in it is the author's own words. Empty when none are. */
9446
+ function substance(line) {
9447
+ const trimmed = line.trim();
9448
+ if (!trimmed || UNCHECKED_TASK.test(trimmed) || EMPTY_LIST_ITEM.test(trimmed)) return "";
9449
+ if (TEMPLATE_LABELS.has(asLabel(trimmed))) return "";
9450
+ return trimmed.replace(HEADING_MARKER, "");
9451
+ }
9452
+ /**
9453
+ * The parts of a description the author wrote, one line each, with the template's scaffolding gone.
9454
+ *
9455
+ * Only HTML comments, unticked checklist items, empty list items and bare template labels are
9456
+ * discounted. A ticked item is the author asserting something, so it stays.
9457
+ */
9458
+ function authorText(description) {
9459
+ if (!description) return "";
9460
+ return description.replace(HTML_COMMENT, "").split("\n").map(substance).filter((line) => line !== "").join("\n");
9461
+ }
9462
+ /**
9463
+ * Whether a description has anything in it beyond an unfilled template.
9464
+ *
9465
+ * Conservative by design: any line that survives counts, so a sparse description is shown rather
9466
+ * than hidden.
9467
+ */
9468
+ function hasAuthorContent(description) {
9469
+ return authorText(description) !== "";
9470
+ }
9471
+ function isLongDescription(description) {
9472
+ const trimmed = description.trim();
9473
+ return trimmed.split("\n").length > 10 || trimmed.length > 700;
9474
+ }
9475
+ //#endregion
9476
+ //#region ../../packages/review-harness/src/domain/ai_review/motivation.ts
9477
+ var MOTIVATION_STATUSES = [
9478
+ "stated",
9479
+ "inferred",
9480
+ "unclear"
9481
+ ];
9482
+ var MOTIVATION_EVIDENCE_KINDS = [
9483
+ "test",
9484
+ "behaviour",
9485
+ "issue",
9486
+ "commit",
9487
+ "docs",
9488
+ "code"
9489
+ ];
9490
+ /** Ours, not the model's: what an unsupported claim is replaced with when a question remains. */
9491
+ var UNESTABLISHED_MOTIVATION = "The reason for this change isn't established by the description or the code.";
9492
+ /** Stable, so "Mark answered" on it survives a reload like any other question's. */
9493
+ var INTENT_QUESTION_ID = "intent-motivation";
9494
+ /**
9495
+ * Fewer words than this cannot attribute a reason to anyone: "the cache" is in most descriptions
9496
+ * that mention a cache. An initial setting, not a measured one.
9497
+ */
9498
+ var MIN_QUOTE_WORDS = 3;
9499
+ function text(value) {
9500
+ if (typeof value !== "string") return void 0;
9501
+ return value.trim() || void 0;
9502
+ }
9503
+ function normalizeEvidence(value) {
9504
+ if (!Array.isArray(value)) return [];
9505
+ return value.flatMap((raw) => {
9506
+ const item = raw ?? {};
9507
+ const kind = item.kind;
9508
+ const detail = text(item.detail);
9509
+ if (!MOTIVATION_EVIDENCE_KINDS.includes(kind) || !detail) return [];
9510
+ const file = text(item.file);
9511
+ return [{
9512
+ kind,
9513
+ detail,
9514
+ ...file ? { file } : {}
9515
+ }];
9516
+ });
9517
+ }
9518
+ /** The model's `motivation`, if it is one at all. Absent, or the wrong shape, is no motivation. */
9519
+ function normalizeMotivation(value) {
9520
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
9521
+ const item = value;
9522
+ const status = item.status;
9523
+ const summary = text(item.summary);
9524
+ if (!MOTIVATION_STATUSES.includes(status) || !summary) return void 0;
9525
+ const sourceQuote = text(item.sourceQuote);
9526
+ const authorQuestion = text(item.authorQuestion);
9527
+ return {
9528
+ status,
9529
+ summary,
9530
+ evidence: normalizeEvidence(item.evidence),
9531
+ ...sourceQuote ? { sourceQuote } : {},
9532
+ ...authorQuestion ? { authorQuestion } : {}
9533
+ };
9534
+ }
9535
+ /** Whitespace is the one thing a faithful quote may differ in: line wrapping is not wording. */
9536
+ function collapse(value) {
9537
+ return value.replace(/\s+/g, " ").trim();
9538
+ }
9539
+ /**
9540
+ * Whether the author wrote this, in these words, in the description the analysis read.
9541
+ *
9542
+ * Searched in the author's own text only. A template's instructions are in the description too, and
9543
+ * "Explain why this change is needed" would otherwise pass as the author explaining why.
9544
+ */
9545
+ function isAuthorQuote(quote, description) {
9546
+ if (!quote) return false;
9547
+ const needle = collapse(quote);
9548
+ if (needle.split(" ").length < MIN_QUOTE_WORDS) return false;
9549
+ return collapse(authorText(description)).includes(needle);
9550
+ }
9551
+ /**
9552
+ * The checks, applied to one candidate.
9553
+ *
9554
+ * Each status has one thing it cannot stand without: a verified quote, evidence, a question. The
9555
+ * fields that belong to the other statuses are dropped rather than carried, so a `stated` motive
9556
+ * cannot also show a guess and an `unclear` one cannot also show a quote.
9557
+ *
9558
+ * A claim that fails becomes unclear only if the model also asked the question that would settle
9559
+ * it, and then in our words rather than its own. Otherwise there is nothing to show that the
9560
+ * reviewer could act on, and nothing is shown.
9561
+ */
9562
+ function verifyMotivation(candidate, authorDescription) {
9563
+ if (!candidate) return {};
9564
+ const base = {
9565
+ contractVersion: 1,
9566
+ summary: candidate.summary
9567
+ };
9568
+ if (candidate.status === "stated" && isAuthorQuote(candidate.sourceQuote, authorDescription)) return { motivation: {
9569
+ ...base,
9570
+ status: "stated",
9571
+ sourceQuote: candidate.sourceQuote
9572
+ } };
9573
+ if (candidate.status === "inferred" && candidate.evidence.length > 0) return { motivation: {
9574
+ ...base,
9575
+ status: "inferred",
9576
+ evidence: candidate.evidence
9577
+ } };
9578
+ if (candidate.status === "unclear" && candidate.authorQuestion) return { motivation: {
9579
+ ...base,
9580
+ status: "unclear",
9581
+ authorQuestion: candidate.authorQuestion
9582
+ } };
9583
+ const reason = candidate.status === "stated" ? candidate.sourceQuote ? "unverified-quote" : "missing-quote" : candidate.status === "inferred" ? "no-evidence" : "no-question";
9584
+ const correction = {
9585
+ status: candidate.status,
9586
+ summary: candidate.summary,
9587
+ reason
9588
+ };
9589
+ if (candidate.status !== "unclear" && candidate.authorQuestion) return {
9590
+ motivation: {
9591
+ contractVersion: 1,
9592
+ status: "unclear",
9593
+ summary: UNESTABLISHED_MOTIVATION,
9594
+ authorQuestion: candidate.authorQuestion
9595
+ },
9596
+ correction: {
9597
+ ...correction,
9598
+ outcome: "downgraded"
9599
+ }
9600
+ };
9601
+ return { correction: {
9602
+ ...correction,
9603
+ outcome: "withheld"
9604
+ } };
9605
+ }
9606
+ /**
9607
+ * The one evidence-link policy, for both hosts.
9608
+ *
9609
+ * A changed file is a diff link and a real unchanged file a source link. A path the host can show
9610
+ * does not exist is removed and returned for the audit. A path the host cannot check keeps its
9611
+ * text and gets no state, so it is never presented as a link the reader can trust.
9612
+ */
9613
+ function placeEvidenceFiles(motivation, paths) {
9614
+ if (!motivation?.evidence) return {
9615
+ motivation,
9616
+ removed: []
9617
+ };
9618
+ const removed = [];
9619
+ const evidence = motivation.evidence.map(({ fileState: _ignored, ...item }) => {
9620
+ if (!item.file) return item;
9621
+ if (paths.changed(item.file)) return {
9622
+ ...item,
9623
+ fileState: "changed"
9624
+ };
9625
+ if (!paths.exists) return item;
9626
+ if (paths.exists(item.file)) return {
9627
+ ...item,
9628
+ fileState: "unchanged"
9629
+ };
9630
+ removed.push(item.file);
9631
+ const { file: _gone, ...rest } = item;
9632
+ return rest;
9633
+ });
9634
+ return {
9635
+ motivation: {
9636
+ ...motivation,
9637
+ evidence
9638
+ },
9639
+ removed
9640
+ };
9641
+ }
9642
+ /** Two questions are the same question when they differ only in case, spacing or end punctuation. */
9643
+ function sameQuestion(a, b) {
9644
+ const key = (value) => collapse(value).toLowerCase().replace(/[?.!\s]+$/, "");
9645
+ return key(a) === key(b);
9646
+ }
9647
+ /**
9648
+ * File an unclear motive's question with the rest, as the one Intent question.
9649
+ *
9650
+ * A question the model already asked in the same words is tagged rather than repeated, so the
9651
+ * reader meets it once, in the Intent group. Telling two differently worded questions apart is
9652
+ * judgement and is left to the prompt.
9653
+ */
9654
+ function withIntentQuestion(questions, motivation) {
9655
+ if (motivation?.status !== "unclear" || !motivation.authorQuestion) return questions;
9656
+ const asked = motivation.authorQuestion;
9657
+ const existing = questions.findIndex((q) => sameQuestion(q.question, asked));
9658
+ if (existing >= 0) return questions.map((q, index) => index === existing ? {
9659
+ ...q,
9660
+ category: "intent"
9661
+ } : q);
9662
+ const taken = new Set(questions.map((q) => q.id));
9663
+ let id = INTENT_QUESTION_ID;
9664
+ for (let n = 2; taken.has(id); n += 1) id = `${INTENT_QUESTION_ID}-${n}`;
9665
+ return [...questions, {
9666
+ id,
9667
+ question: asked,
9668
+ category: "intent"
9669
+ }];
9670
+ }
9671
+ /** The candidate checked and, when it ends unclear, its question filed. */
9672
+ function applyMotivation(result, candidate, authorDescription) {
9673
+ const { motivation, correction } = verifyMotivation(candidate, authorDescription);
9674
+ const { motivation: _unverified, ...rest } = result;
9675
+ return {
9676
+ result: {
9677
+ ...rest,
9678
+ ...motivation ? { motivation } : {},
9679
+ questions: withIntentQuestion(result.questions, motivation)
9680
+ },
9681
+ ...correction ? { correction } : {}
9682
+ };
9683
+ }
9684
+ //#endregion
9313
9685
  //#region ../../packages/review-harness/src/domain/ai_review/pipeline.ts
9314
9686
  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:
9315
9687
  {
9316
9688
  "summary": string,
9689
+ "motivation"?: {
9690
+ "status": "stated" | "inferred" | "unclear",
9691
+ "summary": string,
9692
+ "sourceQuote"?: string,
9693
+ "evidence"?: [{ "kind": "test" | "behaviour" | "issue" | "commit" | "docs" | "code", "detail": string, "file"?: string }],
9694
+ "authorQuestion"?: string
9695
+ },
9317
9696
  "overallRisk": "low" | "medium" | "high",
9318
9697
  "sections": [{
9319
9698
  "id": string,
@@ -9336,6 +9715,11 @@ var STRUCTURED_OUTPUT_INSTRUCTIONS = `In addition to the review principles above
9336
9715
  }
9337
9716
 
9338
9717
  Field guidance:
9718
+ - "motivation": WHY this change exists, which is a different question from what it does ("summary" covers that). Name the problem being solved or the goal being served, never the mechanics: "adds a retry loop" is what the code does, "the sync job was failing on transient network errors" is why. Weigh sources in this order: the PR Description and any issue it links; then tests and user-visible behaviour; then commit messages and docs; then the implementation itself. Choose exactly one "status":
9719
+ - "stated": the author's own words in the PR Description say why. "sourceQuote" is REQUIRED: a short passage (one sentence or less, at least three words) copied character for character from the PR Description, which says why. Only the PR Description counts as the author stating it. Never use "stated" when the description only restates what the code does.
9720
+ - "inferred": nothing states it, but the change gives real evidence of it. "evidence" is REQUIRED and non-empty: each entry names what points to the motive, such as a test asserting the fixed behaviour, an issue reference, or a commit message, with "file" set to the changed file it is in where there is one. Never infer a motive from names alone (a branch, a function or a file being called "fix-x" is not evidence). Word the summary as a likely reading, not a fact: "This appears to..." rather than "This fixes...".
9721
+ - "unclear": neither the description nor the change establishes why, AND knowing why would change how this change should be reviewed (for example, whether a behaviour change is intended). "authorQuestion" is REQUIRED: one focused question for the author that would settle it. Do not also ask that question, in any wording, in "questions".
9722
+ 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.
9339
9723
  - "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.
9340
9724
  - "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."
9341
9725
  - "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.
@@ -9346,7 +9730,7 @@ Field guidance:
9346
9730
  - "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.
9347
9731
  - "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.
9348
9732
  - "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.
9349
- - Every section's "oneLineSummary"/"purpose"/"whyReview", each file's "focus", and each question's "whyThisMatters" are rendered as plain prose paragraphs, not markdown — write them as normal sentences. Do not use "- " bullet points or numbered lists inside these fields; if you're enumerating several points, join them into flowing sentences instead. Inline code/identifiers may use backtick spans (e.g. \`ErrorSeverity\`) — those do render.
9733
+ - 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.
9350
9734
  - "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.
9351
9735
 
9352
9736
  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.`;
@@ -9364,7 +9748,22 @@ function buildStructuredReviewPrompt(pr, options = {}) {
9364
9748
  partialFiles: partialFiles ?? []
9365
9749
  };
9366
9750
  }
9367
- function parseReviewResult(raw) {
9751
+ /**
9752
+ * A reply, parsed, with the motivation checked against the description the analysis read.
9753
+ *
9754
+ * The check happens here rather than after, so there is no parsed result anywhere whose motivation
9755
+ * has not been through it. `correction` says what the check refused, for a caller with an audit to
9756
+ * put it in.
9757
+ */
9758
+ function parseReviewResponse(raw, authorDescription) {
9759
+ const { result, motivation } = parseUnchecked(raw);
9760
+ const applied = applyMotivation(result, motivation, authorDescription);
9761
+ return {
9762
+ result: applied.result,
9763
+ ...applied.correction ? { motivationCorrection: applied.correction } : {}
9764
+ };
9765
+ }
9766
+ function parseUnchecked(raw) {
9368
9767
  const jsonText = extractJsonText(raw);
9369
9768
  let parsed;
9370
9769
  try {
@@ -9378,14 +9777,17 @@ function parseReviewResult(raw) {
9378
9777
  const recommendation = asString(obj.recommendation);
9379
9778
  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.");
9380
9779
  return {
9381
- schemaVersion: 1,
9382
- summary,
9383
- overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
9384
- sections: normalizeSections(obj.sections),
9385
- issues: normalizeIssues(obj.issues),
9386
- questions: normalizeQuestions(obj.questions),
9387
- recommendation,
9388
- nextSteps: asStringArray(obj.nextSteps)
9780
+ result: {
9781
+ schemaVersion: 1,
9782
+ summary,
9783
+ overallRisk: RISKS.includes(obj.overallRisk) ? obj.overallRisk : "medium",
9784
+ sections: normalizeSections(obj.sections),
9785
+ issues: normalizeIssues(obj.issues),
9786
+ questions: normalizeQuestions(obj.questions),
9787
+ recommendation,
9788
+ nextSteps: asStringArray(obj.nextSteps)
9789
+ },
9790
+ motivation: normalizeMotivation(obj.motivation)
9389
9791
  };
9390
9792
  }
9391
9793
  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.";
@@ -9505,6 +9907,7 @@ var KISS_OUTPUT_INSTRUCTIONS = `Respond with a single JSON object, no markdown a
9505
9907
  "justifiedComplexity": [{
9506
9908
  "id": string,
9507
9909
  "title": string,
9910
+ "reason": string,
9508
9911
  "rationale": string,
9509
9912
  "locations": [{ "path": string, "line": number, "primary": boolean }]
9510
9913
  }],
@@ -9530,6 +9933,9 @@ Field guidance:
9530
9933
  - "draftComment": REVIEWER FRAMING ONLY, and only where a comment is worth making. Omit it otherwise. A short, constructive pull request comment in the reviewer's voice.
9531
9934
  - "locations": required, at least one. Where this complexity lives. Prefer the changed files responsible for it, named exactly as they appear in the diff. If an unchanged repository file is where the complexity actually sits and no changed file represents it, cite that file too, but never describe an unchanged file as part of this change. "line" is optional. Set "primary": true on exactly one location — the one place a reader should open first, which should be a changed file wherever one represents the concern — and false on the rest.
9532
9935
  - "justifiedComplexity": complexity in this change that is doing a real job. Empty list when there is nothing worth the note.
9936
+ - "title": what is kept, as the decision: "Keep atomic replacement", "Keep the schema 1 fallback".
9937
+ - "reason": required. Why it stays, in one sentence of at most 15 words: "Prevents concurrent writers from corrupting persisted state." Shown without expanding anything, so it must carry the point on its own. Do not restate the title.
9938
+ - "rationale": the full supporting explanation, in detail. Point at the code.
9533
9939
  - "aggregateEstimate": optional, one phrase, across the whole change. Omit it unless it is defensible.`;
9534
9940
  function buildKissPrompt(pr, options) {
9535
9941
  const { stats, raw } = pr;
@@ -9752,6 +10158,7 @@ async function assemble(changeSet, budget, lens, prContext) {
9752
10158
  ...changeSet.data,
9753
10159
  files: selection.inline,
9754
10160
  changedFileCount: allFiles.length,
10161
+ ...prContext ? { pullRequest: pullRequestFor(changeSet.data.pullRequest, prContext) } : {},
9755
10162
  discussion: prContext?.discussion
9756
10163
  }, selection.inline.length, null);
9757
10164
  const shared = {
@@ -9781,6 +10188,21 @@ async function assemble(changeSet, budget, lens, prContext) {
9781
10188
  };
9782
10189
  }
9783
10190
  /**
10191
+ * The change's pull request, as the prompt should present it once a forge request is attached.
10192
+ *
10193
+ * With no description, the commit subjects stay: they are still the best account of the change
10194
+ * there is, and still labelled as commits rather than as the author's words.
10195
+ */
10196
+ function pullRequestFor(local, prContext) {
10197
+ const body = prContext.pullRequest.body;
10198
+ if (!body) return local;
10199
+ const { bodySource: _commits, ...rest } = local;
10200
+ return {
10201
+ ...rest,
10202
+ body
10203
+ };
10204
+ }
10205
+ /**
9784
10206
  * The inline patches as one blob, for grounding the assistant.
9785
10207
  *
9786
10208
  * No ceiling of its own. `selectInlineDiffs` already bounded this, and a second constant guarding
@@ -9828,10 +10250,10 @@ function parseKissAnswer(raw) {
9828
10250
  const { kept, withheld } = normalizeRecommendations(obj.recommendations);
9829
10251
  return {
9830
10252
  result: {
9831
- schemaVersion: 2,
10253
+ schemaVersion: 3,
9832
10254
  assessment,
9833
10255
  summary,
9834
- recommendations: kept,
10256
+ recommendations: distinctIds(kept),
9835
10257
  justifiedComplexity: normalizeJustified(obj.justifiedComplexity),
9836
10258
  ...optional("aggregateEstimate", asString(obj.aggregateEstimate))
9837
10259
  },
@@ -9896,6 +10318,7 @@ function normalizeJustified(value) {
9896
10318
  return value.filter((item) => typeof item === "object" && item !== null).map((item, index) => ({
9897
10319
  id: asString(item.id) || `keep-${index + 1}`,
9898
10320
  title: asString(item.title),
10321
+ ...optional("reason", unlessPlaceholder(asString(item.reason))),
9899
10322
  rationale: asString(item.rationale),
9900
10323
  locations: normalizeLocations(item.locations)
9901
10324
  })).filter((entry) => entry.title !== "" || entry.rationale !== "");
@@ -10114,13 +10537,14 @@ async function existingPaths(repoPath, paths, commit = "HEAD") {
10114
10537
  * from the change set, and it costs one git call, or none when the guide cites nothing off-diff.
10115
10538
  */
10116
10539
  async function validateGuide(rawResponse, context) {
10117
- const result = parseReviewResult(rawResponse);
10540
+ const { result, motivationCorrection } = parseReviewResponse(rawResponse, context.prContext?.pullRequest.body);
10118
10541
  const changedPaths = new Set(context.changeSet.data.files.map((file) => file.name));
10119
10542
  const sectionIds = new Set(result.sections.map((section) => section.id));
10120
10543
  const findings = [...result.issues, ...result.questions];
10121
10544
  const offDiff = /* @__PURE__ */ new Set();
10122
10545
  for (const section of result.sections) for (const file of section.files) if (!changedPaths.has(file.path)) offDiff.add(file.path);
10123
10546
  for (const finding of findings) if (finding.file && !changedPaths.has(finding.file)) offDiff.add(finding.file);
10547
+ for (const evidence of result.motivation?.evidence ?? []) if (evidence.file && !changedPaths.has(evidence.file)) offDiff.add(evidence.file);
10124
10548
  const realButUnchanged = await existingPaths(context.changeSet.repoPath, [...offDiff]);
10125
10549
  const unknownPaths = /* @__PURE__ */ new Set();
10126
10550
  const sectioned = /* @__PURE__ */ new Set();
@@ -10161,6 +10585,13 @@ async function validateGuide(rawResponse, context) {
10161
10585
  orphanedSectionRefs += 1;
10162
10586
  }
10163
10587
  }
10588
+ const placed = placeEvidenceFiles(result.motivation, {
10589
+ changed: (path) => changedPaths.has(path),
10590
+ exists: (path) => realButUnchanged.has(path)
10591
+ });
10592
+ if (placed.motivation) result.motivation = placed.motivation;
10593
+ for (const path of placed.removed) unknownPaths.add(path);
10594
+ for (const evidence of placed.motivation?.evidence ?? []) if (evidence.fileState === "unchanged" && evidence.file) noteReferenced(evidence.file);
10164
10595
  const attribution = attributeIssuesToDiscussion(result, context.processed.discussion);
10165
10596
  return {
10166
10597
  result: attribution.result,
@@ -10168,7 +10599,8 @@ async function validateGuide(rawResponse, context) {
10168
10599
  unknownPaths: [...unknownPaths].sort(),
10169
10600
  relocatedFindings,
10170
10601
  orphanedSectionRefs,
10171
- rejectedAttributions: attribution.rejected
10602
+ rejectedAttributions: attribution.rejected,
10603
+ ...motivationCorrection ? { motivationCorrection } : {}
10172
10604
  },
10173
10605
  referencedFiles: [...referenced].map(([path, sections]) => ({
10174
10606
  path,
@@ -10849,16 +11281,17 @@ async function startJob(input) {
10849
11281
  } : {}
10850
11282
  });
10851
11283
  saveJob(job);
10852
- if (review.type === "kiss") startKissRun(workspace, {
10853
- jobId: job.id,
10854
- audience: review.audience,
10855
- reviewedSha: target.sha,
10856
- promptVersion: "2",
10857
- agentId: agent.id,
10858
- ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
11284
+ mutateWorkspace(workspace.id, (fresh) => {
11285
+ if (review.type === "kiss") startKissRun(fresh, {
11286
+ jobId: job.id,
11287
+ audience: review.audience,
11288
+ reviewedSha: target.sha,
11289
+ promptVersion: "3",
11290
+ agentId: agent.id,
11291
+ ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
11292
+ });
11293
+ else fresh.jobId = job.id;
10859
11294
  });
10860
- else workspace.jobId = job.id;
10861
- saveWorkspace(workspace);
10862
11295
  });
10863
11296
  return workspace.id;
10864
11297
  }
@@ -10881,37 +11314,41 @@ async function reanalyseReview(workspaceId, agentId, onStarted, requestedModel)
10881
11314
  if (!sha) throw new Error("This review does not record which commit it describes, so it cannot be analysed again.");
10882
11315
  const agent = agentId ? agentById(agentId) : agentById(agentIdOf(workspace.session));
10883
11316
  const started = await withReviewLock(workspaceId, () => {
10884
- if (workspace.session.result) workspace.previousResult = {
10885
- result: workspace.session.result,
10886
- commitSha: sha,
10887
- replacedAt: Date.now(),
10888
- agentId: agentIdOf(workspace.session),
10889
- ...workspace.session.requestedModel ? { requestedModel: workspace.session.requestedModel } : {}
10890
- };
10891
- const target = {
10892
- originRepoPath: workspace.repoPath,
10893
- branch: workspace.changeSet.headRef,
10894
- sha,
10895
- ...baseOf(workspace.changeSet)
10896
- };
10897
- const model = resolveModel({
10898
- ...requestedModel ? { flag: requestedModel } : {},
10899
- review: { session: workspace.session }
10900
- });
10901
- const job = createJob({
10902
- workspaceId: workspace.id,
10903
- target,
10904
- env: agent.captureEnv(),
10905
- purpose: "review",
10906
- reanalysis: true,
10907
- ...model ? { requestedModel: model } : {}
11317
+ const created = {};
11318
+ mutateWorkspace(workspaceId, (workspace) => {
11319
+ if (workspace.session.result) workspace.previousResult = {
11320
+ result: workspace.session.result,
11321
+ commitSha: sha,
11322
+ replacedAt: Date.now(),
11323
+ agentId: agentIdOf(workspace.session),
11324
+ ...workspace.session.requestedModel ? { requestedModel: workspace.session.requestedModel } : {}
11325
+ };
11326
+ const target = {
11327
+ originRepoPath: workspace.repoPath,
11328
+ branch: workspace.changeSet.headRef,
11329
+ sha,
11330
+ ...baseOf(workspace.changeSet)
11331
+ };
11332
+ const model = resolveModel({
11333
+ ...requestedModel ? { flag: requestedModel } : {},
11334
+ review: { session: workspace.session }
11335
+ });
11336
+ const job = createJob({
11337
+ workspaceId: workspace.id,
11338
+ target,
11339
+ env: agent.captureEnv(),
11340
+ purpose: "review",
11341
+ reanalysis: true,
11342
+ ...model ? { requestedModel: model } : {}
11343
+ });
11344
+ saveJob(job);
11345
+ workspace.jobId = job.id;
11346
+ workspace.session = beginReviewSession(sha, agent, void 0, model ?? void 0);
11347
+ dropDerivedFromAnalysis(workspace);
11348
+ created.job = job;
10908
11349
  });
10909
- saveJob(job);
10910
- workspace.jobId = job.id;
10911
- workspace.session = beginReviewSession(sha, agent, void 0, model ?? void 0);
10912
- dropDerivedFromAnalysis(workspace);
10913
- saveWorkspace(workspace);
10914
- return job;
11350
+ if (!created.job) throw new Error("No such review.");
11351
+ return created.job;
10915
11352
  });
10916
11353
  if (isBusy(started)) throw new ReviewBeingDeletedError();
10917
11354
  onStarted?.(started.id);
@@ -11038,27 +11475,27 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
11038
11475
  ...job.target.pullRequest ? { requestNumber: job.target.pullRequest.number } : {},
11039
11476
  ...expectedFor(job.target)
11040
11477
  });
11041
- const stored = loadWorkspace(workspaceId);
11042
11478
  const isLinkedWorktree = await linkedWorktree(job.target.originRepoPath);
11043
11479
  const checkoutName = basename(job.target.originRepoPath);
11044
- saveWorkspace({
11480
+ const target = job.target;
11481
+ mutateWorkspace(workspaceId, (stored) => ({
11045
11482
  ...stored,
11046
11483
  changeSet: {
11047
11484
  ...context.changeSet,
11048
- repoPath: job.target.originRepoPath,
11049
- headRef: job.target.branch,
11050
- ...job.target.ref ? { headResolvedRef: job.target.ref } : {},
11051
- ...commitIdentity(job.target, stored.changeSet),
11485
+ repoPath: target.originRepoPath,
11486
+ headRef: target.branch,
11487
+ ...target.ref ? { headResolvedRef: target.ref } : {},
11488
+ ...commitIdentity(target, stored.changeSet),
11052
11489
  checkoutName,
11053
11490
  isLinkedWorktree,
11054
11491
  data: {
11055
11492
  ...context.changeSet.data,
11056
11493
  pullRequest: {
11057
11494
  ...context.changeSet.data.pullRequest,
11058
- number: job.target.pullRequest?.number ?? context.changeSet.data.pullRequest.number,
11495
+ number: target.pullRequest?.number ?? context.changeSet.data.pullRequest.number,
11059
11496
  head: {
11060
11497
  ...context.changeSet.data.pullRequest.head,
11061
- ref: job.target.branch
11498
+ ref: target.branch
11062
11499
  },
11063
11500
  repo: checkoutName
11064
11501
  }
@@ -11066,7 +11503,7 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
11066
11503
  },
11067
11504
  diff: context.diff,
11068
11505
  reviewedPaths: context.changeSet.data.files.map((file) => file.name)
11069
- });
11506
+ }));
11070
11507
  job = {
11071
11508
  ...job,
11072
11509
  contextId: context.contextId
@@ -11099,10 +11536,12 @@ async function drive(jobId, workspaceId, runners, driver, signal) {
11099
11536
  detail: message(error)
11100
11537
  };
11101
11538
  }
11102
- if (outcome.kind === "attached") saveWorkspace({
11103
- ...stored,
11104
- prContext: outcome.context
11105
- });
11539
+ if (outcome.kind === "attached") {
11540
+ const context = outcome.context;
11541
+ mutateWorkspace(workspaceId, (fresh) => {
11542
+ fresh.prContext = context;
11543
+ });
11544
+ }
11106
11545
  }
11107
11546
  job = {
11108
11547
  ...job,
@@ -11338,15 +11777,17 @@ async function runAnalysisFor(job, workspaceId, runners, signal, onObservedModel
11338
11777
  */
11339
11778
  async function applyKiss(job, workspaceId, payload, observedModel) {
11340
11779
  const stored = loadWorkspace(workspaceId);
11341
- const run = runOfJob(stored, job.id);
11780
+ runOfJob(stored, job.id);
11342
11781
  const validated = await validateKiss(payload, stored.reviewedPaths, (paths) => existingPaths(job.worktreePath, paths));
11343
- completeKissRun(stored, run.id, {
11344
- result: validated.result,
11345
- audit: validated.audit,
11346
- ...job.env.agentId ? { agentId: job.env.agentId } : {},
11347
- ...await runProvenanceOf(job, observedModel)
11782
+ const provenance = await runProvenanceOf(job, observedModel);
11783
+ mutateWorkspace(workspaceId, (fresh) => {
11784
+ completeKissRun(fresh, runOfJob(fresh, job.id).id, {
11785
+ result: validated.result,
11786
+ audit: validated.audit,
11787
+ ...job.env.agentId ? { agentId: job.env.agentId } : {},
11788
+ ...provenance
11789
+ });
11348
11790
  });
11349
- saveWorkspace(stored);
11350
11791
  }
11351
11792
  /**
11352
11793
  * Record the failure on the run, if there is still a run in flight to record it on.
@@ -11358,12 +11799,11 @@ async function applyKiss(job, workspaceId, payload, observedModel) {
11358
11799
  * declines instead of turning a delivered answer into a failure.
11359
11800
  */
11360
11801
  function failCurrentKissRun(workspaceId, job, reason, kind, rejectedAnswer) {
11361
- const stored = loadWorkspace(workspaceId);
11362
- if (!stored) return;
11363
- const run = (stored.kissRuns ?? []).find((candidate) => candidate.jobId === job.id);
11364
- if (!run || run.status === "completed" || run.status === "failed") return;
11365
- failKissRun(stored, run.id, reason, kind, rejectedAnswer);
11366
- saveWorkspace(stored);
11802
+ mutateWorkspace(workspaceId, (stored) => {
11803
+ const run = (stored.kissRuns ?? []).find((candidate) => candidate.jobId === job.id);
11804
+ if (!run || run.status === "completed" || run.status === "failed") return;
11805
+ failKissRun(stored, run.id, reason, kind, rejectedAnswer);
11806
+ });
11367
11807
  }
11368
11808
  async function applyGuide(job, workspaceId, payload, observedModel) {
11369
11809
  const stored = loadWorkspace(workspaceId);
@@ -11372,18 +11812,22 @@ async function applyGuide(job, workspaceId, payload, observedModel) {
11372
11812
  try {
11373
11813
  applied = await applyGuideToContext(context, payload);
11374
11814
  } catch (error) {
11375
- if (error instanceof GuideRejected) saveWorkspace({
11376
- ...stored,
11377
- session: failSession(stored.session, error.message, "agent_output_invalid", error.answer)
11378
- });
11815
+ if (error instanceof GuideRejected) {
11816
+ const failed = failSession(stored.session, error.message, "agent_output_invalid", error.answer);
11817
+ mutateWorkspace(workspaceId, (fresh) => ({
11818
+ ...fresh,
11819
+ session: keepReviewerFields(fresh, failed)
11820
+ }));
11821
+ }
11379
11822
  throw error;
11380
11823
  }
11381
- saveWorkspace({
11382
- ...stored,
11383
- session: completeReviewSession(stored.session, applied.result, context, { run: await runProvenanceOf(job, observedModel) }),
11824
+ const session = completeReviewSession(stored.session, applied.result, context, { run: await runProvenanceOf(job, observedModel) });
11825
+ mutateWorkspace(workspaceId, (fresh) => ({
11826
+ ...fresh,
11827
+ session: keepReviewerFields(fresh, session),
11384
11828
  audit: applied.audit,
11385
11829
  referencedFiles: applied.referencedFiles
11386
- });
11830
+ }));
11387
11831
  }
11388
11832
  /**
11389
11833
  * How the run that just finished was made, as the session should record it.
@@ -11458,7 +11902,7 @@ async function recordKissRun(input) {
11458
11902
  jobId: job.id,
11459
11903
  audience: input.audience,
11460
11904
  reviewedSha: sha,
11461
- promptVersion: "2",
11905
+ promptVersion: "3",
11462
11906
  agentId: agent.id,
11463
11907
  ...input.requestedModel ? { requestedModel: input.requestedModel } : {}
11464
11908
  });
@@ -12220,6 +12664,7 @@ function movable(fetched) {
12220
12664
  };
12221
12665
  copy("headSha");
12222
12666
  copy("title");
12667
+ copy("body");
12223
12668
  copy("filesUrl");
12224
12669
  copy("fileUrlTemplate");
12225
12670
  copy("author");
@@ -12429,6 +12874,7 @@ async function reconcile(workspace, runners, reviewedSha, remote, conversationOk
12429
12874
  const result = {
12430
12875
  schemaVersion: 1,
12431
12876
  summary: parsed.summary || previous.summary,
12877
+ ...previous.motivation ? { motivation: previous.motivation } : {},
12432
12878
  overallRisk: parsed.overallRisk ?? previous.overallRisk,
12433
12879
  recommendation: parsed.recommendation || previous.recommendation,
12434
12880
  nextSteps: parsed.nextSteps.length ? parsed.nextSteps : previous.nextSteps,
@@ -12557,11 +13003,7 @@ function mergeUpdateInto(fresh, updated) {
12557
13003
  const source = updated;
12558
13004
  for (const field of UPDATE_OWNED_FIELDS) if (field in updated) target[field] = source[field];
12559
13005
  else delete target[field];
12560
- const session = { ...updated.session };
12561
- const current = fresh.session;
12562
- for (const field of REVIEWER_SESSION_FIELDS) if (field in fresh.session) session[field] = current[field];
12563
- else delete session[field];
12564
- merged.session = session;
13006
+ merged.session = keepReviewerFields(fresh, updated.session);
12565
13007
  dropStaleChangeMap(merged);
12566
13008
  return merged;
12567
13009
  }
@@ -12991,4 +13433,4 @@ function lineageIds(id) {
12991
13433
  return [.../* @__PURE__ */ new Set([id, ...lineage.map((review) => review.id)])];
12992
13434
  }
12993
13435
  //#endregion
12994
- export { compactQueue as $, loadWorkspace as $t, createPrState as A, reviewerDispositions as An, followedBranch as At, toneOf as B, resolveModel as Bn, phasesFor as Bt, readEvents as C, queueStamp as Cn, claimRequestUrl as Ct, announceJob as D, isQuestionOutstanding as Dn, fillFileUrlTemplate as Dt, telemetryUploadConsent as E, isIssueOutstanding as En, remoteHost as Et, markAllRead as F, resolveTarget as Fn, isTerminal as Ft, extractJsonText as G, STORE_ROOT as Gn, isClaimed as Gt, withUsageRecorded as H, readAgentPreference as Hn, purposeOf as Ht, markRead as I, displayRef as In, loadJob as It, readMarker as J, agentById as Jn, indexKeptIds as Jt, ReviewBeingDeletedError as K, AGENT_IDS as Kn, followedRefName as Kt, pruneNotifications as L, isCommitReview as Ln, saveJob as Lt, eventName as M, describeAuthorship as Mn, allJobs as Mt, headline as N, WITHHELD_REASON as Nn, endedAt as Nt, branchOf as O, issuesOutstanding as On, checkCodeFreshness as Ot, listNotifications as P, kissOutcome as Pn, fail as Pt, activateQueued as Q, AgentUnavailableError as Qn, lineagePosition as Qt, readNotification as R, git as Rn, PHASES as Rt, startJob as S, queueRemove as Sn, writePortPreference as St, recordTelemetryUploadConsent as T, doneVerb as Tn, forgeFor as Tt, EXPLAIN_SIMPLY_PROMPT as U, writeAgentPreference as Un, clearClaim as Ut, unreadCount as V, clearAgentPreference as Vn, progressSteps as Vt, asString as W, CONFIG_PATH as Wn, driverGone as Wt, modelHelpLines as X, detectAgents as Xn, latestKissRun as Xt, removeWorktree as Y, agentFor as Yn, indexReviewGroups as Yt, relativeTime as Z, AgentCancelledError as Zn, lineageKeyFor as Zt, agentEnvOf as _, offersLifecycle as _n, MAX_WORKSPACE_PORT as _t, wasBlocked as a, reviewedCommit as an, ensureServer as at, reanalyseReview as b, queueAdd as bn, resolveWorkspacePort as bt, RefreshUnavailableError as c, touchWorkspace as cn, serverEntryExists as ct, refreshPrContext as d, LIFECYCLES as dn, workspaceUrl as dt, matchingWorkspaceIds as en, queueForReview as et, isUnchanged as f, activityLabel as fn, writeServerRecord as ft, runningJobs as g, lifecyclePill as gn, DEFAULT_WORKSPACE_PORT as gt, liveUpdateFor as h, lifecycleChip as hn, feedbackUrl as ht, liveJobsFor as i, recentWorkspaces as in, clearServerRecord as it, createPrUpdate as j, processDiscussion as jn, reviewedShaOf as jt, subjectOf as k, questionsOutstanding as kn, checkReview as kt, mergeUpdateInto as l, workspaceRevision as ln, spawnServer as lt, UpdateAlreadyRunningError as m, lifecycleAction as mn, PACKAGE_NAME as mt, deleteReview as n, positionInLineage as nn, readIndexToken as nt, discardJob as o, reviewedRepositories as on, readServerRecord as ot, askCheckout as p, isLifecycle as pn, BUILD_VERSION as pt, MANAGED_ROOT as q, DEFAULT_AGENT_ID as qn, groupByLineage as qt, lineageIds as r, previousKissResult as rn, bootstrapUrl as rt, FETCH_TIMEOUT_MS as s, summarise as sn, reviewsUrl as st, deleteLineage as t, mutateWorkspace as tn, settleQueue as tt, updateReview as u, workspaceStamps as un, stopServer as ut, recordKissRun as v, parseLifecycle as vn, MIN_WORKSPACE_PORT as vt, record as w, readQueue as wn, forgeResolver as wt, runJob as x, queueAddMany as xn, statedWorkspacePort as xt, startKissJob as y, sectionOf as yn, parseWorkspacePort as yt, removeNotification as z, resolveAgent as zn, isLiveJob as zt };
13436
+ export { relativeTime as $, AGENT_IDS as $n, kissDecisionRefusal as $t, createPrState as A, readQueue as An, checkCodeFreshness as At, toneOf as B, WITHHELD_REASON as Bn, PHASES as Bt, readEvents as C, offersLifecycle as Cn, statedWorkspacePort as Ct, announceJob as D, queueAddMany as Dn, forgeFor as Dt, telemetryUploadConsent as E, queueAdd as En, forgeResolver as Et, markAllRead as F, questionsOutstanding as Fn, endedAt as Ft, isLongDescription as G, git as Gn, clearClaim as Gt, withUsageRecorded as H, resolveTarget as Hn, phasesFor as Ht, markRead as I, reviewerDispositions as In, fail as It, ReviewBeingDeletedError as J, clearAgentPreference as Jn, decideKissRecommendation as Jt, asString as K, resolveAgent as Kn, driverGone as Kt, pruneNotifications as L, processDiscussion as Ln, isTerminal as Lt, eventName as M, isIssueOutstanding as Mn, followedBranch as Mt, headline as N, isQuestionOutstanding as Nn, reviewedShaOf as Nt, branchOf as O, queueRemove as On, remoteHost as Ot, listNotifications as P, issuesOutstanding as Pn, allJobs as Pt, modelHelpLines as Q, STORE_ROOT as Qn, indexReviewGroups as Qt, readNotification as R, recommendationsOf as Rn, loadJob as Rt, startJob as S, lifecyclePill as Sn, resolveWorkspacePort as St, recordTelemetryUploadConsent as T, sectionOf as Tn, claimRequestUrl as Tt, EXPLAIN_SIMPLY_PROMPT as U, displayRef as Un, progressSteps as Ut, unreadCount as V, kissOutcome as Vn, isLiveJob as Vt, hasAuthorContent as W, isCommitReview as Wn, purposeOf as Wt, readMarker as X, writeAgentPreference as Xn, groupByLineage as Xt, MANAGED_ROOT as Y, readAgentPreference as Yn, followedRefName as Yt, removeWorktree as Z, CONFIG_PATH as Zn, indexKeptIds as Zt, agentEnvOf as _, LIFECYCLES as _n, feedbackUrl as _t, wasBlocked as a, matchingWorkspaceIds as an, AgentUnavailableError as ar, bootstrapUrl as at, reanalyseReview as b, lifecycleAction as bn, MIN_WORKSPACE_PORT as bt, RefreshUnavailableError as c, presentedKissRunOf as cn, readServerRecord as ct, refreshPrContext as d, reviewedCommit as dn, spawnServer as dt, kissDecisionsOf as en, DEFAULT_AGENT_ID as er, activateQueued as et, isUnchanged as f, reviewedRepositories as fn, stopServer as ft, runningJobs as g, workspaceStamps as gn, PACKAGE_NAME as gt, liveUpdateFor as h, workspaceRevision as hn, BUILD_VERSION as ht, liveJobsFor as i, loadWorkspace as in, AgentCancelledError as ir, readIndexToken as it, createPrUpdate as j, doneVerb as jn, checkReview as jt, subjectOf as k, queueStamp as kn, fillFileUrlTemplate as kt, mergeUpdateInto as l, previousKissResult as ln, reviewsUrl as lt, UpdateAlreadyRunningError as m, touchWorkspace as mn, writeServerRecord as mt, deleteReview as n, lineageKeyFor as nn, agentFor as nr, queueForReview as nt, discardJob as o, mutateWorkspace as on, clearServerRecord as ot, askCheckout as p, summarise as pn, workspaceUrl as pt, extractJsonText as q, resolveModel as qn, isClaimed as qt, lineageIds as r, lineagePosition as rn, detectAgents as rr, settleQueue as rt, FETCH_TIMEOUT_MS as s, positionInLineage as sn, ensureServer as st, deleteLineage as t, latestKissRun as tn, agentById as tr, compactQueue as tt, updateReview as u, recentWorkspaces as un, serverEntryExists as ut, recordKissRun as v, activityLabel as vn, DEFAULT_WORKSPACE_PORT as vt, record as w, parseLifecycle as wn, writePortPreference as wt, runJob as x, lifecycleChip as xn, parseWorkspacePort as xt, startKissJob as y, isLifecycle as yn, MAX_WORKSPACE_PORT as yt, removeNotification as z, describeAuthorship as zn, saveJob as zt };