limn-review 0.1.148 → 0.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ai/budget.js CHANGED
@@ -45,7 +45,7 @@
45
45
  //
46
46
  // One budget per ACCOUNT (= per machine), never per workspace.
47
47
  import { and, eq, gte, inArray } from 'drizzle-orm';
48
- import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
48
+ import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, budgetSettingsInForce, rollingSpend, } from '../shared/index.js';
49
49
  import { db, schema } from '../db/client.js';
50
50
  import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
51
51
  import { getActiveProvider, getProvider } from './provider.js';
@@ -288,7 +288,10 @@ export async function aiBudgetSnapshot(accountId, opts = {}) {
288
288
  // the ledger — the named one (a manual run's per-run choice), else the default (active) one.
289
289
  const provider = opts.provider ? getProvider(opts.provider) : getActiveProvider();
290
290
  const route = provider.credentialRoute();
291
- const { settings, isDefault } = await readAiBudgetSettings(accountId);
291
+ const stored = await readAiBudgetSettings(accountId);
292
+ const isDefault = stored.isDefault;
293
+ // On by default (a share of the week); a never-set row on an API key applies no US$ cap.
294
+ const settings = budgetSettingsInForce(stored.settings, isDefault, route);
292
295
  const probe = opts.probe ?? 'when_on';
293
296
  if (route === 'login' && (probe === 'always' || (probe === 'when_on' && settings.enabled)))
294
297
  kickUsageProbe(nowMs, provider);
@@ -0,0 +1,14 @@
1
+ -- THE AUTO VERDICT'S TWO RULES + THE NOT-APPROVED REASON (docs/CLAUDE-REVIEW.md § Auto-posting →
2
+ -- AUTO VERDICT). Both workspace columns are OVERRIDES ONLY: NULL = today's rule.
3
+ -- `pair_verdict_strictness`: how a Claude + Codex pair's verdict is folded — NULL / 'strict' = approve
4
+ -- only when both approved; 'relaxed' = approve when one approved and neither asked for changes.
5
+ ALTER TABLE `workspaces` ADD `pair_verdict_strictness` text;
6
+ --> statement-breakpoint
7
+ -- `approve_leftovers`: which open findings may remain when Limn approves — NULL / 'nits_questions' =
8
+ -- nits and questions; 'nits' = nits only; 'none' = nothing open at all.
9
+ ALTER TABLE `workspaces` ADD `approve_leftovers` text;
10
+ --> statement-breakpoint
11
+ -- A REPLIES PASS's not-approved reason ({heldReason, text}), written when the pass ends while the
12
+ -- workspace's auto verdict is on. NULL on every other row (a full review's reason is on its
13
+ -- `auto_post` record, or computed on read).
14
+ ALTER TABLE `claude_reviews` ADD `verdict_reason` text;
@@ -1016,6 +1016,13 @@
1016
1016
  "when": 1792137640016,
1017
1017
  "tag": "0144_workspace_tracker_notion",
1018
1018
  "breakpoints": true
1019
+ },
1020
+ {
1021
+ "idx": 145,
1022
+ "version": "6",
1023
+ "when": 1792137641016,
1024
+ "tag": "0145_verdict_strictness",
1025
+ "breakpoints": true
1019
1026
  }
1020
1027
  ]
1021
1028
  }
@@ -1123,6 +1123,11 @@ export const claudeReviews = sqliteTable('claude_reviews', {
1123
1123
  // people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
1124
1124
  // ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
1125
1125
  kind: text('kind', { enum: ['replies'] }),
1126
+ // A REPLIES PASS's not-approved reason (migration 0145; review/claude-review/replies-outcome.ts):
1127
+ // `{heldReason, text}`, written when the pass ends while the workspace's auto verdict is on and
1128
+ // the outcome is not an approval. NULL on every full review (its reason rides `auto_post`, or is
1129
+ // computed on read).
1130
+ verdictReason: text('verdict_reason', { mode: 'json' }).$type(),
1126
1131
  }, (t) => ({
1127
1132
  prIdx: index('cr_pr_idx').on(t.prId),
1128
1133
  prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
@@ -1808,6 +1813,13 @@ export const workspaces = sqliteTable('workspaces', {
1808
1813
  // Claude then Codex as one pair (docs/CLAUDE-REVIEW.md § Claude + Codex). The ONE writer is
1809
1814
  // `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
1810
1815
  autoReviewProviders: text('auto_review_providers'),
1816
+ // THE AUTO VERDICT'S TWO RULES (migration 0145; docs/CLAUDE-REVIEW.md § Auto-posting → AUTO
1817
+ // VERDICT), OVERRIDES ONLY. `pairVerdictStrictness`: NULL = 'strict' (a Claude + Codex pair approves
1818
+ // only when both approved), 'relaxed' (one approved and neither asked for changes).
1819
+ // `approveLeftovers`: NULL = 'nits_questions' (nits and questions may remain open on an approve),
1820
+ // 'nits', 'none'. The ONE writer is `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
1821
+ pairVerdictStrictness: text('pair_verdict_strictness'),
1822
+ approveLeftovers: text('approve_leftovers'),
1811
1823
  // DEPENDENCY AUTO-MERGE (migration 0093 / pg 0080): may the server arm "merge when ready" on
1812
1824
  // every open dependency-automation PR in this workspace's repos (merge/dependency-policy.ts)?
1813
1825
  // NULL/false = OFF, the default for every workspace; nullable with no default, so no insert
@@ -37,8 +37,15 @@
37
37
  // (`unsettledEarlierBlockers`) and none of the blocking run's own blockers that never reached
38
38
  // GitHub (`loadUnpostedBlockersOfBlock` — never followed up, so never judged). Otherwise no
39
39
  // dismissal, and an APPROVE over the block is held as 'already_requested';
40
- // 4. nothing to post and nothing but COMMENT to send ⇒ `skipped` — `already_posted` (with the
41
- // ids) when the dedupe alone emptied it, else `nothing_new` — no GitHub write;
40
+ // 3c. THE REASON (only with `autoVerdict` on): a run that does not end in an APPROVE gets ONE
41
+ // plain-English "Not approved: …" line (`findingsNotApproved` + the gate's `heldReason`,
42
+ // ./auto-verdict.ts), stored on the verdict record (`reasonText`). Posted findings carry it as the
43
+ // review body's last line before the footer; with NOTHING else to post it goes out as ONE short
44
+ // COMMENT review of its own — at most once per (PR, head): an earlier record at this head with
45
+ // `reasonPosted` (a full review's or a replies pass's) means nothing more is posted;
46
+ // 4. nothing to post, nothing but COMMENT to send and no reason to post ⇒ `skipped` —
47
+ // `already_posted` (with the ids) when the dedupe alone emptied it, else `nothing_new` — no
48
+ // GitHub write;
42
49
  // 5. the LIVE PR is still open, not a draft, at the reviewed head (a moved head ⇒ `failed`);
43
50
  // 6. THE CLAIM — a compare-and-set of `claude_reviews.auto_post` from NULL to `status: 'posting'`
44
51
  // BEFORE any write, so a second call (or a restart) can never post the same run twice;
@@ -65,9 +72,9 @@ import { similarTitles } from './settled-by-reply.js';
65
72
  import { prAuthorIsAccount } from '../../coding/ai-fix/auto-fix.js';
66
73
  import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, readPairConsolidation, } from './persist.js';
67
74
  import { resolveFindingBody } from './follow-up.js';
68
- import { autoVerdictFor } from './auto-verdict.js';
75
+ import { autoVerdictFor, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
69
76
  import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
70
- import { pairRaisedByLead, pairReviewBody, pairVerdict } from './pair.js';
77
+ import { pairRaisedByLead, pairReviewBody } from './pair.js';
71
78
  // A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
72
79
  const PAIR_BODY_BY = 'Claude and Codex';
73
80
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -245,9 +252,28 @@ export function autoReviewBodyLead(summary, commentCount, by = 'Claude') {
245
252
  return `Reviewed by ${by}.`;
246
253
  return commentCount === 1 ? `One comment from ${by}.` : `${commentCount} comments from ${by}.`;
247
254
  }
248
- /** The posted body of an auto review: the lead, then the footer (the seam appends the marker). */
249
- export function autoReviewBody(summary, commentCount, by = 'Claude') {
250
- return `${autoReviewBodyLead(summary, commentCount, by)}\n\n${autoPostFooter(by)}`;
255
+ /** The posted body of an auto review: the lead, then the not-approved reason when there is one, then
256
+ * the footer (the seam appends the marker). */
257
+ export function autoReviewBody(summary, commentCount, by = 'Claude', reason = null) {
258
+ return `${autoReviewBodyLead(summary, commentCount, by)}\n\n${withReasonLine(reason, autoPostFooter(by))}`;
259
+ }
260
+ /** The footer, led by the not-approved reason as its own line when there is one. */
261
+ export function withReasonLine(reason, footer) {
262
+ return reason ? `${safeReasonText(reason)}\n\n${footer}` : footer;
263
+ }
264
+ /**
265
+ * Has a not-approved reason already gone to GitHub at this head (any run of the PR other than
266
+ * `exceptIds`, full review or replies pass — a record whose `reasonPosted` is set, whatever became of
267
+ * it: never retried)? A reason-only review goes out at most once per (PR, head).
268
+ */
269
+ export async function reasonPostedAtHead(ctx, accountId, prId, headSha, exceptIds) {
270
+ const cr = s(ctx).claudeReviews;
271
+ const rows = (await ctx.db
272
+ .select({ id: cr.id, autoPost: cr.autoPost })
273
+ .from(cr)
274
+ .where(and(eq(cr.prId, prId), eq(cr.accountId, accountId), eq(cr.headSha, headSha)))
275
+ .execute());
276
+ return rows.some((r) => !exceptIds.includes(r.id) && r.autoPost?.reasonPosted != null && r.autoPost.reasonPosted !== '');
251
277
  }
252
278
  export const defaultAutoPostDeps = {
253
279
  async livePr(owner, name, prNumber) {
@@ -416,6 +442,7 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
416
442
  deps.accountUserId(accountId),
417
443
  ]);
418
444
  let requestedOfMe = false;
445
+ let openRequestOfMe = false;
419
446
  if (me != null) {
420
447
  const [open, history] = await Promise.all([
421
448
  ctx.db
@@ -431,7 +458,8 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
431
458
  .limit(1)
432
459
  .execute(),
433
460
  ]);
434
- requestedOfMe = open.length > 0 || history.length > 0;
461
+ openRequestOfMe = open.length > 0;
462
+ requestedOfMe = openRequestOfMe || history.length > 0;
435
463
  }
436
464
  let participatedByMe = false;
437
465
  if (me != null && !authorIsMe && !requestedOfMe) {
@@ -445,7 +473,7 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
445
473
  const hits = await Promise.all([by(t.reviews), by(t.reviewComments), by(t.prComments)]);
446
474
  participatedByMe = hits.some((h) => h.length > 0);
447
475
  }
448
- return { state: pr.state, isDraft: pr.isDraft === true, authorIsBot, authorIsMe, requestedOfMe, participatedByMe };
476
+ return { state: pr.state, isDraft: pr.isDraft === true, authorIsBot, authorIsMe, requestedOfMe, openRequestOfMe, participatedByMe };
449
477
  }
450
478
  /** Every earlier run's findings of this PR (account-scoped), as the dedupe reads them. */
451
479
  export async function loadEarlierFindings(ctx, accountId, prId, beforeReviewId) {
@@ -591,8 +619,13 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
591
619
  for (const f of r.findings)
592
620
  byOf.set(f.id, aiRunProviderName(r));
593
621
  const allFindings = runs.flatMap((r) => r.findings);
622
+ // THE WORKSPACE'S VERDICT RULES (migration 0145): strict / relaxed for a pair, and what may stay open.
623
+ const rules = { pairStrictness: ws.pairStrictness, approveLeftovers: ws.settings.approveLeftovers };
594
624
  // The run's verdict as `autoVerdictFor` reads it: a pair's is both runs' together.
595
- const runVerdict = first ? pairVerdict(first.verdict, review.verdict) : review.verdict;
625
+ const runVerdict = first ? pairVerdict(first.verdict, review.verdict, rules.pairStrictness) : review.verdict;
626
+ // WHY NOT APPROVED, from the findings and the model(s) — only while the auto verdict is on.
627
+ const verdictRuns = runs.map((r) => ({ by: aiRunProviderName(r), verdict: r.verdict, summary: r.summary }));
628
+ const findingsReason = ws.settings.autoVerdict ? findingsNotApproved(verdictRuns, allFindings, rules) : null;
596
629
  // Claim the FIRST half as posted in this pair's review (compare-and-set from NULL): false ⇒ some
597
630
  // other writer has it, and its findings leave this post.
598
631
  const claimFirst = async (rec) => first != null && (await claimRun(ctx, first.id, { ...rec, viaPairReviewId: reviewId }));
@@ -615,6 +648,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
615
648
  await claimFirst(rec);
616
649
  return { kind: 'skipped', reason };
617
650
  };
651
+ const wanted = ws.settings.autoVerdict
652
+ ? autoVerdictFor(runVerdict, allFindings, rules.approveLeftovers)
653
+ : null;
654
+ // A run that never wanted more than a COMMENT is recorded with its reason from the start (shown in
655
+ // Limn whatever happens next).
656
+ if (wanted != null && wanted === 'COMMENT') {
657
+ verdict = withReason(verdictGate('COMMENT', { authorIsMe: false, ownLatest: 'none' }), findingsReason);
658
+ }
618
659
  const facts = await readAutoPostPrFacts(ctx, deps, accountId, ws.workspaceId, prId);
619
660
  if (!facts)
620
661
  return { kind: 'not_found' };
@@ -626,13 +667,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
626
667
  const earlier = await loadEarlierFindings(ctx, accountId, prId, runs[0].id);
627
668
  const picked = selectFindingsToPost(allFindings, ws.settings.kinds, earlier);
628
669
  const nothingReason = () => picked.alreadyPosted.length > 0 ? ['already_posted', picked.alreadyPosted.map((f) => f.id)] : ['nothing_new', undefined];
629
- const wanted = ws.settings.autoVerdict ? autoVerdictFor(runVerdict, allFindings) : null;
630
- if (wanted != null && wanted === 'COMMENT')
631
- verdict = verdictGate('COMMENT', { authorIsMe: facts.authorIsMe, ownLatest: 'none' });
670
+ // A reason-only review goes out at most once per (PR, head): read only when nothing else would post.
671
+ const reasonAlreadyAtHead = ws.settings.autoVerdict && picked.toPost.length === 0
672
+ ? await reasonPostedAtHead(ctx, accountId, prId, review.headSha, runs.map((r) => r.id))
673
+ : false;
632
674
  // LIFTING LIMN'S OWN BLOCK: a run with NO blocker (whatever the auto-verdict switch says now) may
633
675
  // withdraw an earlier AUTOMATIC request for changes of Limn's that still stands. A DB pre-check
634
676
  // first, so an ordinary run costs no extra GitHub read.
635
- const noBlocker = autoVerdictFor(runVerdict, allFindings) !== 'REQUEST_CHANGES';
677
+ const noBlocker = autoVerdictFor(runVerdict, allFindings, rules.approveLeftovers) !== 'REQUEST_CHANGES';
636
678
  // ⚠ "No blocker" from this run's OWN findings is not enough to lift: an earlier blocker the run
637
679
  // did not judge (a 'not_checked' follow-up) is still open as far as anyone knows. (A pair: either
638
680
  // half's.)
@@ -640,8 +682,12 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
640
682
  // (Read for a REQUEST_CHANGES too: the gate must tell Limn's own standing block from a person's.)
641
683
  const limnBlockIds = !facts.authorIsMe ? await loadLimnAutoBlockIds(ctx, accountId, prId, reviewId) : new Set();
642
684
  const mayLift = noBlocker && unjudgedBlockers.length === 0 && limnBlockIds.size > 0;
643
- if (picked.toPost.length === 0 && (wanted == null || wanted === 'COMMENT') && !mayLift)
685
+ // A COMMENT-only run with a reason still to tell GitHub goes on (to the reason-only review).
686
+ // (Never while a review request of the reader is open: any review answers it.)
687
+ const commentReasonToPost = wanted === 'COMMENT' && reasonMayPost(verdict) && !reasonAlreadyAtHead && facts.openRequestOfMe !== true;
688
+ if (picked.toPost.length === 0 && (wanted == null || wanted === 'COMMENT') && !mayLift && !commentReasonToPost) {
644
689
  return skip(...nothingReason());
690
+ }
645
691
  const pctx = await getReviewPostContext(ctx, reviewId, accountId);
646
692
  if (!pctx)
647
693
  return { kind: 'not_found' };
@@ -700,16 +746,27 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
700
746
  if (verdict.submitted === 'APPROVE' && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' && blockersRemain) {
701
747
  verdict = { ...verdict, submitted: 'COMMENT', heldReason: 'already_requested' };
702
748
  }
749
+ verdict = withReason(verdict, findingsReason);
703
750
  }
751
+ // The reason this run tells GitHub (none on an approval, none with the verdict switched off, none
752
+ // for a Limn-only gate reason — own PR, unreadable reviews, a refusal: `reasonMayPost`).
753
+ const reasonNow = () => verdict != null && verdict.submitted !== 'APPROVE' && reasonMayPost(verdict) ? (verdict.reasonText ?? null) : null;
754
+ // NOTHING ELSE TO POST: the reason alone, as one short COMMENT review — once per (PR, head), and
755
+ // never while a review request of the reader is open (the review would answer it).
756
+ const reasonOnly = picked.toPost.length === 0 &&
757
+ (verdict?.submitted ?? 'COMMENT') === 'COMMENT' &&
758
+ reasonNow() != null &&
759
+ !reasonAlreadyAtHead &&
760
+ facts.openRequestOfMe !== true;
704
761
  // Limn's own automatic request for changes still stands, this run has no blocker and none behind
705
762
  // the block is left unjudged: lift it — superseded by the APPROVE this run sends, else dismissed.
706
763
  const liftId = noBlocker && !blockersRemain && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' ? standing.reviewId : null;
707
- if (picked.toPost.length === 0 && (verdict?.submitted ?? 'COMMENT') === 'COMMENT' && liftId == null) {
764
+ if (picked.toPost.length === 0 && (verdict?.submitted ?? 'COMMENT') === 'COMMENT' && liftId == null && !reasonOnly) {
708
765
  return skip(...nothingReason());
709
766
  }
710
767
  const event = verdict?.submitted ?? 'COMMENT';
711
768
  // Nothing but the lift to do: no review is sent at all.
712
- const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT';
769
+ const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT' || reasonOnly;
713
770
  const record = {
714
771
  status: 'posting',
715
772
  at: nowIso(),
@@ -727,6 +784,13 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
727
784
  await claimFirst(rec);
728
785
  return { kind: 'done', record: rec };
729
786
  }
787
+ // The reason rides the review (its last line, or the whole body): recorded as POSSIBLY POSTED from
788
+ // the claim on, so a restart never sends it again at this head.
789
+ if (sendsReview && reasonNow() != null) {
790
+ record.reasonPosted = reasonNow();
791
+ if (reasonOnly)
792
+ record.reasonOnly = true;
793
+ }
730
794
  // ⚠ THE CLAIM, BEFORE ANY WRITE — on THIS run, then (a pair) on its first half. A first half
731
795
  // someone else claimed meanwhile keeps its findings out of this post.
732
796
  if (!(await claimRun(ctx, reviewId, record)))
@@ -749,21 +813,28 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
749
813
  };
750
814
  const posted = new Set();
751
815
  const errors = [];
816
+ // The body. With findings: the lead, the reason line, the footer. Reason only: the reason, the footer.
817
+ const bodyOf = (reason) => {
818
+ const footer = pairFirst != null ? PAIR_AUTO_POST_FOOTER : autoPostFooter(aiRunProviderName(review));
819
+ if (reasonOnly && reason != null)
820
+ return withReasonLine(reason, footer);
821
+ if (pairFirst == null)
822
+ return autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review), reason);
823
+ return consolidation?.how === 'pass'
824
+ ? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${withReasonLine(reason, footer)}`
825
+ : // Joined in code (or an older pair): each half's lead, named.
826
+ pairReviewBody([pairFirst, review].map((r) => {
827
+ const mine = new Set(r.findings.map((x) => x.id));
828
+ const n = picked.toPost.filter((f) => mine.has(f.id)).length;
829
+ return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
830
+ }), withReasonLine(reason, footer));
831
+ };
752
832
  const submit = (ev) => ctx.review.postReview({
753
833
  owner: pctx.owner,
754
834
  name: pctx.name,
755
835
  prNumber: pctx.prNumber,
756
836
  reviewHeadSha: pctx.reviewHeadSha,
757
- body: pairFirst != null
758
- ? consolidation?.how === 'pass'
759
- ? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${PAIR_AUTO_POST_FOOTER}`
760
- : // Joined in code (or an older pair): each half's lead, named.
761
- pairReviewBody([pairFirst, review].map((r) => {
762
- const mine = new Set(r.findings.map((x) => x.id));
763
- const n = picked.toPost.filter((f) => mine.has(f.id)).length;
764
- return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
765
- }), PAIR_AUTO_POST_FOOTER)
766
- : autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review)),
837
+ body: bodyOf(ev === 'APPROVE' ? null : reasonNow()),
767
838
  // COMMENT unless auto verdict is on AND its gate let the verdict through.
768
839
  verdict: ev,
769
840
  // Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
@@ -787,8 +858,10 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
787
858
  // Never on a COMMENT, never on an ambiguous failure (5xx, network, rate limit), never twice.
788
859
  if (event === 'COMMENT' || !isVerdictRefusal(err))
789
860
  throw err;
790
- verdict = { ...verdict, submitted: 'COMMENT', heldReason: 'refused', refusedError: errText(err).slice(0, 300) };
861
+ verdict = withReason({ ...verdict, submitted: 'COMMENT', heldReason: 'refused', refusedError: errText(err).slice(0, 300) }, findingsReason);
791
862
  record.verdict = verdict;
863
+ if (picked.toPost.length > 0 && reasonNow() != null)
864
+ record.reasonPosted = reasonNow();
792
865
  // A bare verdict with no comments behind it: nothing is left to post. GitHub created
793
866
  // nothing, so a lift may still go ahead.
794
867
  if (picked.toPost.length === 0) {
@@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm';
2
2
  import { AUTO_FIX_DEFAULT_AUTO_PUSH, AUTO_FIX_DEFAULT_INCLUDE, AUTO_POST_DEFAULT_APPROVE_IMPACT, AUTO_POST_DEFAULT_AUTO_RESOLVE, AUTO_POST_DEFAULT_AUTO_VERDICT, AUTO_POST_DEFAULT_KINDS, AUTO_POST_DEFAULT_SCOPE, } from '../../shared/index.js';
3
3
  import { dropAutoReviews } from './manager.js';
4
4
  import { resolveAutoReviewProviders } from './pair.js';
5
+ import { DEFAULT_VERDICT_RULES, resolveApproveLeftovers, resolvePairStrictness, } from './auto-verdict.js';
5
6
  import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
6
7
  // AUTO CLAUDE REVIEW — THE PER-WORKSPACE SWITCH (CORE since migration 0074 / pg 0061; it lived on
7
8
  // the plugin's `pro_workspace_settings` until Claude Review left the plugin, and plugin migration
@@ -42,6 +43,11 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
42
43
  // `autoResolve` switches (both OFF by default) ride the same overrides JSON (no column), and so
43
44
  // does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
44
45
  //
46
+ // THE AUTO VERDICT'S TWO RULES ride the same route as two more columns (migration 0145), OVERRIDES
47
+ // ONLY (NULL = today's rule): `pair_verdict_strictness` ('relaxed', else strict — wire
48
+ // `pairStrictness`) and `approve_leftovers` ('nits' / 'none', else 'nits_questions' — wire
49
+ // `autoPost.approveLeftovers`). `readVerdictRulesForPr` is what every verdict reads.
50
+ //
45
51
  // TEAM CLAIMS ("Mark auto reviews on GitHub") ride the same route as one more column,
46
52
  // `team_claims_enabled` (migration 0134): may an AUTO review post a status line on the PR's head so
47
53
  // teammates' Limn skips it (team-claim.ts)? OFF by default — only a stored true is on.
@@ -68,9 +74,12 @@ export function resolveAutoReviewDailyCap(stored) {
68
74
  }
69
75
  const AUTO_POST_KIND_KEYS = Object.keys(AUTO_POST_DEFAULT_KINDS);
70
76
  export const APPROVE_IMPACT_KEYS = Object.keys(AUTO_POST_DEFAULT_APPROVE_IMPACT);
77
+ const APPROVE_LEFTOVERS = ['none', 'nits', 'nits_questions'];
71
78
  const isScope = (v) => v === 'mine' || v === 'all';
72
79
  /** The stored switch + overrides → the settings in force. Anything malformed reads as the default. */
73
- export function resolveAutoPostSettings(enabled, stored) {
80
+ export function resolveAutoPostSettings(enabled, stored,
81
+ // `workspaces.approve_leftovers` (migration 0145): its own column, NULL = the default.
82
+ approveLeftovers) {
74
83
  const kinds = { ...AUTO_POST_DEFAULT_KINDS };
75
84
  const sk = stored?.kinds;
76
85
  if (sk != null && typeof sk === 'object') {
@@ -92,6 +101,7 @@ export function resolveAutoPostSettings(enabled, stored) {
92
101
  autoVerdict: typeof stored?.autoVerdict === 'boolean' ? stored.autoVerdict : AUTO_POST_DEFAULT_AUTO_VERDICT,
93
102
  autoResolve: typeof stored?.autoResolve === 'boolean' ? stored.autoResolve : AUTO_POST_DEFAULT_AUTO_RESOLVE,
94
103
  approveImpact,
104
+ approveLeftovers: resolveApproveLeftovers(approveLeftovers),
95
105
  };
96
106
  }
97
107
  /** The settings in force → what to store: only what differs from the product default (null = none). */
@@ -161,7 +171,8 @@ export function autoReviewOf(row) {
161
171
  // ON unless explicitly switched off (0126).
162
172
  autoDiagramsEnabled: row?.autoDiagramsEnabled !== false,
163
173
  autoReviewProviders: resolveAutoReviewProviders(row?.providers),
164
- autoPost: resolveAutoPostSettings(row?.autoPostEnabled, row?.autoPostSettings),
174
+ pairStrictness: resolvePairStrictness(row?.pairStrictness),
175
+ autoPost: resolveAutoPostSettings(row?.autoPostEnabled, row?.autoPostSettings, row?.approveLeftovers),
165
176
  autoFix: resolveAutoFixSettings(row?.autoFixSettings),
166
177
  // OFF unless explicitly switched on (0134).
167
178
  teamClaimsEnabled: row?.teamClaimsEnabled === true,
@@ -175,7 +186,12 @@ export function autoReviewOf(row) {
175
186
  export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
176
187
  const { workspaces: w, workspaceRepos: wr, pullRequests: prs } = ctx.schema;
177
188
  const rows = (await ctx.db
178
- .select({ workspaceId: w.id, enabled: w.autoPostEnabled, stored: w.autoPostSettings })
189
+ .select({
190
+ workspaceId: w.id,
191
+ enabled: w.autoPostEnabled,
192
+ stored: w.autoPostSettings,
193
+ ...verdictRuleCols(w),
194
+ })
179
195
  .from(prs)
180
196
  .innerJoin(wr, and(eq(wr.repoId, prs.repoId), eq(wr.accountId, accountId)))
181
197
  .innerJoin(w, and(eq(w.id, wr.workspaceId), eq(w.accountId, accountId)))
@@ -183,7 +199,28 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
183
199
  .limit(1)
184
200
  .execute());
185
201
  const r = rows[0];
186
- return r ? { workspaceId: r.workspaceId, settings: resolveAutoPostSettings(r.enabled, r.stored) } : null;
202
+ return r
203
+ ? {
204
+ workspaceId: r.workspaceId,
205
+ settings: resolveAutoPostSettings(r.enabled, r.stored, r.approveLeftovers),
206
+ pairStrictness: resolvePairStrictness(r.pairStrictness),
207
+ }
208
+ : null;
209
+ }
210
+ // The two rule columns (migration 0145), when the schema has them (an older test schema may not).
211
+ const verdictRuleCols = (w) => ({
212
+ ...(w.pairVerdictStrictness ? { pairStrictness: w.pairVerdictStrictness } : {}),
213
+ ...(w.approveLeftovers ? { approveLeftovers: w.approveLeftovers } : {}),
214
+ });
215
+ /**
216
+ * THE AUTO VERDICT'S RULES for the workspace holding this PR's repo (strict / relaxed, what may stay
217
+ * open), with whether its auto verdict is on. A PR with no membership row reads the defaults, off.
218
+ */
219
+ export async function readVerdictRulesForPr(ctx, accountId, prId) {
220
+ const ws = await readWorkspaceAutoPostForPr(ctx, accountId, prId);
221
+ if (!ws)
222
+ return { ...DEFAULT_VERDICT_RULES, autoVerdict: false };
223
+ return { pairStrictness: ws.pairStrictness, approveLeftovers: ws.settings.approveLeftovers, autoVerdict: ws.settings.autoVerdict };
187
224
  }
188
225
  /**
189
226
  * Is auto AI Fix on for the workspace holding this PR's repo? Read by `maybeStartAutoFix`. Only a
@@ -221,6 +258,7 @@ export async function readWorkspaceAutoFixSettingsForPr(ctx, accountId, prId) {
221
258
  }
222
259
  const autoCols = (w) => ({
223
260
  providers: w.autoReviewProviders,
261
+ ...verdictRuleCols(w),
224
262
  enabled: w.autoReviewEnabled,
225
263
  enabledAt: w.autoReviewEnabledAt,
226
264
  autoFixEnabled: w.autoFixEnabled,
@@ -262,7 +300,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
262
300
  const existing = rows[0];
263
301
  if (!existing)
264
302
  return null;
265
- const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, dailyCap, autoPost, autoFix, autoReviewProviders } = change;
303
+ const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, dailyCap, autoPost, autoFix, autoReviewProviders, pairStrictness, } = change;
266
304
  const wasOn = existing.enabled === true && toMs(existing.enabledAt) != null;
267
305
  let review;
268
306
  if (enabled === undefined || (enabled && wasOn))
@@ -290,7 +328,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
290
328
  const claims = teamClaimsEnabled ?? claimsWas;
291
329
  const capOverride = dailyCap === undefined ? existing.dailyCap : dailyCap === AUTO_REVIEW_DAILY_CAP ? null : dailyCap;
292
330
  // Auto-posting: each field optional; a kind left out keeps its stored value.
293
- const postBefore = resolveAutoPostSettings(existing.autoPostEnabled, existing.autoPostSettings);
331
+ const postBefore = resolveAutoPostSettings(existing.autoPostEnabled, existing.autoPostSettings, existing.approveLeftovers);
294
332
  const postAfter = {
295
333
  enabled: autoPost?.enabled ?? postBefore.enabled,
296
334
  scope: autoPost?.scope ?? postBefore.scope,
@@ -298,7 +336,13 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
298
336
  autoVerdict: autoPost?.autoVerdict ?? postBefore.autoVerdict,
299
337
  autoResolve: autoPost?.autoResolve ?? postBefore.autoResolve,
300
338
  approveImpact: { ...postBefore.approveImpact },
339
+ approveLeftovers: autoPost?.approveLeftovers ?? postBefore.approveLeftovers,
301
340
  };
341
+ // The leftovers rule is its own column (migration 0145): NULL = the default.
342
+ const leftoversStored = postAfter.approveLeftovers === 'nits_questions' ? null : postAfter.approveLeftovers;
343
+ // How a pair's verdict folds (migration 0145): NULL = strict.
344
+ const strictnessWas = resolvePairStrictness(existing.pairStrictness);
345
+ const strictness = pairStrictness ?? strictnessWas;
302
346
  for (const k of AUTO_POST_KIND_KEYS) {
303
347
  const v = autoPost?.kinds?.[k];
304
348
  if (typeof v === 'boolean')
@@ -327,6 +371,8 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
327
371
  const providers = autoReviewProviders ?? providersWas;
328
372
  const next = {
329
373
  providers: providers === 'both' ? 'both' : null,
374
+ pairStrictness: strictness === 'relaxed' ? 'relaxed' : null,
375
+ approveLeftovers: leftoversStored,
330
376
  enabled: review.enabled,
331
377
  enabledAt: review.enabledAt,
332
378
  autoFixEnabled: fix,
@@ -357,6 +403,12 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
357
403
  if (autoReviewProviders !== undefined && providers !== providersWas) {
358
404
  set.autoReviewProviders = providers === 'both' ? 'both' : null;
359
405
  }
406
+ if (pairStrictness !== undefined && strictness !== strictnessWas) {
407
+ set.pairVerdictStrictness = strictness === 'relaxed' ? 'relaxed' : null;
408
+ }
409
+ if (autoPost?.approveLeftovers !== undefined && postAfter.approveLeftovers !== postBefore.approveLeftovers) {
410
+ set.approveLeftovers = leftoversStored;
411
+ }
360
412
  if (autoPost !== undefined) {
361
413
  if (postAfter.enabled !== (existing.autoPostEnabled === true))
362
414
  set.autoPostEnabled = postAfter.enabled;
@@ -409,6 +461,25 @@ function settingChanges(set, existing, next) {
409
461
  const now = resolveAutoFixSettings(next.autoFixSettings).autoPush;
410
462
  out.push(['auto_fix', was !== now ? `Auto fix push ${onOff(now)}` : 'Auto fix contents changed']);
411
463
  }
464
+ if ('pairVerdictStrictness' in set) {
465
+ out.push([
466
+ 'auto_post',
467
+ next.pairStrictness === 'relaxed'
468
+ ? 'Claude + Codex verdict: relaxed (approve if one approves and neither asks for changes)'
469
+ : 'Claude + Codex verdict: strict (approve only if both approve)',
470
+ ]);
471
+ }
472
+ if ('approveLeftovers' in set) {
473
+ const now = resolveApproveLeftovers(next.approveLeftovers);
474
+ out.push([
475
+ 'auto_post',
476
+ now === 'none'
477
+ ? 'Auto-approve: only with nothing open'
478
+ : now === 'nits'
479
+ ? 'Auto-approve: nits may stay open'
480
+ : 'Auto-approve: nits and questions may stay open',
481
+ ]);
482
+ }
412
483
  if ('autoPostEnabled' in set)
413
484
  out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled === true)}`]);
414
485
  else if ('autoPostSettings' in set)
@@ -560,6 +631,7 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
560
631
  autoDiagramsEnabled: { type: 'boolean' },
561
632
  teamClaimsEnabled: { type: 'boolean' },
562
633
  autoReviewProviders: { type: 'string', enum: ['default', 'both'] },
634
+ pairStrictness: { type: 'string', enum: ['strict', 'relaxed'] },
563
635
  // An integer 1..500; anything else (a fraction, a string, out of range) is a 400.
564
636
  dailyCap: {
565
637
  type: 'integer',
@@ -587,6 +659,7 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
587
659
  additionalProperties: false,
588
660
  properties: Object.fromEntries(APPROVE_IMPACT_KEYS.map((k) => [k, { type: 'boolean' }])),
589
661
  },
662
+ approveLeftovers: { type: 'string', enum: APPROVE_LEFTOVERS },
590
663
  },
591
664
  },
592
665
  autoFix: {