limn-review 0.1.140 → 0.1.141

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.
Files changed (29) hide show
  1. package/dist/coding/ai-fix/manager.js +4 -2
  2. package/dist/db/migrations/0137_review_pair_consolidation.sql +8 -0
  3. package/dist/db/migrations/meta/_journal.json +7 -0
  4. package/dist/db/queries.js +30 -4
  5. package/dist/db/schema.sqlite.js +12 -0
  6. package/dist/platform/slack/signals.js +27 -3
  7. package/dist/review/agent-context.js +1 -0
  8. package/dist/review/claude-review/auto-post.js +40 -31
  9. package/dist/review/claude-review/auto-verdict.js +17 -0
  10. package/dist/review/claude-review/chat.js +5 -3
  11. package/dist/review/claude-review/finding-facts.js +27 -0
  12. package/dist/review/claude-review/manager.js +59 -10
  13. package/dist/review/claude-review/manual-reply.js +3 -2
  14. package/dist/review/claude-review/pair-consolidate.js +302 -0
  15. package/dist/review/claude-review/pair-view.js +248 -0
  16. package/dist/review/claude-review/pair.js +7 -3
  17. package/dist/review/claude-review/persist.js +139 -36
  18. package/dist/review/claude-review/routes.js +70 -128
  19. package/dist/review/llm.js +1 -1
  20. package/dist/shared/ai-provider-words.js +18 -1
  21. package/package.json +1 -1
  22. package/public/assets/AiFixTab-DCFKnCcv.js +3 -0
  23. package/public/assets/ClaudeReviewTab-CZxpjVme.js +20 -0
  24. package/public/assets/{PrBotBehaviourTab-Bwu839bx.js → PrBotBehaviourTab-Btzq2J3-.js} +1 -1
  25. package/public/assets/{index-B23U_yh7.css → index-CY_rIeEn.css} +1 -1
  26. package/public/assets/{index-BoS_VHWj.js → index-DInrk9cC.js} +46 -46
  27. package/public/index.html +2 -2
  28. package/public/assets/AiFixTab-D8HrsNZV.js +0 -3
  29. package/public/assets/ClaudeReviewTab-CJpvYk_E.js +0 -20
@@ -1,7 +1,7 @@
1
1
  import { and, desc, eq } from 'drizzle-orm';
2
2
  import { AI_FIX_PLAN_MAX_REVISIONS, aiRunProvider } from '../../shared/index.js';
3
3
  import { getFixPrContext } from './pr-context.js';
4
- import { getClaudeReviewById } from '../../review/claude-review/persist.js';
4
+ import { getReviewView } from '../../review/claude-review/persist.js';
5
5
  import { currentThreadAssessmentsForPrs } from '../../review/claude-review/thread-judgements.js';
6
6
  import { pickReviewNonce } from '../../review/claude-review/prompts.js';
7
7
  import { buildPickerPreview, buildReviewSeed, } from './review-seed.js';
@@ -103,7 +103,9 @@ function emitFixStream(prId, e) {
103
103
  async function loadSourceReview(ctx, accountId, prId, reviewId) {
104
104
  if (reviewId == null)
105
105
  return null;
106
- const review = await getClaudeReviewById(ctx, reviewId, accountId);
106
+ // A Claude + Codex pair seeds from its ONE consolidated review (both halves' findings, merged-away
107
+ // duplicates never read — pair-view.ts), never from one half.
108
+ const review = await getReviewView(ctx, reviewId, accountId);
107
109
  if (!review || review.prId !== prId || review.status !== 'succeeded')
108
110
  return null;
109
111
  // A later Check threads run judged threads since this review: when this IS the latest review,
@@ -0,0 +1,8 @@
1
+ -- CLAUDE + CODEX: ONE REVIEW BY BOTH (docs/CLAUDE-REVIEW.md § Claude + Codex). When a pair ends its two
2
+ -- halves are consolidated into one review. `pair_consolidation` (on the pair's FIRST row only) holds
3
+ -- the one summary, the code-computed verdict, each kept finding's source, the merged-away duplicates
4
+ -- and the half that did not finish; NULL everywhere else. `merged_into` marks a finding the other
5
+ -- half's finding stands for (never shown, posted, counted or sent to AI Fix); NULL everywhere else.
6
+ ALTER TABLE `claude_reviews` ADD `pair_consolidation` text;
7
+ --> statement-breakpoint
8
+ ALTER TABLE `claude_review_findings` ADD `merged_into` integer;
@@ -960,6 +960,13 @@
960
960
  "when": 1792137623016,
961
961
  "tag": "0136_review_kind",
962
962
  "breakpoints": true
963
+ },
964
+ {
965
+ "idx": 137,
966
+ "version": "6",
967
+ "when": 1792137624016,
968
+ "tag": "0137_review_pair_consolidation",
969
+ "breakpoints": true
963
970
  }
964
971
  ]
965
972
  }
@@ -8038,6 +8038,7 @@ repoIds) {
8038
8038
  reviewHead: claudeReviews.headSha,
8039
8039
  prHead: pullRequests.headSha,
8040
8040
  trigger: claudeReviews.trigger,
8041
+ pairId: claudeReviews.pairId,
8041
8042
  })
8042
8043
  .from(claudeReviews)
8043
8044
  .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
@@ -8049,16 +8050,41 @@ repoIds) {
8049
8050
  .execute();
8050
8051
  const seen = new Set(); // most-recent succeeded run per PR only
8051
8052
  const candidates = [];
8053
+ // A CLAUDE + CODEX PAIR is ONE review (review/claude-review/pair-view.ts): its succeeded halves
8054
+ // are read together — posted when either was posted as a review, its findings both halves' (a
8055
+ // merged-away duplicate never counts), its verdict the pair's one verdict.
8056
+ const halvesOf = new Map();
8057
+ for (const r of rows) {
8058
+ if (r.pairId == null)
8059
+ continue;
8060
+ const list = halvesOf.get(r.pairId) ?? [];
8061
+ list.push(r);
8062
+ halvesOf.set(r.pairId, list);
8063
+ }
8052
8064
  for (const r of rows) {
8053
8065
  if (seen.has(r.prId))
8054
8066
  continue;
8055
8067
  seen.add(r.prId);
8056
- if (r.postedAt != null)
8068
+ const halves = r.pairId != null ? (halvesOf.get(r.pairId) ?? [r]) : [r];
8069
+ if (halves.some((h) => h.postedAt != null))
8057
8070
  continue; // the run itself was posted → actioned, no findings needed
8058
8071
  candidates.push(r);
8059
8072
  }
8060
8073
  if (candidates.length === 0)
8061
8074
  return [];
8075
+ const runIdsOf = (c) => c.pairId != null ? (halvesOf.get(c.pairId) ?? [c]).map((h) => h.reviewId) : [c.reviewId];
8076
+ // The pairs' one verdicts (on each pair's FIRST row), in ONE read.
8077
+ const pairIds = [...new Set(candidates.map((c) => c.pairId).filter((id) => id != null))];
8078
+ const pairVerdicts = new Map();
8079
+ if (pairIds.length > 0) {
8080
+ const recs = await db
8081
+ .select({ id: claudeReviews.id, rec: claudeReviews.pairConsolidation })
8082
+ .from(claudeReviews)
8083
+ .where(inArray(claudeReviews.id, pairIds))
8084
+ .execute();
8085
+ for (const p of recs)
8086
+ pairVerdicts.set(p.id, p.rec?.verdict ?? null);
8087
+ }
8062
8088
  // ONE grouped read over the candidate runs — never one query per run, and never a per-run
8063
8089
  // EXISTS in the select above, which would run the sub-query for every historical run of every
8064
8090
  // open PR rather than the handful that got this far.
@@ -8070,7 +8096,7 @@ repoIds) {
8070
8096
  postedAt: claudeReviewFindings.postedAt,
8071
8097
  })
8072
8098
  .from(claudeReviewFindings)
8073
- .where(inArray(claudeReviewFindings.reviewId, candidates.map((c) => c.reviewId)))
8099
+ .where(and(inArray(claudeReviewFindings.reviewId, candidates.flatMap(runIdsOf)), isNull(claudeReviewFindings.mergedInto)))
8074
8100
  .execute();
8075
8101
  const findingsByRun = new Map();
8076
8102
  for (const f of findingRows) {
@@ -8080,7 +8106,7 @@ repoIds) {
8080
8106
  }
8081
8107
  const out = [];
8082
8108
  for (const r of candidates) {
8083
- const findings = findingsByRun.get(r.reviewId) ?? [];
8109
+ const findings = runIdsOf(r).flatMap((id) => findingsByRun.get(id) ?? []);
8084
8110
  // STATE ONE: nothing from this run has reached GitHub. It was never worked, so it stays —
8085
8111
  // including a run with no findings at all, whose summary and verdict are still yours to post
8086
8112
  // or discard. ⚠ This branch is what keeps a fresh run visible: `included` defaults to false,
@@ -8101,7 +8127,7 @@ repoIds) {
8101
8127
  repoFullName: `${r.owner}/${r.name}`,
8102
8128
  prNumber: r.prNumber,
8103
8129
  prTitle: r.prTitle,
8104
- verdict: r.verdict,
8130
+ verdict: r.pairId != null ? (pairVerdicts.get(r.pairId) ?? r.verdict) : r.verdict,
8105
8131
  finishedAt: iso(r.finishedAt),
8106
8132
  headStale: r.reviewHead !== r.prHead,
8107
8133
  githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
@@ -1113,6 +1113,12 @@ export const claudeReviews = sqliteTable('claude_reviews', {
1113
1113
  pairId: integer('pair_id'),
1114
1114
  pairRole: text('pair_role', { enum: ['first', 'second'] }),
1115
1115
  pairDropped: integer('pair_dropped'),
1116
+ // THE PAIR'S ONE REVIEW (migration 0137; review/claude-review/pair-consolidate.ts): on the pair's
1117
+ // FIRST row only, written ONCE when the pair ends — the consolidated summary, the code verdict,
1118
+ // each kept finding's source, the merged-away duplicates and the half that did not finish.
1119
+ // NULL on every other row, on a pair still running, and on a pair from before the column (read as
1120
+ // the two halves joined in code).
1121
+ pairConsolidation: text('pair_consolidation', { mode: 'json' }).$type(),
1116
1122
  // A REPLIES PASS (review/claude-review/replies-check.ts): 'replies' on a run that judged only
1117
1123
  // people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
1118
1124
  // ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
@@ -1207,6 +1213,12 @@ export const claudeReviewFindings = sqliteTable('claude_review_findings', {
1207
1213
  // The reference-library sections this finding rests on, server-checked (migration 0117 / pg 0104).
1208
1214
  // NULL = none.
1209
1215
  docCitations: text('doc_citations', { mode: 'json' }).$type(),
1216
+ // A CLAUDE + CODEX PAIR'S DUPLICATE (migration 0137): the id of the finding kept in this one's
1217
+ // place when the pair was consolidated (the other half made the same point). A soft reference to
1218
+ // a finding of the same pair (no FK, like `prior_finding_id`). A merged-away finding stays here
1219
+ // but is NEVER shown, posted, counted in a verdict or sent to AI Fix; the next follow-up of its
1220
+ // provider follows up the kept one instead. NULL on every other finding.
1221
+ mergedInto: integer('merged_into'),
1210
1222
  }, (t) => ({ reviewIdx: index('crf_review_idx').on(t.reviewId) }));
1211
1223
  // ---- Claude Review chat (CORE table, written by the Pro plugin) ----
1212
1224
  // One row per chat MESSAGE about one succeeded review: a general thread (`finding_id` NULL) and
@@ -3,7 +3,7 @@ import { proSchemaFor } from '../db.js';
3
3
  import { escapeSlack, prLink, slackLink } from './links.js';
4
4
  import { postToSlack } from './send.js';
5
5
  import { readTargetRow } from './targets.js';
6
- import { aiRunProviderName } from '../../shared/index.js';
6
+ import { aiRunProviderName, PAIR_RUN_LABEL } from '../../shared/index.js';
7
7
  // ── THE TWO SLACK EVENT SIGNALS (Pro, `slackDigest`; plugin migration 0040) ──────────────────
8
8
  //
9
9
  // Per workspace, each its own switch on the delivery row, both OFF by default:
@@ -105,7 +105,9 @@ const trimSummary = (s) => {
105
105
  };
106
106
  /** Pure builder for the "Claude Review ran" message ("Codex Review ran" when Codex ran it). Exported
107
107
  * for its test. */
108
- export function reviewMessage(ctx, workspaceId, f, verdict, summary, ranBy = 'Claude') {
108
+ export function reviewMessage(ctx, workspaceId, f, verdict, summary,
109
+ // 'Claude + Codex' for a pair's one review.
110
+ ranBy = 'Claude') {
109
111
  const by = f.authorLogin ? ` by @${escapeSlack(f.authorLogin)}` : '';
110
112
  const head = `*${ranBy} Review ran* on ${prLine(ctx, workspaceId, f)}${by}`;
111
113
  const lines = [head];
@@ -161,6 +163,8 @@ export async function onClaudeReviewCompleted(ctx, e) {
161
163
  summary: cr.summary,
162
164
  reviewMode: cr.reviewMode,
163
165
  trigger: cr.trigger,
166
+ // A Claude + Codex pair announces its ONE review (pair-view.ts): the consolidation on its first row.
167
+ ...(cr.pairId != null ? { pairId: cr.pairId } : {}),
164
168
  // The provider that ran it: the stored `provider` (Phase 4, sqlite 0128), else read from the
165
169
  // model id (aiRunProvider). A schema without the columns reads as Claude.
166
170
  ...(cr.model != null ? { model: cr.model } : {}),
@@ -177,10 +181,30 @@ export async function onClaudeReviewCompleted(ctx, e) {
177
181
  // The STORED trigger agrees, or nothing is sent (no claim either).
178
182
  if (run.trigger !== 'auto')
179
183
  return;
184
+ // A PAIR: its one summary and verdict, named "Claude + Codex" (an older pair reads as this half).
185
+ let verdict = run.verdict;
186
+ let summary = run.summary;
187
+ let ranBy = aiRunProviderName(run);
188
+ if (run.pairId != null) {
189
+ const rec = (await ctx.db
190
+ .select({ pairConsolidation: cr.pairConsolidation })
191
+ .from(cr)
192
+ .where(and(eq(cr.id, run.pairId), eq(cr.accountId, e.accountId), eq(cr.prId, e.prId)))
193
+ .limit(1)
194
+ .execute())[0]?.pairConsolidation;
195
+ if (rec != null && typeof rec === 'object') {
196
+ if (typeof rec.summary === 'string')
197
+ summary = rec.summary;
198
+ if (rec.verdict === 'APPROVE' || rec.verdict === 'REQUEST_CHANGES' || rec.verdict === 'COMMENT')
199
+ verdict = rec.verdict;
200
+ if (rec.how !== 'single')
201
+ ranBy = PAIR_RUN_LABEL;
202
+ }
203
+ }
180
204
  if (!(await claimNotice(ctx, e.accountId, 'review', e.reviewId)))
181
205
  return;
182
206
  try {
183
- await postToSlack(target.row.webhookUrl, reviewMessage(ctx, target.workspaceId, facts, run.verdict, run.summary, aiRunProviderName(run)));
207
+ await postToSlack(target.row.webhookUrl, reviewMessage(ctx, target.workspaceId, facts, verdict, summary, ranBy));
184
208
  }
185
209
  catch (err) {
186
210
  ctx.log.warn({ err, accountId: e.accountId, workspaceId: target.workspaceId, reviewId: e.reviewId }, 'slack review signal send failed');
@@ -30,6 +30,7 @@ export function buildAgentContext(log) {
30
30
  const r = detectProviderAuth(provider, kind);
31
31
  return r.status === 'ok' ? { status: 'ok' } : { status: 'none', message: r.message };
32
32
  },
33
+ complete: async (opts) => (await import('./llm.js')).cheapComplete(opts),
33
34
  },
34
35
  recordAiUsage: (row) => recordAiUsage(row),
35
36
  github: {
@@ -62,10 +62,13 @@ import {} from '../../shared/index.js';
62
62
  import { readWorkspaceAutoPostForPr } from './auto-settings.js';
63
63
  import { similarTitles } from './settled-by-reply.js';
64
64
  import { prAuthorIsAccount } from '../../coding/ai-fix/auto-fix.js';
65
- import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, } from './persist.js';
65
+ import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, readPairConsolidation, } from './persist.js';
66
66
  import { resolveFindingBody } from './follow-up.js';
67
- import { aiRunProviderName, autoPostFooter, limnSeverityLabel, PAIR_AUTO_POST_FOOTER } from '../../shared/index.js';
67
+ import { autoVerdictFor } from './auto-verdict.js';
68
+ import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
68
69
  import { pairRaisedByLead, pairReviewBody, pairVerdict } from './pair.js';
70
+ // A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
71
+ const PAIR_BODY_BY = 'Claude and Codex';
69
72
  /* eslint-disable @typescript-eslint/no-explicit-any */
70
73
  const s = (ctx) => ctx.schema;
71
74
  /** May auto-posting write on this PR? null = yes; else why not. */
@@ -143,21 +146,8 @@ export function selectFindingsToPost(findings, kinds, earlier) {
143
146
  }
144
147
  return { toPost, alreadyPosted };
145
148
  }
146
- /**
147
- * The verdict an auto post wants — Claude's, made STRICTER. Counts the run's non-story findings
148
- * a reader did not ignore (whatever the kind toggles say): any blocker ⇒ REQUEST_CHANGES; APPROVE only when
149
- * Claude approved and there is no warning either; everything else ⇒ COMMENT.
150
- */
151
- export function autoVerdictFor(claudeVerdict, findings) {
152
- // ⚠ Everything except a READER'S ignore counts — a re-raise the server left out because its
153
- // comment is already on this commit is still an open issue (persist.ts `isReaderIgnoredFinding`).
154
- const counted = findings.filter((f) => f.story == null && !isReaderIgnoredFinding(f));
155
- if (counted.some((f) => f.severity === 'blocker'))
156
- return 'REQUEST_CHANGES';
157
- if (claudeVerdict === 'APPROVE' && !counted.some((f) => f.severity === 'warning'))
158
- return 'APPROVE';
159
- return 'COMMENT';
160
- }
149
+ // THE VERDICT RULE (./auto-verdict.ts), re-exported under its old name.
150
+ export { autoVerdictFor } from './auto-verdict.js';
161
151
  /** May an automatic APPROVE go out at this impact level? ⚠ 'unknown' is judged as HIGH. */
162
152
  export function impactAllowsApprove(level, allowed) {
163
153
  return allowed[level === 'unknown' ? 'high' : level];
@@ -355,9 +345,13 @@ export async function loadUnpostedBlockersOfBlock(ctx, accountId, prId, githubRe
355
345
  .from(cr)
356
346
  .where(and(eq(cr.prId, prId), eq(cr.accountId, accountId)))
357
347
  .execute());
358
- const blockRun = runs.find((r) => r.autoPost?.githubReviewId != null && String(r.autoPost.githubReviewId) === githubReviewId);
348
+ // Every run whose record carries this GitHub review: a pair's combined post is recorded on BOTH
349
+ // halves, and either half may hold the blocker that was posted (or merged away).
350
+ const blockRunIds = runs
351
+ .filter((r) => r.autoPost?.githubReviewId != null && String(r.autoPost.githubReviewId) === githubReviewId)
352
+ .map((r) => r.id);
359
353
  // No such run: the caller only passes ids `loadLimnAutoBlockIds` read, so this is a race — hold.
360
- if (!blockRun)
354
+ if (blockRunIds.length === 0)
361
355
  return [-1];
362
356
  const rows = (await ctx.db
363
357
  .select({
@@ -371,7 +365,9 @@ export async function loadUnpostedBlockersOfBlock(ctx, accountId, prId, githubRe
371
365
  storyRef: crf.storyRef,
372
366
  })
373
367
  .from(crf)
374
- .where(eq(crf.reviewId, blockRun.id))
368
+ // A pair's merged-away duplicate is never posted and never read as a finding: its kept finding
369
+ // stands for it.
370
+ .where(and(inArray(crf.reviewId, blockRunIds), isNull(crf.mergedInto)))
375
371
  .execute());
376
372
  const onGithub = new Map(earlier.map((e) => [e.id, e.onGithub]));
377
373
  return rows
@@ -491,9 +487,10 @@ async function writeRecord(ctx, reviewId, rec) {
491
487
  const cr = s(ctx).claudeReviews;
492
488
  await ctx.db.update(cr).set({ autoPost: rec }).where(eq(cr.id, reviewId)).execute();
493
489
  }
494
- // `inPair`: the comment rides a Claude + Codex pair's one review, so it opens by naming who raised it
495
- // (story findings are never auto-posted, so the lead slot is free).
496
- const toPostFinding = (f, by, inPair = false) => ({
490
+ // `tag`: the comment rides a Claude + Codex pair's one review, so it names who raised it — "Claude",
491
+ // "Codex" or "Claude + Codex" (`findingSourceLabel`); null on an ordinary run. (Story findings are never
492
+ // auto-posted, so the lead slot is free.) `by` is the provider whose run holds the finding (its footer).
493
+ const toPostFinding = (f, by, tag = null) => ({
497
494
  id: f.id,
498
495
  path: f.path,
499
496
  line: f.line,
@@ -505,8 +502,8 @@ const toPostFinding = (f, by, inPair = false) => ({
505
502
  severity: f.severity,
506
503
  // A pair's comment names who raised it ON the ONE severity lead ("**Warning** · Codex"); a finding
507
504
  // with no label word (none posted today) falls back to its own "Raised by" line.
508
- severityTag: inPair ? by : null,
509
- storyLead: inPair && limnSeverityLabel(f.severity) == null ? pairRaisedByLead(by) : null,
505
+ severityTag: tag,
506
+ storyLead: tag != null && limnSeverityLabel(f.severity) == null ? pairRaisedByLead(tag) : null,
510
507
  // The footer names the provider that ran the review (Claude's text unchanged).
511
508
  footer: autoPostFooter(by),
512
509
  });
@@ -731,6 +728,15 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
731
728
  first = null;
732
729
  }
733
730
  const pairFirst = first;
731
+ // THE PAIR'S ONE REVIEW (pair-consolidate.ts): its summary, and who raised each kept finding. A pair
732
+ // from before the record reads as the two summaries joined and each half's own provider.
733
+ const consolidation = pairFirst != null ? await readPairConsolidation(ctx, pairFirst.id, prId).catch(() => null) : null;
734
+ const tagOf = (f) => {
735
+ if (pairFirst == null)
736
+ return null;
737
+ const src = consolidation?.sourceOf[String(f.id)];
738
+ return src != null ? findingSourceLabel(src) : (byOf.get(f.id) ?? aiRunProviderName(review));
739
+ };
734
740
  const posted = new Set();
735
741
  const errors = [];
736
742
  const submit = (ev) => ctx.review.postReview({
@@ -739,17 +745,20 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
739
745
  prNumber: pctx.prNumber,
740
746
  reviewHeadSha: pctx.reviewHeadSha,
741
747
  body: pairFirst != null
742
- ? pairReviewBody([pairFirst, review].map((r) => {
743
- const mine = new Set(r.findings.map((x) => x.id));
744
- const n = picked.toPost.filter((f) => mine.has(f.id)).length;
745
- return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
746
- }), PAIR_AUTO_POST_FOOTER)
748
+ ? consolidation?.how === 'pass'
749
+ ? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${PAIR_AUTO_POST_FOOTER}`
750
+ : // Joined in code (or an older pair): each half's lead, named.
751
+ pairReviewBody([pairFirst, review].map((r) => {
752
+ const mine = new Set(r.findings.map((x) => x.id));
753
+ const n = picked.toPost.filter((f) => mine.has(f.id)).length;
754
+ return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
755
+ }), PAIR_AUTO_POST_FOOTER)
747
756
  : autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review)),
748
757
  // COMMENT unless auto verdict is on AND its gate let the verdict through.
749
758
  verdict: ev,
750
759
  // Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
751
760
  // comment carries its own provider's footer and names who raised it.
752
- includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review), pairFirst != null)),
761
+ includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review), tagOf(f))),
753
762
  dryRun: false,
754
763
  });
755
764
  // Did the review half go wrong outright (thrown, or the head moved)? Then nothing more is written.
@@ -0,0 +1,17 @@
1
+ import { isReaderIgnoredFinding } from './finding-facts.js';
2
+ /**
3
+ * The verdict an auto post wants — Claude's, made STRICTER. Counts the run's non-story findings
4
+ * a reader did not ignore (whatever the kind toggles say): any blocker ⇒ REQUEST_CHANGES; APPROVE only when
5
+ * Claude approved and there is no warning either; everything else ⇒ COMMENT.
6
+ */
7
+ export function autoVerdictFor(claudeVerdict, findings) {
8
+ // ⚠ Everything except a READER'S ignore counts — a re-raise the server left out because its
9
+ // comment is already on this commit is still an open issue (`isReaderIgnoredFinding`).
10
+ const counted = findings.filter((f) => f.story == null && !isReaderIgnoredFinding(f));
11
+ if (counted.some((f) => f.severity === 'blocker'))
12
+ return 'REQUEST_CHANGES';
13
+ if (claudeVerdict === 'APPROVE' && !counted.some((f) => f.severity === 'warning'))
14
+ return 'APPROVE';
15
+ return 'COMMENT';
16
+ }
17
+ //# sourceMappingURL=auto-verdict.js.map
@@ -2,7 +2,7 @@ import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
3
3
  import { requestModelOf } from '../../ai/provider.js';
4
4
  import { AGENTIC_AI_ENABLED, chatMayApplyAuthEnv } from './manager.js';
5
- import { getClaudeReviewById, getReviewPrContext } from './persist.js';
5
+ import { getReviewPrContext, getReviewView } from './persist.js';
6
6
  import { pickReviewNonce } from './prompts.js';
7
7
  import { EXPLAIN_INSTRUCTION, EXPLAIN_SYSTEM_ADDENDUM, explainSectionLines, explanationsMarkdown, findingPinLabel, findingPinText, parsePinRefs, questionWithPins, storyPinLabel, storyPinText, validateExplanations, } from './chat-explain.js';
8
8
  import { providerName } from '../../ai/prompt-tools.js';
@@ -389,7 +389,8 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
389
389
  }
390
390
  inFlight.set(accountId, reviewId);
391
391
  try {
392
- const review = await getClaudeReviewById(ctx, reviewId, accountId);
392
+ // A Claude + Codex pair is asked about as its ONE review (both halves' findings).
393
+ const review = await getReviewView(ctx, reviewId, accountId);
393
394
  if (!review) {
394
395
  return { ok: false, status: 404, error: 'NotFound', message: `Review ${reviewId} not found` };
395
396
  }
@@ -611,7 +612,8 @@ export function registerClaudeReviewChatRoutes(app, ctx) {
611
612
  const { reviewId } = req.params;
612
613
  const findingId = req.query.findingId ?? null;
613
614
  const accountId = ctx.accountIdOf(req);
614
- const review = await getClaudeReviewById(ctx, reviewId, accountId);
615
+ // A Claude + Codex pair is asked about as its ONE review (both halves' findings).
616
+ const review = await getReviewView(ctx, reviewId, accountId);
615
617
  if (!review)
616
618
  return notFound(reply, `Review ${reviewId}`);
617
619
  if (findingId != null && !review.findings.some((f) => f.id === findingId)) {
@@ -0,0 +1,27 @@
1
+ // PURE FACTS ABOUT A STORED FINDING, shared by persist.ts, the verdict rule (./auto-verdict.ts) and
2
+ // the pair's consolidated view (./pair-view.ts). No imports, so any of them may read these without a
3
+ // cycle. persist.ts re-exports all three under their old names.
4
+ /**
5
+ * The server's own left-out re-raise: `included: false` stored on a RE-RAISE (a `priorFindingId`)
6
+ * of a comment already posted on this same commit (follow-up.ts `isAlreadyOnThisCommit`, written
7
+ * by persist.ts) — an issue still OPEN, just not posted twice.
8
+ */
9
+ export function isAlreadyPostedReraise(f) {
10
+ return f.included === false && f.postedAt == null && f.priorFindingId != null;
11
+ }
12
+ /**
13
+ * `included = false` is TWO facts, and only one is a READER'S IGNORE — every reader of the flag
14
+ * meaning "the reader ignored it" goes through this, never a bare `included === false`.
15
+ */
16
+ export function isReaderIgnoredFinding(f) {
17
+ return f.included === false && !isAlreadyPostedReraise(f);
18
+ }
19
+ /**
20
+ * A CLAUDE + CODEX PAIR'S MERGED-AWAY DUPLICATE (`claude_review_findings.merged_into`, migration
21
+ * 0137): the other half's finding stands for it. It stays stored but is never shown, posted, counted
22
+ * in a verdict or sent to AI Fix — every read of a run's findings leaves it out.
23
+ */
24
+ export function isMergedAway(f) {
25
+ return f.mergedInto != null;
26
+ }
27
+ //# sourceMappingURL=finding-facts.js.map
@@ -8,6 +8,8 @@ import { decideReviewMode } from './routing.js';
8
8
  import { offeredSpecialists } from './specialists.js';
9
9
  import { buildUserPrompt, peerRef, pickReviewNonce, skipSummary, sonarHasContent, systemPromptForMode, untrustedTexts, } from './prompts.js';
10
10
  import { dropPairRepeats, pairInputOf, pairProvidersReady, readAutoReviewProvidersForPr } from './pair.js';
11
+ import { consolidatePair, notStartedHalf } from './pair-consolidate.js';
12
+ import { markPairBusy } from './pair-view.js';
11
13
  import { preflightFor } from '../../ai/run-choice.js';
12
14
  import { linkReraisedFindings, reconcileFollowUp, dropAcceptedReraises, isResolvedWithReplies, selectPriorFindings, SINCE_PATCH_CHARS, } from './follow-up.js';
13
15
  import { planThreadReview, reconcileThreads } from './threads.js';
@@ -404,11 +406,15 @@ function launch(item) {
404
406
  if (item.teamClaim)
405
407
  startTeamClaimRenew(ctx, item.teamClaim);
406
408
  void runPipeline(item, controller, emit)
407
- .then((succeeded) => {
409
+ .then(async (succeeded) => {
408
410
  item.succeeded = succeeded;
409
411
  // A pair's FIRST half hands over to its second; everything after a run waits for the pair's end.
410
412
  if (pairContinues(item))
411
413
  return;
414
+ // ⚠ A PAIR'S END: the two halves become ONE review BEFORE anything is posted (pair-consolidate.ts).
415
+ // Still inside the run (slot, claim and stream held), so the tab shows "Merging the two reviews…".
416
+ if (item.pair?.role === 'second')
417
+ await consolidateAtPairEnd(item, emit);
412
418
  item.autoPosted = runEnded(item, endOf(item, succeeded));
413
419
  })
414
420
  .catch(async (err) => {
@@ -416,9 +422,12 @@ function launch(item) {
416
422
  ctx.log.error({ err }, `claude review pr ${prId} failed: ${err instanceof Error ? err.message : String(err)}`);
417
423
  await markReviewFailed(ctx, reviewId, err instanceof Error ? err.message : String(err)).catch(() => { });
418
424
  item.succeeded = false;
419
- // A pair's second half failing this way still ends the pair: the first half's results stand.
420
- if (item.pair?.role === 'second')
425
+ // A pair's second half failing this way still ends the pair: the first half's results stand,
426
+ // consolidated as the pair's one review.
427
+ if (item.pair?.role === 'second') {
428
+ await consolidateAtPairEnd(item, emit);
421
429
  item.autoPosted = runEnded(item, endOf(item, false));
430
+ }
422
431
  })
423
432
  .finally(() => {
424
433
  // ⚠ A pair's first half keeps the PR's claim and its slot (`startingPair`, taken BEFORE the
@@ -435,8 +444,11 @@ function launch(item) {
435
444
  reviewIdByPr.delete(prId);
436
445
  controllers.delete(reviewId);
437
446
  progressByReview.delete(reviewId);
438
- if (continues)
447
+ if (continues) {
439
448
  startingPair.set(prId, item);
449
+ // The pair reads as running until it ends (pair-view.ts), never as its first half alone.
450
+ markPairBusy(item.pair.pairId, true);
451
+ }
440
452
  else {
441
453
  claimed.delete(prId);
442
454
  emitDone(ctx, prId, item.accountId, reviewId);
@@ -447,6 +459,29 @@ function launch(item) {
447
459
  void startPairSecond(item);
448
460
  });
449
461
  }
462
+ /**
463
+ * A pair's end, on its second half (succeeded, failed or stopped): consolidate the two into ONE review
464
+ * and store it (pair-consolidate.ts) — with the quick pass when both succeeded ("Merging the two
465
+ * reviews…"), else in code. Marked "merging" meanwhile, so every read shows the pair as running.
466
+ * Never throws: a consolidation failure never fails the pair.
467
+ */
468
+ async function consolidateAtPairEnd(item, emit) {
469
+ const p = item.pair;
470
+ if (p?.role !== 'second')
471
+ return;
472
+ markPairBusy(p.pairId, true);
473
+ try {
474
+ if (p.firstSucceeded && item.succeeded)
475
+ emit({ phase: 'merging' });
476
+ await consolidatePair(item.ctx, { accountId: item.accountId, prId: item.prId, pairId: p.pairId, secondId: item.reviewId });
477
+ }
478
+ catch (err) {
479
+ item.ctx.log.warn(`claude review pr ${item.prId}: consolidating the pair failed: ${err instanceof Error ? err.message : String(err)}`);
480
+ }
481
+ finally {
482
+ markPairBusy(p.pairId, false);
483
+ }
484
+ }
450
485
  function emitDone(ctx, prId, accountId, reviewId) {
451
486
  void getLatestClaudeReview(ctx, prId, accountId)
452
487
  .then((latest) => emitReviewStream(prId, { type: 'done', status: latest?.status ?? 'failed', reviewId }))
@@ -578,8 +613,11 @@ async function startPairSecond(first) {
578
613
  const provider = pair.secondProvider;
579
614
  let launched = false;
580
615
  let teamClaim = null;
616
+ // Why the second half did not start (the pair's one review names it).
617
+ let why = 'error';
581
618
  try {
582
619
  if (first.trigger === 'auto' && !pair.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review (second half)'))) {
620
+ why = 'budget';
583
621
  return;
584
622
  }
585
623
  // TEAM CLAIMS: an AUTO pair's second half claims its OWN provider's context. A teammate's Limn
@@ -594,8 +632,10 @@ async function startPairSecond(first) {
594
632
  headSha: first.headSha,
595
633
  provider,
596
634
  }, { pressed: pair.pressed === true });
597
- if (claim.kind === 'skip')
635
+ if (claim.kind === 'skip') {
636
+ why = 'claimed';
598
637
  return;
638
+ }
599
639
  teamClaim = claim.kind === 'run' ? claim.handle : null;
600
640
  }
601
641
  const firstReview = first.succeeded ? await getClaudeReviewById(ctx, first.reviewId, accountId) : null;
@@ -605,6 +645,8 @@ async function startPairSecond(first) {
605
645
  await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
606
646
  startingPair.delete(prId);
607
647
  launched = true;
648
+ // The second half's row is running now; the end (consolidateAtPairEnd) marks the merge itself.
649
+ markPairBusy(pair.pairId, false);
608
650
  launch({
609
651
  ctx,
610
652
  accountId,
@@ -623,7 +665,7 @@ async function startPairSecond(first) {
623
665
  first,
624
666
  firstSucceeded: first.succeeded === true,
625
667
  input: firstFindings.length > 0 ? pairInputOf(first.provider, firstFindings) : null,
626
- firstFindings: firstFindings.map((f) => ({ path: f.path, line: f.line, title: f.title })),
668
+ firstFindings: firstFindings.map((f) => ({ path: f.path, line: f.line, title: f.title, priorFindingId: f.priorFindingId ?? null })),
627
669
  },
628
670
  });
629
671
  }
@@ -632,11 +674,18 @@ async function startPairSecond(first) {
632
674
  }
633
675
  finally {
634
676
  if (!launched) {
635
- // The pair ends on its first half.
677
+ // The pair ends on its first half: stored as the pair's one review (naming the half that did
678
+ // not start) BEFORE the end is announced and anything is posted.
636
679
  startingPair.delete(prId);
637
680
  claimed.delete(prId);
638
- emitDone(ctx, prId, accountId, first.reviewId);
639
- first.autoPosted = runEnded(first, { reviewId: first.reviewId, succeeded: first.succeeded === true });
681
+ const consolidated = first.succeeded === true
682
+ ? consolidatePair(ctx, { accountId, prId, pairId: pair.pairId, secondId: null, notStarted: notStartedHalf(provider, why) })
683
+ : Promise.resolve(null);
684
+ first.autoPosted = consolidated.then(() => {
685
+ markPairBusy(pair.pairId, false);
686
+ emitDone(ctx, prId, accountId, first.reviewId);
687
+ return runEnded(first, { reviewId: first.reviewId, succeeded: first.succeeded === true });
688
+ });
640
689
  // TEAM CLAIMS: a claim this half posted before its row could be written is released, and the
641
690
  // first half's held line gets its final one.
642
691
  if (teamClaim)
@@ -699,7 +748,7 @@ async function runPipeline(item, controller, emit) {
699
748
  const settledIds = new Set(settled.map((f) => f.id));
700
749
  const prior = fresh
701
750
  ? null
702
- : await loadPriorReviewForFollowUp(ctx, item.prId, item.accountId, reviewId, settledIds, item.provider);
751
+ : await loadPriorReviewForFollowUp(ctx, item.prId, item.accountId, reviewId, settledIds, item.provider, item.pair != null);
703
752
  // BACKWARD COMPATIBILITY: findings the RETIRED settle-on-sight rule took out of the chain were
704
753
  // never judged. Load them as carried items; only the ones whose thread is resolved WITH a
705
754
  // person's reply are kept (below), so they are judged once like any other.
@@ -14,7 +14,7 @@
14
14
  // re-claim of a refused record (local-only feature: one process).
15
15
  import { and, eq, isNull } from 'drizzle-orm';
16
16
  import { FINDING_REPLY_MARKER, } from '../../shared/index.js';
17
- import { getClaudeReviewById } from './persist.js';
17
+ import { getReviewView } from './persist.js';
18
18
  import { findFindingThread } from './finding-thread.js';
19
19
  import { PUSHBACK_MARKER, accountLoginOf, isClearReplyRefusal, loadPrThreads, loadThreadOwners, pushbackMayBePosted, resolveReplyMayBePosted, threadHasPushback, } from './finding-replies.js';
20
20
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -68,7 +68,8 @@ const inFlight = new Set(); // thread OWNER finding ids
68
68
  const conflict = (code, message) => ({ kind: 'conflict', code, message });
69
69
  /** Post the reply a reply status drafted, on that finding's thread. Never posts twice. */
70
70
  export async function postFollowUpReply(ctx, { accountId, reviewId, priorFindingId }, deps = defaultManualReplyDeps) {
71
- const review = await getClaudeReviewById(ctx, reviewId, accountId);
71
+ // A Claude + Codex pair is one review: its follow-up holds both halves' items (pair-view.ts).
72
+ const review = await getReviewView(ctx, reviewId, accountId);
72
73
  if (!review || review.status !== 'succeeded')
73
74
  return { kind: 'not_found' };
74
75
  const item = review.followUp?.items.find((it) => it.priorFindingId === priorFindingId);